From e676af69a08b0f7d214eb6fb45229ec94b5dad8d Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 03:06:48 +0500 Subject: [PATCH 001/466] 82307: debounce autocomplete query and memoize search options --- .../Search/SearchAutocompleteList.tsx | 22 ++++++++++++--- .../Search/SearchRouter/SearchRouter.tsx | 6 ++--- src/libs/OptionsListUtils/index.ts | 15 +++++------ tests/perf-test/OptionsListUtils.perf-test.ts | 27 +++++++++++++++++++ tests/perf-test/SearchRouter.perf-test.tsx | 25 +++++++++++++++++ 5 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index e504a7b08b6d..486c06beb44b 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -188,7 +188,7 @@ function SearchAutocompleteList({ } }, [contextAreOptionsInitialized]); - const searchOptions = (() => { + const searchOptions = useMemo(() => { if (!areOptionsInitialized) { return defaultListOptions; } @@ -214,7 +214,21 @@ function SearchAutocompleteList({ policyCollection: policies, personalDetails, }); - })(); + }, [ + areOptionsInitialized, + options, + draftComments, + nvpDismissedProductTraining, + betas, + autocompleteQueryValue, + countryCode, + loginList, + visibleReportActionsData, + currentUserAccountID, + currentUserEmail, + policies, + personalDetails, + ]); const [isInitialRender, setIsInitialRender] = useState(true); const prevQueryRef = useRef(autocompleteQueryValue); @@ -324,7 +338,7 @@ function SearchAutocompleteList({ }; }); - const recentReportsOptions = (() => { + const recentReportsOptions = useMemo(() => { if (autocompleteQueryValue.trim() === '') { return searchOptions.recentReports; } @@ -340,7 +354,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - })(); + }, [autocompleteQueryValue, searchOptions]); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 94f53cad8c9e..59accbefc274 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // The actual input text that the user sees const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); - // The input text that was last used for autocomplete; needed for the SearchAutocompleteList when browsing list via arrow keys - const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(textInputValue); + // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list + const [autocompleteQueryValue, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); const [selection, setSelection] = useState({start: textInputValue.length, end: textInputValue.length}); const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const textInputRef = useRef(null); @@ -348,7 +348,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla /> (), isReportChatRoom = false): boolean { - const searchWords = new Set(searchValue.replaceAll(',', ' ').split(/\s+/)); + const searchWords = Array.from(new Set(searchValue.replaceAll(',', ' ').split(/\s+/).filter(Boolean))); const valueToSearch = searchText?.replaceAll(new RegExp(/ /g), ''); - let matching = true; - for (const word of searchWords) { - // if one of the word is not matching, we don't need to check further - if (!matching) { - continue; + const compiledRegexes = searchWords.map((word) => ({word, regex: new RegExp(Str.escapeForRegExp(word), 'i')})); + for (const {word, regex} of compiledRegexes) { + if (!(regex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)))) { + return false; } - const matchRegex = new RegExp(Str.escapeForRegExp(word), 'i'); - matching = matchRegex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)); } - return matching; + return true; } function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchValue: string) { diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 07e9c0cb3f96..bbc3efee6d96 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -20,6 +20,9 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const REPORTS_COUNT = 5000; const PERSONAL_DETAILS_LIST_COUNT = 1000; +// Larger dataset used specifically to measure the isSearchStringMatch RegExp optimization +const LARGE_REPORTS_COUNT = 10000; +const LARGE_PERSONAL_DETAILS_COUNT = 2000; const SEARCH_VALUE = 'Report'; const COUNTRY_CODE = 1; @@ -289,6 +292,30 @@ describe('OptionsListUtils', () => { ); }); + // This test directly measures the isSearchStringMatch hot path. + // A multi-word query forces one RegExp creation per word per item on main; + // on the PR branch RegExps are compiled once per call, so duration drops significantly. + test('[OptionsListUtils] filterAndOrderOptions with multi-word search on large dataset', async () => { + const largePersonalDetails = getMockedPersonalDetails(LARGE_PERSONAL_DETAILS_COUNT); + const largeReports = getMockedReports(LARGE_REPORTS_COUNT); + const largeOptionList = createOptionList(largePersonalDetails, EMPTY_PRIVATE_IS_ARCHIVED_MAP, largeReports, undefined); + + const formattedOptions = getValidOptions( + {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, + allPolicies, + {}, + nvpDismissedProductTraining, + loginList, + MOCK_CURRENT_USER_ACCOUNT_ID, + MOCK_CURRENT_USER_EMAIL, + ValidOptionsConfig, + ); + + await measureFunction(() => { + filterAndOrderOptions(formattedOptions, 'Email Report Five', COUNTRY_CODE, loginList, MOCK_CURRENT_USER_EMAIL, MOCK_CURRENT_USER_ACCOUNT_ID, largePersonalDetails); + }); + }); + test('[OptionsListUtils] getSearchOptions with searchTerm', async () => { await waitForBatchedUpdates(); const optionLists = createFilteredOptionList(personalDetails, mockedReportsMap, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 01d60b954aa6..5e3c1d58088b 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -190,3 +190,28 @@ test('[SearchRouter] should react to text input changes', async () => { ) .then(() => measureRenders(, {scenario})); }); + +test('[SearchRouter] should re-render minimally when typing into the full router with autocomplete list', async () => { + const scenario = async () => { + const input = await screen.findByTestId('search-autocomplete-text-input'); + fireEvent.changeText(input, 'E'); + fireEvent.changeText(input, 'Em'); + fireEvent.changeText(input, 'Ema'); + fireEvent.changeText(input, 'Emai'); + fireEvent.changeText(input, 'Email'); + fireEvent.changeText(input, 'Email F'); + fireEvent.changeText(input, 'Email Fi'); + fireEvent.changeText(input, 'Email Five'); + }; + + return waitForBatchedUpdates() + .then(() => + Onyx.multiSet({ + ...mockedReports, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails, + [ONYXKEYS.BETAS]: mockedBetas, + [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: true, + }), + ) + .then(() => measureRenders(, {scenario})); +}); From 3bbdbca4fb7b621d174ad85b0beaaca8afb31f34 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 03:26:59 +0500 Subject: [PATCH 002/466] 82307: debounce autocomplete query and memoize search options --- .../Search/SearchAutocompleteList.tsx | 22 ++++++++++++--- .../Search/SearchRouter/SearchRouter.tsx | 6 ++--- src/libs/OptionsListUtils/index.ts | 15 +++++------ tests/perf-test/OptionsListUtils.perf-test.ts | 27 +++++++++++++++++++ tests/perf-test/SearchRouter.perf-test.tsx | 25 +++++++++++++++++ 5 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index e504a7b08b6d..486c06beb44b 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -188,7 +188,7 @@ function SearchAutocompleteList({ } }, [contextAreOptionsInitialized]); - const searchOptions = (() => { + const searchOptions = useMemo(() => { if (!areOptionsInitialized) { return defaultListOptions; } @@ -214,7 +214,21 @@ function SearchAutocompleteList({ policyCollection: policies, personalDetails, }); - })(); + }, [ + areOptionsInitialized, + options, + draftComments, + nvpDismissedProductTraining, + betas, + autocompleteQueryValue, + countryCode, + loginList, + visibleReportActionsData, + currentUserAccountID, + currentUserEmail, + policies, + personalDetails, + ]); const [isInitialRender, setIsInitialRender] = useState(true); const prevQueryRef = useRef(autocompleteQueryValue); @@ -324,7 +338,7 @@ function SearchAutocompleteList({ }; }); - const recentReportsOptions = (() => { + const recentReportsOptions = useMemo(() => { if (autocompleteQueryValue.trim() === '') { return searchOptions.recentReports; } @@ -340,7 +354,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - })(); + }, [autocompleteQueryValue, searchOptions]); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 94f53cad8c9e..59accbefc274 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // The actual input text that the user sees const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); - // The input text that was last used for autocomplete; needed for the SearchAutocompleteList when browsing list via arrow keys - const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(textInputValue); + // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list + const [autocompleteQueryValue, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); const [selection, setSelection] = useState({start: textInputValue.length, end: textInputValue.length}); const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const textInputRef = useRef(null); @@ -348,7 +348,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla /> (), isReportChatRoom = false): boolean { - const searchWords = new Set(searchValue.replaceAll(',', ' ').split(/\s+/)); + const searchWords = Array.from(new Set(searchValue.replaceAll(',', ' ').split(/\s+/).filter(Boolean))); const valueToSearch = searchText?.replaceAll(new RegExp(/ /g), ''); - let matching = true; - for (const word of searchWords) { - // if one of the word is not matching, we don't need to check further - if (!matching) { - continue; + const compiledRegexes = searchWords.map((word) => ({word, regex: new RegExp(Str.escapeForRegExp(word), 'i')})); + for (const {word, regex} of compiledRegexes) { + if (!(regex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)))) { + return false; } - const matchRegex = new RegExp(Str.escapeForRegExp(word), 'i'); - matching = matchRegex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)); } - return matching; + return true; } function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchValue: string) { diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 07e9c0cb3f96..4b3fe60e2323 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -20,6 +20,9 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const REPORTS_COUNT = 5000; const PERSONAL_DETAILS_LIST_COUNT = 1000; +// Larger dataset used specifically to measure the isSearchStringMatch RegExp optimization +const LARGE_REPORTS_COUNT = 40000; +const LARGE_PERSONAL_DETAILS_COUNT = 5000; const SEARCH_VALUE = 'Report'; const COUNTRY_CODE = 1; @@ -289,6 +292,30 @@ describe('OptionsListUtils', () => { ); }); + // This test directly measures the isSearchStringMatch hot path. + // A multi-word query forces one RegExp creation per word per item on main; + // on the PR branch RegExps are compiled once per call, so duration drops significantly. + test('[OptionsListUtils] filterAndOrderOptions with multi-word search on large dataset', async () => { + const largePersonalDetails = getMockedPersonalDetails(LARGE_PERSONAL_DETAILS_COUNT); + const largeReports = getMockedReports(LARGE_REPORTS_COUNT); + const largeOptionList = createOptionList(largePersonalDetails, EMPTY_PRIVATE_IS_ARCHIVED_MAP, largeReports, undefined); + + const formattedOptions = getValidOptions( + {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, + allPolicies, + {}, + nvpDismissedProductTraining, + loginList, + MOCK_CURRENT_USER_ACCOUNT_ID, + MOCK_CURRENT_USER_EMAIL, + ValidOptionsConfig, + ); + + await measureFunction(() => { + filterAndOrderOptions(formattedOptions, 'Email Report Five', COUNTRY_CODE, loginList, MOCK_CURRENT_USER_EMAIL, MOCK_CURRENT_USER_ACCOUNT_ID, largePersonalDetails); + }); + }); + test('[OptionsListUtils] getSearchOptions with searchTerm', async () => { await waitForBatchedUpdates(); const optionLists = createFilteredOptionList(personalDetails, mockedReportsMap, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, { diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 01d60b954aa6..5e3c1d58088b 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -190,3 +190,28 @@ test('[SearchRouter] should react to text input changes', async () => { ) .then(() => measureRenders(, {scenario})); }); + +test('[SearchRouter] should re-render minimally when typing into the full router with autocomplete list', async () => { + const scenario = async () => { + const input = await screen.findByTestId('search-autocomplete-text-input'); + fireEvent.changeText(input, 'E'); + fireEvent.changeText(input, 'Em'); + fireEvent.changeText(input, 'Ema'); + fireEvent.changeText(input, 'Emai'); + fireEvent.changeText(input, 'Email'); + fireEvent.changeText(input, 'Email F'); + fireEvent.changeText(input, 'Email Fi'); + fireEvent.changeText(input, 'Email Five'); + }; + + return waitForBatchedUpdates() + .then(() => + Onyx.multiSet({ + ...mockedReports, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails, + [ONYXKEYS.BETAS]: mockedBetas, + [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: true, + }), + ) + .then(() => measureRenders(, {scenario})); +}); From 4a55f1a91bd5a581e4e5e7e88e3d51e412d19146 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 04:19:21 +0500 Subject: [PATCH 003/466] fixed eslint issues --- src/components/Search/SearchRouter/SearchRouter.tsx | 4 ++-- tests/perf-test/SearchRouter.perf-test.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 59accbefc274..6e9d0d67b97c 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // The actual input text that the user sees const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); - // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list - const [autocompleteQueryValue, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + // Debounced value gates expensive filtering in the autocomplete list + const [, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); const [selection, setSelection] = useState({start: textInputValue.length, end: textInputValue.length}); const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const textInputRef = useRef(null); diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 5e3c1d58088b..610ddbc629f7 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -194,14 +194,14 @@ test('[SearchRouter] should react to text input changes', async () => { test('[SearchRouter] should re-render minimally when typing into the full router with autocomplete list', async () => { const scenario = async () => { const input = await screen.findByTestId('search-autocomplete-text-input'); - fireEvent.changeText(input, 'E'); - fireEvent.changeText(input, 'Em'); - fireEvent.changeText(input, 'Ema'); - fireEvent.changeText(input, 'Emai'); fireEvent.changeText(input, 'Email'); - fireEvent.changeText(input, 'Email F'); - fireEvent.changeText(input, 'Email Fi'); + fireEvent.changeText(input, 'Email Four'); + fireEvent.changeText(input, 'Email'); fireEvent.changeText(input, 'Email Five'); + fireEvent.changeText(input, 'Report'); + fireEvent.changeText(input, 'Report One'); + fireEvent.changeText(input, 'Report'); + fireEvent.changeText(input, 'Report Two'); }; return waitForBatchedUpdates() From 9b669fefa2f454f633940f42a83191e0d7524023 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 04:24:42 +0500 Subject: [PATCH 004/466] fixed eslint issues --- .../Search/SearchRouter/SearchRouter.tsx | 4 ++-- tests/perf-test/SearchRouter.perf-test.tsx | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 6e9d0d67b97c..59accbefc274 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // The actual input text that the user sees const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); - // Debounced value gates expensive filtering in the autocomplete list - const [, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list + const [autocompleteQueryValue, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); const [selection, setSelection] = useState({start: textInputValue.length, end: textInputValue.length}); const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const textInputRef = useRef(null); diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 610ddbc629f7..3779189a34c0 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -194,14 +194,14 @@ test('[SearchRouter] should react to text input changes', async () => { test('[SearchRouter] should re-render minimally when typing into the full router with autocomplete list', async () => { const scenario = async () => { const input = await screen.findByTestId('search-autocomplete-text-input'); - fireEvent.changeText(input, 'Email'); - fireEvent.changeText(input, 'Email Four'); - fireEvent.changeText(input, 'Email'); - fireEvent.changeText(input, 'Email Five'); - fireEvent.changeText(input, 'Report'); - fireEvent.changeText(input, 'Report One'); + fireEvent.changeText(input, 'R'); + fireEvent.changeText(input, 'Re'); + fireEvent.changeText(input, 'Rep'); + fireEvent.changeText(input, 'Repo'); fireEvent.changeText(input, 'Report'); - fireEvent.changeText(input, 'Report Two'); + fireEvent.changeText(input, 'Report F'); + fireEvent.changeText(input, 'Report Fi'); + fireEvent.changeText(input, 'Report Five'); }; return waitForBatchedUpdates() From 3e4067cd062e528995a0eb148334f20b40e71fa3 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 04:42:30 +0500 Subject: [PATCH 005/466] fixed lint issue --- src/components/Search/SearchRouter/SearchRouter.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 59accbefc274..6e9d0d67b97c 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -78,8 +78,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // The actual input text that the user sees const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); - // Immediate value drives arrow-key navigation and contextual logic; debounced value gates expensive filtering in the autocomplete list - const [autocompleteQueryValue, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + // Debounced value gates expensive filtering in the autocomplete list + const [, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); const [selection, setSelection] = useState({start: textInputValue.length, end: textInputValue.length}); const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const textInputRef = useRef(null); From c4c2f9ada7d101da5549785f0eabb0bac97fce43 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 2 Apr 2026 05:07:05 +0500 Subject: [PATCH 006/466] fixed AI feedback --- src/components/Search/SearchRouter/SearchRouter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 6e9d0d67b97c..0a8ebd6cba41 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -348,7 +348,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla /> Date: Fri, 3 Apr 2026 00:44:53 +0500 Subject: [PATCH 007/466] Fixed eslint warnings --- .../Search/SearchAutocompleteList.tsx | 22 ++++--------------- .../Search/SearchRouter/SearchRouter.tsx | 4 ++-- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 486c06beb44b..e504a7b08b6d 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -188,7 +188,7 @@ function SearchAutocompleteList({ } }, [contextAreOptionsInitialized]); - const searchOptions = useMemo(() => { + const searchOptions = (() => { if (!areOptionsInitialized) { return defaultListOptions; } @@ -214,21 +214,7 @@ function SearchAutocompleteList({ policyCollection: policies, personalDetails, }); - }, [ - areOptionsInitialized, - options, - draftComments, - nvpDismissedProductTraining, - betas, - autocompleteQueryValue, - countryCode, - loginList, - visibleReportActionsData, - currentUserAccountID, - currentUserEmail, - policies, - personalDetails, - ]); + })(); const [isInitialRender, setIsInitialRender] = useState(true); const prevQueryRef = useRef(autocompleteQueryValue); @@ -338,7 +324,7 @@ function SearchAutocompleteList({ }; }); - const recentReportsOptions = useMemo(() => { + const recentReportsOptions = (() => { if (autocompleteQueryValue.trim() === '') { return searchOptions.recentReports; } @@ -354,7 +340,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, searchOptions]); + })(); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 0a8ebd6cba41..b9cb30125288 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -216,7 +216,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setAutocompleteSubstitutions(updatedSubstitutionsMap); } }, - [autocompleteSubstitutions, setTextInputValue, textInputValue], + [autocompleteSubstitutions, setAutocompleteQueryValue, setTextInputValue, textInputValue], ); const submitSearch = useCallback( @@ -239,7 +239,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla setTextInputValue(''); setAutocompleteQueryValue(''); }, - [autocompleteSubstitutions, onRouterClose, setTextInputValue, setShouldResetSearchQuery], + [autocompleteSubstitutions, onRouterClose, setAutocompleteQueryValue, setTextInputValue, setShouldResetSearchQuery], ); const onListItemPress = useCallback( From 81ffc89d887230935ce586a7ad6e49740f6e7465 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 8 Apr 2026 23:12:32 +0500 Subject: [PATCH 008/466] Added unit test cases coverage for search matching normalization --- tests/unit/OptionsListUtilsTest.tsx | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 79a7807d2506..8ded912eb7eb 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3231,6 +3231,47 @@ describe('OptionsListUtils', () => { // Then the self dm should be on top. expect(filteredOptions.recentReports.at(0)?.isSelfDM).toBe(true); }); + + it('should return the same matches for normalized multi-word queries with extra spaces', () => { + const options = getSearchOptions({ + options: OPTIONS, + reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, + draftComments: {}, + nvpDismissedProductTraining, + loginList, + betas: [CONST.BETAS.ALL], + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserEmail: CURRENT_USER_EMAIL, + policyCollection: allPolicies, + personalDetails: PERSONAL_DETAILS, + }); + + const multiSpaceQueryResults = filterAndOrderOptions( + options, + 'Invisible Woman', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + ); + const spaceSeparatedQueryResults = filterAndOrderOptions( + options, + 'Invisible Woman', + COUNTRY_CODE, + loginList, + CURRENT_USER_EMAIL, + CURRENT_USER_ACCOUNT_ID, + PERSONAL_DETAILS, + ); + + expect(multiSpaceQueryResults.recentReports.map((option) => option.reportID)).toEqual( + spaceSeparatedQueryResults.recentReports.map((option) => option.reportID), + ); + expect(multiSpaceQueryResults.personalDetails.map((option) => option.accountID)).toEqual( + spaceSeparatedQueryResults.personalDetails.map((option) => option.accountID), + ); + }); }); describe('canCreateOptimisticPersonalDetailOption()', () => { From c447ddce2761677d1855258ce81a1a79943ae4a6 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 8 Apr 2026 23:23:06 +0500 Subject: [PATCH 009/466] Updated SearchRouter to prevent raw input from bypassing debounce --- src/components/Search/SearchRouter/SearchRouter.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 51448498b3c8..00d353204bbe 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -347,7 +347,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla /> Date: Wed, 8 Apr 2026 23:25:25 +0500 Subject: [PATCH 010/466] Fixed prettier issues --- tests/unit/OptionsListUtilsTest.tsx | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 8ded912eb7eb..8f01b222c5b1 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3246,31 +3246,11 @@ describe('OptionsListUtils', () => { personalDetails: PERSONAL_DETAILS, }); - const multiSpaceQueryResults = filterAndOrderOptions( - options, - 'Invisible Woman', - COUNTRY_CODE, - loginList, - CURRENT_USER_EMAIL, - CURRENT_USER_ACCOUNT_ID, - PERSONAL_DETAILS, - ); - const spaceSeparatedQueryResults = filterAndOrderOptions( - options, - 'Invisible Woman', - COUNTRY_CODE, - loginList, - CURRENT_USER_EMAIL, - CURRENT_USER_ACCOUNT_ID, - PERSONAL_DETAILS, - ); + const multiSpaceQueryResults = filterAndOrderOptions(options, 'Invisible Woman', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); + const spaceSeparatedQueryResults = filterAndOrderOptions(options, 'Invisible Woman', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); - expect(multiSpaceQueryResults.recentReports.map((option) => option.reportID)).toEqual( - spaceSeparatedQueryResults.recentReports.map((option) => option.reportID), - ); - expect(multiSpaceQueryResults.personalDetails.map((option) => option.accountID)).toEqual( - spaceSeparatedQueryResults.personalDetails.map((option) => option.accountID), - ); + expect(multiSpaceQueryResults.recentReports.map((option) => option.reportID)).toEqual(spaceSeparatedQueryResults.recentReports.map((option) => option.reportID)); + expect(multiSpaceQueryResults.personalDetails.map((option) => option.accountID)).toEqual(spaceSeparatedQueryResults.personalDetails.map((option) => option.accountID)); }); }); From 4bbbec926f10d32ebb39931af9060c948fdace86 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 9 Apr 2026 02:13:57 +0500 Subject: [PATCH 011/466] Fixed AI feedbacks --- src/components/Search/SearchRouter/SearchRouter.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 00d353204bbe..dfe4f37a92ba 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -76,7 +76,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass']); // The actual input text that the user sees - const [textInputValue, , setTextInputValue] = useDebouncedState('', 500); + const [textInputValue, setTextInputValue] = useState(''); // Debounced value gates expensive filtering in the autocomplete list const [, debouncedAutocompleteQueryValue, setAutocompleteQueryValue] = useDebouncedState('', CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); const [selection, setSelection] = useState({start: textInputValue.length, end: textInputValue.length}); @@ -326,6 +326,13 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla isFullWidth={shouldUseNarrowLayout} onSearchQueryChange={onSearchQueryChange} onSubmit={() => { + // If user submits before debounce catches up, submit the typed query directly + // instead of selecting a stale focused list item from the previous query. + if (textInputValue && textInputValue !== debouncedAutocompleteQueryValue) { + submitSearch(textInputValue); + return; + } + const focusedOption = listRef.current?.getFocusedOption?.(); if (!focusedOption) { From 500aec86b1806e530abadbf6ac7f83bf785f55e4 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Tue, 21 Apr 2026 22:59:09 +0500 Subject: [PATCH 012/466] Fixed types issue --- tests/unit/OptionsListUtilsTest.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 3fc48a533fc5..c560aafcc74a 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3488,6 +3488,8 @@ describe('OptionsListUtils', () => { currentUserEmail: CURRENT_USER_EMAIL, policyCollection: allPolicies, personalDetails: PERSONAL_DETAILS, + sortedActions: undefined, + conciergeReportID: undefined, }); const multiSpaceQueryResults = filterAndOrderOptions(options, 'Invisible Woman', COUNTRY_CODE, loginList, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, PERSONAL_DETAILS); From a1c33cb13960ef8952c9552b51e5154f0a5858a4 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 6 May 2026 20:08:25 +0500 Subject: [PATCH 013/466] Fixed recent search flicker issue --- .../Search/SearchAutocompleteList.tsx | 35 ++++++++++++------- .../Search/SearchRouter/SearchRouter.tsx | 1 + 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 23bd7dfb9bf4..1b72ed121732 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -51,6 +51,8 @@ type GetAdditionalSectionsCallback = (options: Options, sectionIndex: number) => type SearchAutocompleteListProps = { /** Value of TextInput */ autocompleteQueryValue: string; + /** Immediate (non-debounced) query from the input for UI-only behavior */ + inputQueryValue?: string; /** Callback to trigger search action * */ handleSearch: (value: string) => void; @@ -130,6 +132,7 @@ function SearchRouterItem(props: UserListItemProps | Searc function SearchAutocompleteList({ autocompleteQueryValue, + inputQueryValue, handleSearch, searchQueryItem, getAdditionalSections, @@ -161,6 +164,8 @@ function SearchAutocompleteList({ const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const allCards = personalAndWorkspaceCards ?? CONST.EMPTY_OBJECT; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const effectiveInputQueryValue = inputQueryValue ?? autocompleteQueryValue; + const hasEffectiveInputQuery = effectiveInputQueryValue.trim() !== ''; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; @@ -235,7 +240,7 @@ function SearchAutocompleteList({ ]); const [isInitialRender, setIsInitialRender] = useState(true); - const prevQueryRef = useRef(autocompleteQueryValue); + const prevQueryRef = useRef(effectiveInputQueryValue); const innerListRef = useRef(null); const hasSetInitialFocusRef = useRef(false); @@ -257,11 +262,11 @@ function SearchAutocompleteList({ return; } - const queryChanged = prevQueryRef.current !== autocompleteQueryValue; - prevQueryRef.current = autocompleteQueryValue; + const queryChanged = prevQueryRef.current !== effectiveInputQueryValue; + prevQueryRef.current = effectiveInputQueryValue; if (queryChanged) { - if (autocompleteQueryValue === '') { + if (effectiveInputQueryValue === '') { // When query is cleared, reset the initial focus guard so the initial focus // effect can re-fire and correctly focus the first focusable item (skipping section headers). hasSetInitialFocusRef.current = false; @@ -271,7 +276,7 @@ function SearchAutocompleteList({ innerListRef.current?.updateAndScrollToFocusedIndex(0, true); } } - }, [autocompleteQueryValue, isInitialRender]); + }, [effectiveInputQueryValue, isInitialRender]); // Track external text input focus to prevent list items from stealing focus while typing useEffect(() => { @@ -289,7 +294,7 @@ function SearchAutocompleteList({ // Note: We can't easily subscribe to focus/blur events on the ref, so we update on query changes // which happen when the user types (meaning input is focused) - }, [textInputRef, autocompleteQueryValue]); + }, [textInputRef, effectiveInputQueryValue]); const autocompleteSuggestions = useAutocompleteSuggestions({ autocompleteQueryValue, @@ -360,10 +365,15 @@ function SearchAutocompleteList({ ]); const recentReportsOptions = useMemo(() => { - if (autocompleteQueryValue.trim() === '') { + if (!hasEffectiveInputQuery) { return searchOptions.recentReports; } + // User typed but debounce has not emitted yet; avoid showing stale empty-query results. + if (autocompleteQueryValue.trim() === '') { + return []; + } + const orderedOptions = combineOrderingOfReportsAndPersonalDetails(searchOptions, autocompleteQueryValue, { sortByReportTypeInSearch: true, preferChatRoomsOverThreads: true, @@ -375,7 +385,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, searchOptions]); + }, [autocompleteQueryValue, hasEffectiveInputQuery, searchOptions]); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { @@ -419,7 +429,7 @@ function SearchAutocompleteList({ } } - if (!autocompleteQueryValue && recentSearchesData && recentSearchesData.length > 0) { + if (!hasEffectiveInputQuery && recentSearchesData && recentSearchesData.length > 0) { pushSection({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); } @@ -441,18 +451,18 @@ function SearchAutocompleteList({ if (!isLoadingOptions) { pushSection({ - title: autocompleteQueryValue.trim() === '' ? translate('search.recentChats') : undefined, + title: !hasEffectiveInputQuery ? translate('search.recentChats') : undefined, data: nextStyledRecentReports, sectionIndex: sectionIndex++, }); - } else if (autocompleteQueryValue.trim() !== '' && nextStyledRecentReports.length > 0) { + } else if (hasEffectiveInputQuery && nextStyledRecentReports.length > 0) { // When options aren't fully initialized but we have a search query with available results, // render them immediately so they're selectable instead of hiding the section entirely. pushSection({ data: nextStyledRecentReports, sectionIndex: sectionIndex++, }); - } else if (autocompleteQueryValue.trim() === '') { + } else if (!hasEffectiveInputQuery) { pushSection({ title: translate('search.recentChats'), data: [], @@ -491,6 +501,7 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ autocompleteQueryValue, + hasEffectiveInputQuery, autocompleteSuggestions, expensifyIcons, getAdditionalSections, diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index e4ed960d36c3..11a96bf54ef6 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -392,6 +392,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla Date: Wed, 6 May 2026 20:36:21 +0500 Subject: [PATCH 014/466] Fixed Typesrcipt issues --- src/components/Search/SearchAutocompleteList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 1b72ed121732..79e65cae80eb 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -500,7 +500,6 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ - autocompleteQueryValue, hasEffectiveInputQuery, autocompleteSuggestions, expensifyIcons, From 478dcf2fa3632f5e00d59cec809a43aa19a24a84 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 6 May 2026 21:06:16 +0500 Subject: [PATCH 015/466] fixed eslint issues --- tests/perf-test/OptionsListUtils.perf-test.ts | 5 +++-- tests/unit/OptionsListUtilsTest.tsx | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 5b86b61b8f80..b5ebcdf34109 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -1,5 +1,6 @@ import {rand} from '@ngneat/falso'; import type * as NativeNavigation from '@react-navigation/native'; +import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {measureFunction} from 'reassure'; import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; @@ -8,6 +9,7 @@ import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Policy} from '@src/types/onyx'; +import type Login from '@src/types/onyx/Login'; import type Report from '@src/types/onyx/Report'; import {formatSectionsFromSearchTerm} from '../../src/libs/OptionsListUtils'; import createCollection from '../utils/collections/createCollection'; @@ -109,7 +111,7 @@ const ValidOptionsConfig = { sortedActions: undefined, }; -const loginList = {}; +const loginList: OnyxEntry = {}; /* GetOption is the private function and is never called directly, we are testing the functions which call getOption with different params */ describe('OptionsListUtils', () => { @@ -304,7 +306,6 @@ describe('OptionsListUtils', () => { {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, allPolicies, {}, - nvpDismissedProductTraining, loginList, MOCK_CURRENT_USER_ACCOUNT_ID, MOCK_CURRENT_USER_EMAIL, diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 0b9aeac50861..718301d703a5 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -77,6 +77,7 @@ import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; +import type Login from '@src/types/onyx/Login'; import type {PersonalDetails, Policy, Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; @@ -662,7 +663,7 @@ describe('OptionsListUtils', () => { }, ]; - const loginList = {}; + const loginList: OnyxEntry = {}; const CURRENT_USER_ACCOUNT_ID = 2; const CURRENT_USER_EMAIL = 'tonystark@expensify.com'; @@ -3302,7 +3303,6 @@ describe('OptionsListUtils', () => { options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, draftComments: {}, - nvpDismissedProductTraining, loginList, betas: [CONST.BETAS.ALL], currentUserAccountID: CURRENT_USER_ACCOUNT_ID, From b333029070d11692ab66768296e1075af2a6fbeb Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 6 May 2026 21:27:31 +0500 Subject: [PATCH 016/466] Fixed prettier issues --- tests/unit/OptionsListUtilsTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 718301d703a5..14f2b7911bad 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -77,10 +77,10 @@ import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; -import type Login from '@src/types/onyx/Login'; import type {PersonalDetails, Policy, Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; +import type Login from '@src/types/onyx/Login'; import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport, createRegularChat} from '../utils/collections/reports'; import createRandomTransaction from '../utils/collections/transaction'; From f434f434ac3b1ff9161754e42806251e5eb2ee02 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Thu, 7 May 2026 14:01:19 +0500 Subject: [PATCH 017/466] Fixed Feedbacks --- src/components/Search/SearchAutocompleteList.tsx | 9 ++------- .../Search/SearchRouter/SearchRouter.tsx | 15 ++++++++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index cd9729181031..f340872d43f4 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -10,7 +10,6 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import type {Section, SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDebounce from '@hooks/useDebounce'; import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; import useFilteredOptions from '@hooks/useFilteredOptions'; @@ -395,17 +394,13 @@ function SearchAutocompleteList({ return reportOptions.slice(0, 20); }, [autocompleteQueryValue, hasEffectiveInputQuery, searchOptions]); - const debounceHandleSearch = useDebounce(() => { + useEffect(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { return; } handleSearch(autocompleteQueryWithoutFilters); - }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); - - useEffect(() => { - debounceHandleSearch(); - }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); + }, [autocompleteQueryWithoutFilters, handleSearch]); const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'SearchAutocompleteList', diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 25621ae835a8..65e5e52cf70a 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -384,15 +384,20 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla isFullWidth={shouldUseNarrowLayout} onSearchQueryChange={onSearchQueryChange} onSubmit={() => { - // If user submits before debounce catches up, submit the typed query directly - // instead of selecting a stale focused list item from the previous query. - if (textInputValue && textInputValue !== debouncedAutocompleteQueryValue) { + const focusedOption = listRef.current?.getFocusedOption?.(); + const isInputAheadOfDebounce = !!textInputValue && textInputValue !== debouncedAutocompleteQueryValue; + + // During the debounce window, keep keyboard behavior for focused search rows + // (e.g. Ask Concierge / typed query row), but avoid stale non-search row submits. + if (isInputAheadOfDebounce) { + if (focusedOption && isSearchQueryItem(focusedOption)) { + onListItemPress(focusedOption); + return; + } submitSearch(textInputValue); return; } - const focusedOption = listRef.current?.getFocusedOption?.(); - if (!focusedOption) { submitSearch(textInputValue); return; From 7d6479a206704b69f4e754d8bb0548b34e8d4af9 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Fri, 15 May 2026 17:22:44 +0500 Subject: [PATCH 018/466] fixed AI feedbacks --- src/components/Search/SearchAutocompleteList.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index f340872d43f4..245328bdaf9c 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -175,6 +175,7 @@ function SearchAutocompleteList({ const allCards = personalAndWorkspaceCards ?? CONST.EMPTY_OBJECT; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const effectiveInputQueryValue = inputQueryValue ?? autocompleteQueryValue; + const isInputAheadOfDebounce = effectiveInputQueryValue !== autocompleteQueryValue; const hasEffectiveInputQuery = effectiveInputQueryValue.trim() !== ''; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -376,8 +377,8 @@ function SearchAutocompleteList({ return searchOptions.recentReports; } - // User typed but debounce has not emitted yet; avoid showing stale empty-query results. - if (autocompleteQueryValue.trim() === '') { + // User typed but debounce has not emitted yet; avoid showing stale results from the previous query. + if (isInputAheadOfDebounce) { return []; } @@ -392,7 +393,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, hasEffectiveInputQuery, searchOptions]); + }, [autocompleteQueryValue, hasEffectiveInputQuery, isInputAheadOfDebounce, searchOptions]); useEffect(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { @@ -485,7 +486,7 @@ function SearchAutocompleteList({ }); } - if (autocompleteSuggestions.length > 0) { + if (!isInputAheadOfDebounce && autocompleteSuggestions.length > 0) { const autocompleteData: AutocompleteListItem[] = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey}) => { return { text: getAutocompleteDisplayText(filterKey, text), @@ -504,6 +505,7 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ hasEffectiveInputQuery, + isInputAheadOfDebounce, autocompleteSuggestions, expensifyIcons, getAdditionalSections, From b5f476a68ef38667256d520127fa1f664ea5704c Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Wed, 10 Jun 2026 23:05:13 +0500 Subject: [PATCH 019/466] Fixed feedbacks and CI issues --- src/components/Search/SearchAutocompleteList.tsx | 12 ++++++++++-- tests/perf-test/OptionsListUtils.perf-test.ts | 2 +- tests/unit/OptionsListUtilsTest.tsx | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 0c854a0149ba..b6baa2f14cb5 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -10,6 +10,7 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import type {Section, SelectionListWithSectionsHandle} from '@components/SelectionList/SelectionListWithSections/types'; import useAutocompleteSuggestions from '@hooks/useAutocompleteSuggestions'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDebounce from '@hooks/useDebounce'; import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; import useFilteredOptions from '@hooks/useFilteredOptions'; @@ -422,13 +423,20 @@ function SearchAutocompleteList({ setFrozenLocalRank(buildRankMap(recentReportsOptions)); } - useEffect(() => { + // Debounce the server search so callers that don't already debounce upstream + // (e.g. the main Spend page header via useSearchPageInput) don't fire a request per keystroke. + // For SearchRouter the upstream value is already debounced, so this just adds a no-op coalescing layer. + const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { return; } handleSearch(autocompleteQueryWithoutFilters); - }, [autocompleteQueryWithoutFilters, handleSearch]); + }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); + + useEffect(() => { + debounceHandleSearch(); + }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'SearchAutocompleteList', diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 4f75cd1b6800..79c778fdb1b5 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -302,7 +302,7 @@ describe('OptionsListUtils', () => { const largeReports = getMockedReports(LARGE_REPORTS_COUNT); const largeOptionList = createOptionList(largePersonalDetails, EMPTY_PRIVATE_IS_ARCHIVED_MAP, largeReports, undefined); - const formattedOptions = getValidOptions( + const {options: formattedOptions} = getValidOptions( {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, allPolicies, {}, diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 574bb94f36e7..b4b74c126c17 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3253,7 +3253,7 @@ describe('OptionsListUtils', () => { }); it('should return the same matches for normalized multi-word queries with extra spaces', () => { - const options = getSearchOptions({ + const {options} = getSearchOptions({ options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, draftComments: {}, From 7c1f52ec8c0e7e74c783cc37e2cba2603cca964e Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 19 Jun 2026 12:06:17 +0200 Subject: [PATCH 020/466] Add new option and handle messages --- src/CONST/index.ts | 3 +- src/components/AddressForm.tsx | 13 +++- src/languages/de.ts | 20 ++++++- src/languages/en.ts | 16 +++++ src/languages/es.ts | 20 ++++++- src/languages/fr.ts | 20 ++++++- src/languages/it.ts | 20 ++++++- src/languages/ja.ts | 20 ++++++- src/languages/nl.ts | 20 ++++++- src/languages/pl.ts | 20 ++++++- src/languages/pt-BR.ts | 20 ++++++- src/languages/zh-hans.ts | 20 ++++++- .../SetPolicyCommuterExclusionsParams.ts | 6 +- src/libs/actions/Policy/DistanceRate.ts | 32 ++++++---- src/pages/AddressPage.tsx | 43 +++++++++---- src/pages/home/TimeSensitiveSection/index.tsx | 8 +++ .../PersonalDetails/PersonalAddressPage.tsx | 17 +++++- src/pages/settings/Profile/ProfilePage.tsx | 7 +++ .../WorkspaceOverviewAddressPage.tsx | 7 +++ .../PolicyCommuterExclusionsPage.tsx | 60 +++++++++++++++++-- .../PolicyDistanceRatesSettingsPage.tsx | 28 ++++++--- src/types/onyx/OriginalMessage.ts | 12 ++++ 22 files changed, 376 insertions(+), 56 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4f5aca8c9a4d..876791fbc223 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1672,6 +1672,7 @@ const CONST = { REIMBURSEMENT_SETUP: 'REIMBURSEMENTSETUP', // Deprecated OldDot Action REIMBURSEMENT_SETUP_REQUESTED: 'REIMBURSEMENTSETUPREQUESTED', // Deprecated OldDot Action REIMBURSEMENT_DIRECTOR_INFORMATION_REQUIRED: 'DIRECTORINFORMATIONREQUIRED', + HOME_ADDRESS_REQUIRED: 'HOMEADDRESSREQUIRED', REJECTED: 'REJECTED', REJECTED_TO_SUBMITTER: 'REJECTEDTOSUBMITTER', REMOVED_FROM_APPROVAL_CHAIN: 'REMOVEDFROMAPPROVALCHAIN', @@ -4086,7 +4087,7 @@ const CONST = { }, COMMUTER_EXCLUSION_METHOD: { FIXED_DISTANCE: 'fixedDistance', - // R2 will add HOME_AND_OFFICE: 'homeAndOffice' + HOME_AND_OFFICE: 'homeAndOffice', }, COMMUTER_EXCLUSION_TYPE: { METHOD: 'method', diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index e36d09770bbc..0d4988f26540 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -62,6 +62,13 @@ type AddressFormProps = { /** Whether the form submit button should be enabled when offline */ enabledWhenOffline?: boolean; + + /** + * Whether to force the zip/postal code to be present. Defaults to off because most consumers + * treat it as optional, but workspace addresses backing the homeAndOffice commuter exclusions + * method require a complete address so the distance can be computed reliably. + */ + shouldRequireZip?: boolean; }; function AddressForm({ @@ -78,6 +85,7 @@ function AddressForm({ zip = '', shouldHideCountrySelector = false, enabledWhenOffline: enabledWhenOfflineProp = true, + shouldRequireZip = false, }: AddressFormProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -103,7 +111,8 @@ function AddressForm({ const errors: Errors & { zipPostCode?: string | string[]; } = {}; - const requiredFields = shouldHideCountrySelector ? (['addressLine1', 'city', 'state'] as const) : (['addressLine1', 'city', 'country', 'state'] as const); + const baseRequiredFields = shouldHideCountrySelector ? (['addressLine1', 'city', 'state'] as const) : (['addressLine1', 'city', 'country', 'state'] as const); + const requiredFields = shouldRequireZip ? ([...baseRequiredFields, 'zipPostCode'] as const) : baseRequiredFields; // Check "State" dropdown is a valid state if selected Country is USA if (values.country === CONST.COUNTRY.US && !values.state) { @@ -157,7 +166,7 @@ function AddressForm({ return errors; }, - [translate, shouldHideCountrySelector, country], + [translate, shouldHideCountrySelector, country, shouldRequireZip], ); return ( diff --git a/src/languages/de.ts b/src/languages/de.ts index 16bd988397cb..4a9f0009434e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -967,6 +967,11 @@ const translations: TranslationDeepObject = { subtitle: 'Wallet', }, validateAccount: {title: 'Bestätigen Sie Ihr Konto', subtitle: 'Konto', cta: 'Bestätigen'}, + addHomeAddress: { + title: 'Heimadresse hinzufügen', + subtitle: 'Dein Administrator hat Pendlerausschlüsse für Distanzausgaben aktiviert. Füge eine Heimadresse hinzu, damit wir sie auf deine Abrechnungen anwenden können.', + cta: 'Adresse hinzufügen', + }, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Ihr Geschäftskonto wurde gesperrt', @@ -3428,6 +3433,8 @@ ${amount} für ${merchant} – ${date}`, legalFirstName: 'Rechtlicher Vorname', legalLastName: 'Rechtlicher Nachname', address: 'Adresse', + homeAddress: 'Heimadresse', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} verwendet diese Adresse für Pendlerausschlüsse.`, error: { dateShouldBeBefore: (dateString: string) => `Datum muss vor dem ${dateString} liegen`, dateShouldBeAfter: (dateString: string) => `Datum muss nach ${dateString} liegen`, @@ -6752,7 +6759,18 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', distanceLabel: 'Entfernung', - errors: {distanceMustBePositive: 'Die Entfernung muss größer als 0 sein.'}, + summaryHomeAndOffice: 'Heim- und Bürostandorte verwenden', + optionHomeAndOfficeTitle: 'Nach Zuhause und Büro berechnen', + optionHomeAndOfficeHelp: 'Ziehe den Arbeitsweg jedes Mitglieds zwischen Zuhause und der Adresse des Arbeitsbereichs von jeder Distanzabrechnung ab.', + workspaceAddressRequired: { + title: 'Ups, nicht so schnell...', + prompt: 'Dieser Arbeitsbereich hat noch keine Adresse. Füge eine hinzu, damit Pendelausschlüsse sie verwenden können.', + cta: 'Adresse hinzufügen', + }, + errors: { + distanceMustBePositive: 'Die Entfernung muss größer als 0 sein.', + invalidAddress: 'Bitte gib eine gültige Adresse ein', + }, }, distance: 'Entfernung', centrallyManage: 'Tarife zentral verwalten, in Meilen oder Kilometern nachverfolgen und eine Standardkategorie festlegen.', diff --git a/src/languages/en.ts b/src/languages/en.ts index c6b631692c40..0497b34680a3 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1017,6 +1017,11 @@ const translations = { subtitle: 'Account', cta: 'Validate', }, + addHomeAddress: { + title: 'Add a home address', + subtitle: 'Your admin enabled commuter exclusions for distance expenses. Add a home address so we can apply them to your claims.', + cta: 'Add address', + }, fixFailedBilling: { title: "We couldn't bill your card on file", subtitle: 'Subscription', @@ -3519,6 +3524,8 @@ const translations = { legalFirstName: 'Legal first name', legalLastName: 'Legal last name', address: 'Address', + homeAddress: 'Home address', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} uses this address for commuter exclusions.`, error: { dateShouldBeBefore: (dateString: string) => `Date should be before ${dateString}`, dateShouldBeAfter: (dateString: string) => `Date should be after ${dateString}`, @@ -6894,13 +6901,22 @@ const translations = { title: 'Exclude commutes', summaryDisabled: 'No commute exclusion', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclude ${distance} ${unit} per claim`, + summaryHomeAndOffice: 'Use home and office locations', optionDisabledTitle: 'Do not exclude commutes', optionDisabledHelp: 'No commute exclusion is applied.', optionFixedDistanceTitle: 'Exclude a fixed distance per claim', optionFixedDistanceHelp: 'Remove the same commute distance from each claim. Best for members who submit one claim per workday.', + optionHomeAndOfficeTitle: 'Calculate by home and office', + optionHomeAndOfficeHelp: 'Subtract each member’s commute between their home and the workspace address from every distance claim.', distanceLabel: 'Distance', + workspaceAddressRequired: { + title: 'Oops, not so fast...', + prompt: 'This workspace doesn’t have an address yet. Add one so commuter exclusions can use it.', + cta: 'Add address', + }, errors: { distanceMustBePositive: 'Distance must be greater than 0.', + invalidAddress: 'Please enter a valid address', }, }, distance: 'Distance', diff --git a/src/languages/es.ts b/src/languages/es.ts index b8f8dbd58a2b..becb385cc9da 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -927,6 +927,11 @@ const translations: TranslationDeepObject = { subtitle: 'Cuenta', cta: 'Validar', }, + addHomeAddress: { + title: 'Añade una dirección de casa', + subtitle: 'Tu administrador habilitó exclusiones de desplazamientos para los gastos por distancia. Añade una dirección de casa para que podamos aplicarlas a tus reclamaciones.', + cta: 'Añadir dirección', + }, fixFailedBilling: { title: 'No pudimos cobrar a la tarjeta registrada.', subtitle: 'Suscripción', @@ -3303,6 +3308,8 @@ ${amount} para ${merchant} - ${date}`, legalFirstName: 'Nombre legal', legalLastName: 'Apellidos legales', address: 'Dirección', + homeAddress: 'Dirección de casa', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa esta dirección para las exclusiones de desplazamientos.`, error: { dateShouldBeBefore: (dateString) => `La fecha debe ser anterior a ${dateString}`, dateShouldBeAfter: (dateString) => `La fecha debe ser posterior a ${dateString}`, @@ -6663,12 +6670,23 @@ ${amount} para ${merchant} - ${date}`, title: 'Excluir desplazamientos al trabajo', summaryDisabled: 'Sin exclusión por desplazamiento al trabajo', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, + summaryHomeAndOffice: 'Usar las ubicaciones de casa y oficina', optionDisabledTitle: 'No excluir los desplazamientos al trabajo', optionDisabledHelp: 'No se aplica ninguna exclusión de desplazamiento.', optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', optionFixedDistanceHelp: 'Resta la misma distancia de desplazamiento de cada solicitud. Ideal para personas que envían una solicitud por día laborable.', + optionHomeAndOfficeTitle: 'Calcular por casa y oficina', + optionHomeAndOfficeHelp: 'Resta el trayecto entre la casa de cada miembro y la dirección del espacio de trabajo en cada reclamación de distancia.', distanceLabel: 'Distancia', - errors: {distanceMustBePositive: 'La distancia debe ser mayor que 0.'}, + workspaceAddressRequired: { + title: 'Espera un momento...', + prompt: 'Este espacio de trabajo aún no tiene dirección. Añade una para que las exclusiones de desplazamientos puedan usarla.', + cta: 'Añadir dirección', + }, + errors: { + distanceMustBePositive: 'La distancia debe ser mayor que 0.', + invalidAddress: 'Introduce una dirección válida', + }, }, distance: 'Distancia', centrallyManage: 'Gestiona centralizadamente las tasas, elige si contabilizar en millas o kilómetros, y define una categoría por defecto', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index dd7e0d3581f7..0b38146ff8a0 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -970,6 +970,11 @@ const translations: TranslationDeepObject = { subtitle: 'Portefeuille', }, validateAccount: {title: 'Validez votre compte', subtitle: 'Compte', cta: 'Valider'}, + addHomeAddress: { + title: 'Ajouter une adresse personnelle', + subtitle: 'Votre administrateur a activé les exclusions de trajet pour les dépenses de distance. Ajoutez une adresse personnelle pour que nous puissions les appliquer à vos réclamations.', + cta: 'Ajouter une adresse', + }, fixFailedBilling: {title: 'Nous n’avons pas pu débiter votre carte enregistrée', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Votre compte bancaire professionnel a été verrouillé', @@ -3436,6 +3441,8 @@ ${amount} pour ${merchant} - ${date}`, legalFirstName: 'Prénom légal', legalLastName: 'Nom de famille légal', address: 'Adresse', + homeAddress: 'Adresse personnelle', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilise cette adresse pour les exclusions de trajet.`, error: { dateShouldBeBefore: (dateString: string) => `La date doit être antérieure au ${dateString}`, dateShouldBeAfter: (dateString: string) => `La date doit être postérieure au ${dateString}`, @@ -6778,7 +6785,18 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. optionFixedDistanceTitle: 'Exclure une distance fixe par demande', optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', distanceLabel: 'Distance', - errors: {distanceMustBePositive: 'La distance doit être supérieure à 0.'}, + summaryHomeAndOffice: 'Utiliser les emplacements du domicile et du bureau', + optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', + optionHomeAndOfficeHelp: 'Soustrayez le trajet de chaque membre entre son domicile et l’adresse de l’espace de travail de chaque réclamation de distance.', + workspaceAddressRequired: { + title: 'Oups, pas si vite...', + prompt: 'Cet espace de travail n’a pas encore d’adresse. Ajoutez-en une pour que les exclusions de trajet puissent l’utiliser.', + cta: 'Ajouter une adresse', + }, + errors: { + distanceMustBePositive: 'La distance doit être supérieure à 0.', + invalidAddress: 'Veuillez saisir une adresse valide', + }, }, distance: 'Distance', centrallyManage: 'Gérez les taux de manière centralisée, suivez en miles ou en kilomètres et définissez une catégorie par défaut.', diff --git a/src/languages/it.ts b/src/languages/it.ts index dad81ecc0647..42a493e03c7f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -968,6 +968,11 @@ const translations: TranslationDeepObject = { subtitle: 'Portafoglio', }, validateAccount: {title: 'Conferma il tuo account', subtitle: 'Account', cta: 'Conferma'}, + addHomeAddress: { + title: 'Aggiungi un indirizzo di casa', + subtitle: 'Il tuo amministratore ha abilitato le esclusioni dei tragitti per le spese di distanza. Aggiungi un indirizzo di casa così possiamo applicarle alle tue richieste.', + cta: 'Aggiungi indirizzo', + }, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, unlockBankAccount: { workspaceTitle: 'Il conto bancario della tua azienda è stato bloccato', @@ -3420,6 +3425,8 @@ ${amount} per ${merchant} - ${date}`, legalFirstName: 'Nome legale di battesimo', legalLastName: 'Cognome legale', address: 'Indirizzo', + homeAddress: 'Indirizzo di casa', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa questo indirizzo per le esclusioni dei tragitti.`, error: { dateShouldBeBefore: (dateString: string) => `La data deve essere precedente a ${dateString}`, dateShouldBeAfter: (dateString: string) => `La data deve essere successiva a ${dateString}`, @@ -6738,7 +6745,18 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. optionFixedDistanceTitle: 'Escludi una distanza fissa per richiesta', optionFixedDistanceHelp: 'Detrai la stessa distanza del tragitto casa-lavoro da ogni richiesta. Ideale per chi invia una richiesta per ogni giorno lavorativo.', distanceLabel: 'Distanza', - errors: {distanceMustBePositive: 'La distanza deve essere maggiore di 0.'}, + summaryHomeAndOffice: 'Usa le località di casa e ufficio', + optionHomeAndOfficeTitle: 'Calcola in base a casa e ufficio', + optionHomeAndOfficeHelp: 'Sottrai da ogni richiesta di distanza il tragitto di ciascun membro tra la sua casa e l’indirizzo dell’area di lavoro.', + workspaceAddressRequired: { + title: 'Ops, non così in fretta...', + prompt: 'Quest’area di lavoro non ha ancora un indirizzo. Aggiungine uno in modo che le esclusioni dei tragitti possano usarlo.', + cta: 'Aggiungi indirizzo', + }, + errors: { + distanceMustBePositive: 'La distanza deve essere maggiore di 0.', + invalidAddress: 'Inserisci un indirizzo valido', + }, }, distance: 'Distanza', centrallyManage: 'Gestisci centralmente le tariffe, monitora in miglia o chilometri e imposta una categoria predefinita.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1718b9d9512d..c3f90e7cde84 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -954,6 +954,11 @@ const translations: TranslationDeepObject = { }, fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, validateAccount: {title: 'アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, + addHomeAddress: { + title: '自宅住所を追加', + subtitle: '管理者が距離経費の通勤控除を有効にしました。請求に適用できるよう自宅住所を追加してください。', + cta: '住所を追加', + }, fixFailedBilling: {title: '登録されているカードから請求できませんでした', subtitle: 'サブスクリプション'}, unlockBankAccount: { workspaceTitle: 'ビジネス用銀行口座がロックされました', @@ -3396,6 +3401,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' legalFirstName: '法的な名', legalLastName: '法的な姓', address: '住所', + homeAddress: '自宅住所', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} は通勤控除のためにこの住所を使用します。`, error: { dateShouldBeBefore: (dateString: string) => `日付は${dateString}より前である必要があります`, dateShouldBeAfter: (dateString: string) => `日付は${dateString}より後の日付にしてください`, @@ -6666,7 +6673,18 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO optionFixedDistanceTitle: '申請ごとに一定距離を除外します', optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', distanceLabel: '距離', - errors: {distanceMustBePositive: '距離は0より大きい値でなければなりません。'}, + summaryHomeAndOffice: '自宅とオフィスの位置を使用', + optionHomeAndOfficeTitle: '自宅とオフィスで計算', + optionHomeAndOfficeHelp: '各メンバーの自宅とワークスペースの住所間の通勤距離を、各距離請求から差し引きます。', + workspaceAddressRequired: { + title: 'おっと、ちょっと待って...', + prompt: 'このワークスペースにはまだ住所がありません。通勤控除が使用できるように追加してください。', + cta: '住所を追加', + }, + errors: { + distanceMustBePositive: '距離は0より大きい値でなければなりません。', + invalidAddress: '有効な住所を入力してください', + }, }, distance: '距離', centrallyManage: '料金を一元管理し、マイルまたはキロメートルで追跡し、デフォルトのカテゴリを設定できます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 96d342122017..6f9f13a6394e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -966,6 +966,11 @@ const translations: TranslationDeepObject = { subtitle: 'Portemonnee', }, validateAccount: {title: 'Valideer je account', subtitle: 'Account', cta: 'Valideren'}, + addHomeAddress: { + title: 'Voeg een huisadres toe', + subtitle: 'Je beheerder heeft woon-werkuitsluitingen voor afstandsuitgaven ingeschakeld. Voeg een huisadres toe zodat we deze op je claims kunnen toepassen.', + cta: 'Adres toevoegen', + }, fixFailedBilling: {title: 'We konden je kaart in ons bestand niet belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Je zakelijke bankrekening is geblokkeerd', @@ -3417,6 +3422,8 @@ ${amount} voor ${merchant} - ${date}`, legalFirstName: 'Juridische voornaam', legalLastName: 'Wettelijke achternaam', address: 'Adres', + homeAddress: 'Huisadres', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} gebruikt dit adres voor woon-werkuitsluitingen.`, error: { dateShouldBeBefore: (dateString: string) => `Datum moet vóór ${dateString} zijn`, dateShouldBeAfter: (dateString: string) => `Datum moet na ${dateString} liggen`, @@ -6717,7 +6724,18 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', distanceLabel: 'Afstand', - errors: {distanceMustBePositive: 'Afstand moet groter zijn dan 0.'}, + summaryHomeAndOffice: 'Gebruik thuis- en kantoorlocaties', + optionHomeAndOfficeTitle: 'Berekenen via huis en kantoor', + optionHomeAndOfficeHelp: 'Trek van elke afstandsclaim het woon-werkverkeer van elk lid tussen huis en het werkruimteadres af.', + workspaceAddressRequired: { + title: 'Oeps, niet zo snel...', + prompt: 'Deze werkruimte heeft nog geen adres. Voeg er een toe zodat woon-werkuitsluitingen het kunnen gebruiken.', + cta: 'Adres toevoegen', + }, + errors: { + distanceMustBePositive: 'Afstand moet groter zijn dan 0.', + invalidAddress: 'Voer een geldig adres in', + }, }, distance: 'Afstand', centrallyManage: 'Beheer tarieven centraal, volg in mijlen of kilometers en stel een standaardcategorie in.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 352e79d1c838..88c2473be1f9 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -968,6 +968,11 @@ const translations: TranslationDeepObject = { subtitle: 'Portfel', }, validateAccount: {title: 'Zweryfikuj swoje konto', subtitle: 'Konto', cta: 'Zatwierdź'}, + addHomeAddress: { + title: 'Dodaj adres domowy', + subtitle: 'Twój administrator włączył wykluczenia dojazdów dla wydatków za dystans. Dodaj adres domowy, abyśmy mogli je zastosować do twoich roszczeń.', + cta: 'Dodaj adres', + }, fixFailedBilling: {title: 'Nie mogliśmy obciążyć zapisanej karty', subtitle: 'Subskrypcja'}, unlockBankAccount: { workspaceTitle: 'Twoje firmowe konto bankowe zostało zablokowane', @@ -3410,6 +3415,8 @@ ${amount} dla ${merchant} - ${date}`, legalFirstName: 'Imię (zgodnie z dokumentem tożsamości)', legalLastName: 'Nazwisko zgodne z dokumentami', address: 'Adres', + homeAddress: 'Adres domowy', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} używa tego adresu do wykluczeń dojazdów.`, error: { dateShouldBeBefore: (dateString: string) => `Data powinna być wcześniejsza niż ${dateString}`, dateShouldBeAfter: (dateString: string) => `Data powinna być po ${dateString}`, @@ -6711,7 +6718,18 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', distanceLabel: 'Dystans', - errors: {distanceMustBePositive: 'Dystans musi być większy niż 0.'}, + summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', + optionHomeAndOfficeTitle: 'Oblicz na podstawie domu i biura', + optionHomeAndOfficeHelp: 'Odejmij od każdego roszczenia o dystans dojazd każdego członka między jego domem a adresem przestrzeni roboczej.', + workspaceAddressRequired: { + title: 'Hej, nie tak szybko...', + prompt: 'Ta przestrzeń robocza nie ma jeszcze adresu. Dodaj go, aby wykluczenia dojazdów mogły z niego korzystać.', + cta: 'Dodaj adres', + }, + errors: { + distanceMustBePositive: 'Dystans musi być większy niż 0.', + invalidAddress: 'Wprowadź prawidłowy adres', + }, }, distance: 'Dystans', centrallyManage: 'Centralnie zarządzaj stawkami, śledź w milach lub kilometrach i ustaw domyślną kategorię.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 12fc1db81381..1f8faa5986ce 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -966,6 +966,11 @@ const translations: TranslationDeepObject = { subtitle: 'Carteira', }, validateAccount: {title: 'Valide sua conta', subtitle: 'Conta', cta: 'Validar'}, + addHomeAddress: { + title: 'Adicione um endereço residencial', + subtitle: 'Seu administrador habilitou exclusões de trajeto para despesas de distância. Adicione um endereço residencial para que possamos aplicá-las às suas solicitações.', + cta: 'Adicionar endereço', + }, fixFailedBilling: {title: 'Não foi possível cobrar o cartão cadastrado', subtitle: 'Assinatura'}, unlockBankAccount: { workspaceTitle: 'Sua conta bancária comercial foi bloqueada', @@ -3407,6 +3412,8 @@ ${amount} para ${merchant} - ${date}`, legalFirstName: 'Primeiro nome legal', legalLastName: 'Sobrenome legal', address: 'Endereço', + homeAddress: 'Endereço residencial', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa este endereço para exclusões de trajeto.`, error: { dateShouldBeBefore: (dateString: string) => `A data deve ser anterior a ${dateString}`, dateShouldBeAfter: (dateString: string) => `A data deve ser posterior a ${dateString}`, @@ -6712,7 +6719,18 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', distanceLabel: 'Distância', - errors: {distanceMustBePositive: 'A distância deve ser maior que 0.'}, + summaryHomeAndOffice: 'Usar locais de casa e escritório', + optionHomeAndOfficeTitle: 'Calcular por casa e escritório', + optionHomeAndOfficeHelp: 'Subtraia em cada solicitação de distância o trajeto de cada membro entre sua casa e o endereço do espaço de trabalho.', + workspaceAddressRequired: { + title: 'Ops, espere um momento...', + prompt: 'Este espaço de trabalho ainda não tem endereço. Adicione um para que as exclusões de trajeto possam usá-lo.', + cta: 'Adicionar endereço', + }, + errors: { + distanceMustBePositive: 'A distância deve ser maior que 0.', + invalidAddress: 'Insira um endereço válido', + }, }, distance: 'Distância', centrallyManage: 'Gerencie tarifas centralmente, acompanhe em milhas ou quilômetros e defina uma categoria padrão.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2c8d1dc0eb9f..90d73d161c00 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -937,6 +937,11 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, validateAccount: {title: '验证您的账户', subtitle: '账户', cta: '验证'}, + addHomeAddress: { + title: '添加家庭地址', + subtitle: '您的管理员已为距离支出启用了通勤排除。请添加家庭地址,以便将其应用于您的申报。', + cta: '添加地址', + }, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, unlockBankAccount: { workspaceTitle: '您的企业银行账户已被锁定', @@ -3328,6 +3333,8 @@ ${amount},商户:${merchant} - 日期:${date}`, legalFirstName: '法定名(名)', legalLastName: '法定姓氏', address: '地址', + homeAddress: '家庭地址', + commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} 将此地址用于通勤排除规则。`, error: { dateShouldBeBefore: (dateString: string) => `日期应早于 ${dateString}`, dateShouldBeAfter: (dateString: string) => `日期应晚于 ${dateString}`, @@ -6544,7 +6551,18 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM optionFixedDistanceTitle: '为每笔报销排除固定距离', optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', distanceLabel: '距离', - errors: {distanceMustBePositive: '距离必须大于 0。'}, + summaryHomeAndOffice: '使用家庭和办公室位置', + optionHomeAndOfficeTitle: '按家庭和办公室计算', + optionHomeAndOfficeHelp: '从每次距离申报中扣除每位成员住所与工作区地址之间的通勤距离。', + workspaceAddressRequired: { + title: '哎呀,等一下...', + prompt: '此工作区还没有地址。请添加一个,以便通勤排除规则可以使用它。', + cta: '添加地址', + }, + errors: { + distanceMustBePositive: '距离必须大于 0。', + invalidAddress: '请输入有效地址', + }, }, distance: '距离', centrallyManage: '集中管理费率,以英里或公里跟踪,并设置默认类别。', diff --git a/src/libs/API/parameters/SetPolicyCommuterExclusionsParams.ts b/src/libs/API/parameters/SetPolicyCommuterExclusionsParams.ts index 4a61adb067b2..9c55f16f7a67 100644 --- a/src/libs/API/parameters/SetPolicyCommuterExclusionsParams.ts +++ b/src/libs/API/parameters/SetPolicyCommuterExclusionsParams.ts @@ -1,13 +1,13 @@ type SetPolicyCommuterExclusionsParams = { policyID: string; /** - * Exclusion method - currently only "fixedDistance" is supported. R2 will add "homeAndOffice". + * Exclusion method - either "fixedDistance" or "homeAndOffice". * Named `commuterExclusionMethod` (not `method`) because the Web-Expensify WAF restricts the * plain `method` parameter to payment values. */ commuterExclusionMethod: string; - /** Distance to exclude per claim. Must be > 0 when commuterExclusionMethod is "fixedDistance". */ - distance: number; + /** Distance to exclude per claim. Required and > 0 when commuterExclusionMethod is "fixedDistance"; omitted for "homeAndOffice". */ + distance?: number; }; export default SetPolicyCommuterExclusionsParams; diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 5a527e66b262..8b6b8530f1f7 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -1,5 +1,6 @@ import type {NullishDeep, OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; import type { CreatePolicyDistanceRateParams, @@ -522,27 +523,28 @@ function updateDistanceTaxRate(policyID: string, customUnit: CustomUnit, customU } /** - * Set the commuter exclusion for a workspace. Currently only `fixedDistance` is supported; the - * distance unit is owned by the workspace's distance custom unit (Auth resolves it server-side and - * snapshots it into the change log), so we only echo it into Onyx optimistically. + * Set the commuter exclusion for a policy. Two methods are supported: + * - "fixedDistance" - subtracts a fixed distance per claim. `fixedDistance` (> 0) and `fixedDistanceUnit` + * (mirrors the policy's distance custom unit) are required. + * - "homeAndOffice" - subtracts each member's home-to-office distance, computed per-claim from the + * member's saved addresses. No client-side distance/unit needed. * - * Callers should pass the workspace's current `commuterExclusions` so the failure path can restore + * Callers should pass the policy's current `commuterExclusions` so the failure path can restore * the prior state. */ function setPolicyCommuterExclusions( policyID: string, - method: 'fixedDistance', - fixedDistance: number, - fixedDistanceUnit: string, + method: ValueOf, + fixedDistance: number | undefined, + fixedDistanceUnit: string | undefined, previousCommuterExclusions: CommuterExclusions | undefined, ) { const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; - const optimisticCommuterExclusions: CommuterExclusions = { - method, - fixedDistance, - fixedDistanceUnit, - }; + const optimisticCommuterExclusions: CommuterExclusions = + method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE + ? {method, fixedDistance, fixedDistanceUnit} + : {method}; const onyxData: OnyxData = { optimisticData: [ @@ -578,7 +580,11 @@ function setPolicyCommuterExclusions( ], }; - const parameters: SetPolicyCommuterExclusionsParams = {policyID, commuterExclusionMethod: method, distance: fixedDistance}; + // Only send distance when the server actually needs it; homeAndOffice ignores the field. + const parameters: SetPolicyCommuterExclusionsParams = + method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE + ? {policyID, commuterExclusionMethod: method, distance: fixedDistance} + : {policyID, commuterExclusionMethod: method}; API.write(WRITE_COMMANDS.SET_POLICY_COMMUTER_EXCLUSIONS, parameters, onyxData); } diff --git a/src/pages/AddressPage.tsx b/src/pages/AddressPage.tsx index 303ee3f3667e..85114a906598 100644 --- a/src/pages/AddressPage.tsx +++ b/src/pages/AddressPage.tsx @@ -6,6 +6,7 @@ import AddressForm from '@components/AddressForm'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -28,10 +29,22 @@ type AddressPageProps = { /** Title of address page */ title: string; + /** + * Optional muted helper text shown directly under the header. Used to surface contextual + * notes about the address (e.g. which workspace consumes it for commuter exclusions). + */ + helperText?: string; + + /** + * When true, the underlying AddressForm also requires the zip/postal code. Used by callers + * that need a complete address (e.g. workspace addresses backing homeAndOffice exclusions). + */ + shouldRequireZip?: boolean; + defaultCountry?: Country; } & BackToParams; -function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo, defaultCountry}: AddressPageProps) { +function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo, defaultCountry, helperText, shouldRequireZip}: AddressPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -108,18 +121,22 @@ function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo /> ) : ( - + <> + {!!helperText && {helperText}} + + )} diff --git a/src/pages/home/TimeSensitiveSection/index.tsx b/src/pages/home/TimeSensitiveSection/index.tsx index 6650746f1a60..4366b57e8a2e 100644 --- a/src/pages/home/TimeSensitiveSection/index.tsx +++ b/src/pages/home/TimeSensitiveSection/index.tsx @@ -25,9 +25,11 @@ import useBrokenDirectCompanyCardFeedsForAdmin from './hooks/useBrokenDirectComp import useTimeSensitiveAddPaymentCard from './hooks/useTimeSensitiveAddPaymentCard'; import useTimeSensitiveBilling from './hooks/useTimeSensitiveBilling'; import useTimeSensitiveCards from './hooks/useTimeSensitiveCards'; +import useTimeSensitiveHomeAddress from './hooks/useTimeSensitiveHomeAddress'; import useTimeSensitiveLockedBankAccount from './hooks/useTimeSensitiveLockedBankAccount'; import useTimeSensitiveSignerInfo from './hooks/useTimeSensitiveSignerInfo'; import ActivateCard from './items/ActivateCard'; +import AddHomeAddress from './items/AddHomeAddress'; import AddPaymentCard from './items/AddPaymentCard'; import AddShippingAddress from './items/AddShippingAddress'; import EnterSignerInfo from './items/EnterSignerInfo'; @@ -76,6 +78,7 @@ function TimeSensitiveSection() { const {shouldShowAddPaymentCard} = useTimeSensitiveAddPaymentCard(); const {shouldShowAddShippingAddress, shouldShowActivateCard, shouldShowReviewCardFraud, cardsNeedingShippingAddress, cardsNeedingActivation, cardsWithFraud} = useTimeSensitiveCards(); const {shouldShowFixFailedBilling} = useTimeSensitiveBilling(); + const {shouldShowAddHomeAddress} = useTimeSensitiveHomeAddress(); // Selector for filtering admin policies (Release 4) const adminPoliciesSelectorWrapper = useCallback((policies: OnyxCollection) => activeAdminPoliciesSelector(policies, login ?? ''), [login]); @@ -150,6 +153,7 @@ function TimeSensitiveSection() { shouldShowFixFailedBilling || shouldShowReviewCardFraud || shouldShowAddPaymentCard || + shouldShowAddHomeAddress || hasBrokenCompanyCards || hasBrokenPersonalCards || hasBrokenPolicyConnections || @@ -178,6 +182,10 @@ function TimeSensitiveSection() { if (shouldShowValidateAccount) { items.push(); } + // Priority 1b: Add home address (commuter exclusions, homeAndOffice method) + if (shouldShowAddHomeAddress) { + items.push(); + } // Priority 2: Failed billing for existing customers if (shouldShowFixFailedBilling) { items.push(); diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx index 7f553e3c3d5b..7b24facb575c 100644 --- a/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx @@ -7,6 +7,7 @@ import {getCurrentAddress} from '@libs/PersonalDetailsUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import AddressPage from '@pages/AddressPage'; import type {FormOnyxValues} from '@src/components/Form/types'; +import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import {updateAddress as updateAddressPersonalDetails} from '@src/libs/actions/PersonalDetails'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -34,6 +35,17 @@ function PersonalAddressPage() { const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [defaultCountry, defaultCountryStatus] = useOnyx(ONYXKEYS.COUNTRY); + // Find the first workspace using the homeAndOffice commuter-exclusion method so we can surface + // a contextual note to the member explaining where this address is consumed. Multiple workspaces + // may rely on it; we name the first one for brevity since the policy is identical for all. + const [commuterHomeAndOfficePolicyName] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (policies) => + Object.values(policies ?? {}).find( + (policy) => + policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && + policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + )?.name, + }); const isLoading = isLoadingOnyxValue(defaultCountryStatus); const address = useMemo(() => normalizeCountryCode(getCurrentAddress(privatePersonalDetails)) as Address, [privatePersonalDetails]); const reasonAttributes: SkeletonSpanReasonAttributes = { @@ -50,7 +62,10 @@ function PersonalAddressPage() { address={address} isLoadingApp={isLoadingApp} updateAddress={(values) => updateAddress(values, privatePersonalDetails?.addresses ?? [])} - title={translate('privatePersonalDetails.address')} + title={translate('privatePersonalDetails.homeAddress')} + helperText={ + commuterHomeAndOfficePolicyName ? translate('privatePersonalDetails.commuterExclusionsNote', {workspaceName: commuterHomeAndOfficePolicyName}) : undefined + } /> ); } diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index e4d2faa33bd2..1bf6008ef30f 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -36,6 +36,7 @@ import {getDisplayNameOrDefault, getFormattedAddress} from '@libs/PersonalDetail import {useIsAgentAccount} from '@libs/SessionUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {expensifyLoginsSelector, getContactMethodsOptions, getLoginListBrickRoadIndicator} from '@libs/UserUtils'; +import useTimeSensitiveHomeAddress from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress'; import {clearAgentAvatarUpdateError} from '@userActions/Agent'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -82,6 +83,11 @@ function ProfilePage() { const privateDetails = privatePersonalDetails ?? {}; const legalName = `${privateDetails.legalFirstName ?? ''} ${privateDetails.legalLastName ?? ''}`.trim(); + // Surface an info GBR on the Home address row when a workspace needs it for commuter exclusions + // and the member hasn't set one yet. The hook tracks both conditions and auto-clears when either + // becomes false (workspace turns off homeAndOffice, or the user saves an address with a street). + const {shouldShowAddHomeAddress: shouldShowAddHomeAddressGBR} = useTimeSensitiveHomeAddress(); + const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); const {isActingAsDelegate} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -170,6 +176,7 @@ function ProfilePage() { title: getFormattedAddress(privateDetails), sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.ADDRESS, action: () => navigateToPrivateDetails(INPUT_IDS.ADDRESS_LINE_1), + brickRoadIndicator: shouldShowAddHomeAddressGBR ? CONST.BRICK_ROAD_INDICATOR_STATUS.INFO : undefined, }, ]; diff --git a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx index d6c13cc32487..84efb7b23998 100644 --- a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx @@ -8,6 +8,7 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getWorkspaceAddressStreetLines} from '@libs/WorkspacesSettingsUtils'; import AddressPage from '@pages/AddressPage'; import {updateAddress} from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -52,6 +53,11 @@ function WorkspaceOverviewAddressPage({policy}: WorkspaceOverviewAddressPageProp Navigation.goBack(backPath); }; + // When the policy uses homeAndOffice commuter exclusions, the workspace address backs the + // per-member commute calculation. A partial/invalid address would silently break that, so + // promote the zip code to a required field on top of the form's standard required set. + const requiresFullAddressForExclusions = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + return ( ); } diff --git a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx index 5e28f22aa1a7..d7b839484bd1 100644 --- a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx @@ -4,16 +4,19 @@ import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import TextInput from '@components/TextInput'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {getLatestErrorField} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; @@ -23,12 +26,15 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {clearPolicyCommuterExclusionsErrors, disablePolicyCommuterExclusions, setPolicyCommuterExclusions} from '@userActions/Policy/DistanceRate'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; type PolicyCommuterExclusionsPageProps = PlatformStackScreenProps; -type ExclusionOptionKey = typeof CONST.POLICY.COMMUTER_EXCLUSION_TYPE.DISABLED | typeof CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE; +type ExclusionOptionKey = + | typeof CONST.POLICY.COMMUTER_EXCLUSION_TYPE.DISABLED + | typeof CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE + | typeof CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; type ExclusionOption = { text: string; @@ -49,6 +55,7 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps selector: (policy) => ({ commuterExclusions: policy?.commuterExclusions, unit: getDistanceRateCustomUnit(policy)?.attributes?.unit, + hasWorkspaceAddress: !!policy?.address?.addressStreet?.trim(), pendingFields: policy?.pendingFields, errorFields: policy?.errorFields, }), @@ -58,9 +65,12 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps const existingCommuterExclusions = policyData?.commuterExclusions; const existingMethod = existingCommuterExclusions?.method; - const [selectedKey, setSelectedKey] = useState( - existingMethod === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE ? CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE : CONST.POLICY.COMMUTER_EXCLUSION_TYPE.DISABLED, - ); + const initialSelectedKey: ExclusionOptionKey = + existingMethod === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE || existingMethod === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE + ? existingMethod + : CONST.POLICY.COMMUTER_EXCLUSION_TYPE.DISABLED; + const [selectedKey, setSelectedKey] = useState(initialSelectedKey); + const {showConfirmModal} = useConfirmModal(); const [fixedDistanceInput, setFixedDistanceInput] = useState(() => (existingCommuterExclusions?.fixedDistance != null ? String(existingCommuterExclusions.fixedDistance) : '')); const [inlineError, setInlineError] = useState(''); @@ -75,6 +85,25 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps if (item.keyForList === selectedKey) { return; } + + // homeAndOffice depends on the workspace's own address - if it's missing, route the admin to + // fill it in before letting them persist the selection. The chosen option is not stored + // locally until they actually have a workspace address. + if (item.keyForList === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !policyData?.hasWorkspaceAddress) { + showConfirmModal({ + title: translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.title'), + prompt: translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.prompt'), + confirmText: translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.cta'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_OVERVIEW_ADDRESS.path)); + }); + return; + } + setSelectedKey(item.keyForList); setInlineError(''); }; @@ -88,6 +117,21 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps return; } + if (selectedKey === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { + // Defensive guard: the row selector already blocks this path when the workspace has no + // address, but a manual save (e.g. via keyboard) shouldn't bypass the requirement. + if (!policyData?.hasWorkspaceAddress) { + return; + } + if (existingMethod === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { + goBackToSettings(); + return; + } + setPolicyCommuterExclusions(policyID, CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, undefined, undefined, existingCommuterExclusions); + goBackToSettings(); + return; + } + const trimmed = fixedDistanceInput.trim(); const numeric = Number(trimmed); @@ -144,6 +188,12 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps isSelected: isFixedDistanceSelected, footerContent: isFixedDistanceSelected ? fixedDistanceFooter : null, }, + { + text: translate('workspace.distanceRates.commuterExclusions.optionHomeAndOfficeTitle'), + alternateText: translate('workspace.distanceRates.commuterExclusions.optionHomeAndOfficeHelp'), + keyForList: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, + isSelected: selectedKey === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, + }, ]; return ( diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index 2093f5c9ee66..b3926c4b5332 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -30,10 +30,27 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {CustomUnit} from '@src/types/onyx/Policy'; +import type {CommuterExclusions, CustomUnit} from '@src/types/onyx/Policy'; type PolicyDistanceRatesSettingsPageProps = PlatformStackScreenProps; +function getCommuterExclusionsSummary( + commuterExclusions: CommuterExclusions | undefined, + defaultUnit: string | undefined, + translate: ReturnType['translate'], +): string { + if (commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { + return translate('workspace.distanceRates.commuterExclusions.summaryHomeAndOffice'); + } + if (commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE && commuterExclusions?.fixedDistance != null) { + return translate('workspace.distanceRates.commuterExclusions.summaryFixedDistance', { + distance: commuterExclusions.fixedDistance, + unit: commuterExclusions.fixedDistanceUnit ?? defaultUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }); + } + return translate('workspace.distanceRates.commuterExclusions.summaryDisabled'); +} + function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPageProps) { const policyID = route.params.policyID; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -117,14 +134,7 @@ function PolicyDistanceRatesSettingsPage({route}: PolicyDistanceRatesSettingsPag > Navigation.navigate(ROUTES.WORKSPACE_DISTANCE_RATES_COMMUTER_EXCLUSIONS.getRoute(policyID))} wrapperStyle={[styles.ph5, styles.mt3]} diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index cbcaaa958134..1d08e721f3ad 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1581,6 +1581,17 @@ type IssueNewCardOriginalMessage = OriginalMessage< | typeof CONST.REPORT.ACTIONS.TYPE.CARD_REPLACED >; +/** + * Model of a HOME_ADDRESS_REQUIRED Concierge report action. Posted by Auth's + * SetPolicyCommuterExclusions command for members missing a home address when their workspace + * switches to the homeAndOffice commuter-exclusion method. The App renders a custom "Add address" + * CTA for this action and uses `policyID` to deep-link the user to the workspace that triggered it. + */ +type OriginalMessageHomeAddressRequired = { + /** ID of the policy whose commuter-exclusion change triggered the prompt */ + policyID: string; +}; + /** * Model of reimbursement director information report action */ @@ -1707,6 +1718,7 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.REASSIGN_APPROVER]: OriginalMessageReassignApprover; [CONST.REPORT.ACTIONS.TYPE.REROUTE]: OriginalMessageTakeControl; [CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENT_DIRECTOR_INFORMATION_REQUIRED]: OriginalMessageReimbursementDirectorInformationRequired; + [CONST.REPORT.ACTIONS.TYPE.HOME_ADDRESS_REQUIRED]: OriginalMessageHomeAddressRequired; [CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED]: OriginalMessageSettlementAccountLocked; } & OldDotOriginalMessageMap & Record, OriginalMessagePolicyChangeLog> & { From 0efb568156e6f31edf4bdaf5d63c7e0bc548d0b3 Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 19 Jun 2026 12:19:20 +0200 Subject: [PATCH 021/466] move options around --- .../distanceRates/PolicyCommuterExclusionsPage.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx index d7b839484bd1..0148553a5eb9 100644 --- a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx @@ -181,6 +181,12 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps keyForList: CONST.POLICY.COMMUTER_EXCLUSION_TYPE.DISABLED, isSelected: selectedKey === CONST.POLICY.COMMUTER_EXCLUSION_TYPE.DISABLED, }, + { + text: translate('workspace.distanceRates.commuterExclusions.optionHomeAndOfficeTitle'), + alternateText: translate('workspace.distanceRates.commuterExclusions.optionHomeAndOfficeHelp'), + keyForList: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, + isSelected: selectedKey === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, + }, { text: translate('workspace.distanceRates.commuterExclusions.optionFixedDistanceTitle'), alternateText: translate('workspace.distanceRates.commuterExclusions.optionFixedDistanceHelp'), @@ -188,12 +194,6 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps isSelected: isFixedDistanceSelected, footerContent: isFixedDistanceSelected ? fixedDistanceFooter : null, }, - { - text: translate('workspace.distanceRates.commuterExclusions.optionHomeAndOfficeTitle'), - alternateText: translate('workspace.distanceRates.commuterExclusions.optionHomeAndOfficeHelp'), - keyForList: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, - isSelected: selectedKey === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE, - }, ]; return ( From bf88064cfdb0c699a79ff4a616a88f5a8d8caa29 Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 19 Jun 2026 15:13:31 +0200 Subject: [PATCH 022/466] use better component --- src/languages/de.ts | 16 ++++---- src/languages/en.ts | 16 ++++---- src/languages/es.ts | 14 +++---- src/languages/fr.ts | 16 ++++---- src/languages/it.ts | 16 ++++---- src/languages/ja.ts | 16 ++++---- src/languages/nl.ts | 16 ++++---- src/languages/pl.ts | 16 ++++---- src/languages/pt-BR.ts | 16 ++++---- src/languages/zh-hans.ts | 16 ++++---- src/pages/AddressPage.tsx | 38 +++++++------------ .../PersonalDetails/PersonalAddressPage.tsx | 17 +-------- .../PolicyCommuterExclusionsPage.tsx | 38 ++++++++++++------- 13 files changed, 118 insertions(+), 133 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 4a9f0009434e..bc165e52f1c8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3433,8 +3433,6 @@ ${amount} für ${merchant} – ${date}`, legalFirstName: 'Rechtlicher Vorname', legalLastName: 'Rechtlicher Nachname', address: 'Adresse', - homeAddress: 'Heimadresse', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} verwendet diese Adresse für Pendlerausschlüsse.`, error: { dateShouldBeBefore: (dateString: string) => `Datum muss vor dem ${dateString} liegen`, dateShouldBeAfter: (dateString: string) => `Datum muss nach ${dateString} liegen`, @@ -6755,17 +6753,19 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU summaryDisabled: 'Kein Ausschluss von Pendelstrecken', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `${distance} ${unit} pro Abrechnung ausschließen`, optionDisabledTitle: 'Pendelfahrten nicht ausschließen', - optionDisabledHelp: 'Es wird kein Ausschluss für den Arbeitsweg angewendet.', + optionDisabledHelp: 'Keine Pendelstrecke wird aus Abrechnungen entfernt.', optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', distanceLabel: 'Entfernung', - summaryHomeAndOffice: 'Heim- und Bürostandorte verwenden', + summaryHomeAndOffice: 'Nach Zuhause und Büro berechnen', optionHomeAndOfficeTitle: 'Nach Zuhause und Büro berechnen', - optionHomeAndOfficeHelp: 'Ziehe den Arbeitsweg jedes Mitglieds zwischen Zuhause und der Adresse des Arbeitsbereichs von jeder Distanzabrechnung ab.', + optionHomeAndOfficeHelp: 'Verwende die Heimadresse, das Arbeitsmodell und die Bürozuweisung des Mitglieds, um Pendelausschlüsse zu berechnen.', workspaceAddressRequired: { - title: 'Ups, nicht so schnell...', - prompt: 'Dieser Arbeitsbereich hat noch keine Adresse. Füge eine hinzu, damit Pendelausschlüsse sie verwenden können.', - cta: 'Adresse hinzufügen', + title: 'Nicht so schnell...', + promptStart: 'Du kannst die Berechnung nach Zuhause und Büro erst aktivieren, wenn du in ', + linkText: 'Übersicht', + promptEnd: ' eine Büroadresse hinzugefügt hast.', + cta: 'Verstanden', }, errors: { distanceMustBePositive: 'Die Entfernung muss größer als 0 sein.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 0497b34680a3..816d35e5db52 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3524,8 +3524,6 @@ const translations = { legalFirstName: 'Legal first name', legalLastName: 'Legal last name', address: 'Address', - homeAddress: 'Home address', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} uses this address for commuter exclusions.`, error: { dateShouldBeBefore: (dateString: string) => `Date should be before ${dateString}`, dateShouldBeAfter: (dateString: string) => `Date should be after ${dateString}`, @@ -6901,18 +6899,20 @@ const translations = { title: 'Exclude commutes', summaryDisabled: 'No commute exclusion', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclude ${distance} ${unit} per claim`, - summaryHomeAndOffice: 'Use home and office locations', + summaryHomeAndOffice: 'Calculate by home and office', optionDisabledTitle: 'Do not exclude commutes', - optionDisabledHelp: 'No commute exclusion is applied.', + optionDisabledHelp: 'No commute is removed from claims.', optionFixedDistanceTitle: 'Exclude a fixed distance per claim', optionFixedDistanceHelp: 'Remove the same commute distance from each claim. Best for members who submit one claim per workday.', optionHomeAndOfficeTitle: 'Calculate by home and office', - optionHomeAndOfficeHelp: 'Subtract each member’s commute between their home and the workspace address from every distance claim.', + optionHomeAndOfficeHelp: 'Use member’s home address, work arrangement, and office assignment to calculate commute exclusions.', distanceLabel: 'Distance', workspaceAddressRequired: { - title: 'Oops, not so fast...', - prompt: 'This workspace doesn’t have an address yet. Add one so commuter exclusions can use it.', - cta: 'Add address', + title: 'Not so fast...', + promptStart: 'You can’t enable the calculate by home and office setting until you first add an office location in ', + linkText: 'Overview', + promptEnd: '.', + cta: 'Got it', }, errors: { distanceMustBePositive: 'Distance must be greater than 0.', diff --git a/src/languages/es.ts b/src/languages/es.ts index becb385cc9da..e360a89e2fd1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3308,8 +3308,6 @@ ${amount} para ${merchant} - ${date}`, legalFirstName: 'Nombre legal', legalLastName: 'Apellidos legales', address: 'Dirección', - homeAddress: 'Dirección de casa', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa esta dirección para las exclusiones de desplazamientos.`, error: { dateShouldBeBefore: (dateString) => `La fecha debe ser anterior a ${dateString}`, dateShouldBeAfter: (dateString) => `La fecha debe ser posterior a ${dateString}`, @@ -6670,18 +6668,20 @@ ${amount} para ${merchant} - ${date}`, title: 'Excluir desplazamientos al trabajo', summaryDisabled: 'Sin exclusión por desplazamiento al trabajo', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, - summaryHomeAndOffice: 'Usar las ubicaciones de casa y oficina', + summaryHomeAndOffice: 'Calcular por casa y oficina', optionDisabledTitle: 'No excluir los desplazamientos al trabajo', - optionDisabledHelp: 'No se aplica ninguna exclusión de desplazamiento.', + optionDisabledHelp: 'No se elimina ningún desplazamiento de las reclamaciones.', optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', optionFixedDistanceHelp: 'Resta la misma distancia de desplazamiento de cada solicitud. Ideal para personas que envían una solicitud por día laborable.', optionHomeAndOfficeTitle: 'Calcular por casa y oficina', - optionHomeAndOfficeHelp: 'Resta el trayecto entre la casa de cada miembro y la dirección del espacio de trabajo en cada reclamación de distancia.', + optionHomeAndOfficeHelp: 'Usa la dirección de casa, el arreglo de trabajo y la asignación de oficina del miembro para calcular las exclusiones de desplazamientos.', distanceLabel: 'Distancia', workspaceAddressRequired: { title: 'Espera un momento...', - prompt: 'Este espacio de trabajo aún no tiene dirección. Añade una para que las exclusiones de desplazamientos puedan usarla.', - cta: 'Añadir dirección', + promptStart: 'No puedes activar el cálculo por casa y oficina hasta que añadas una dirección de oficina en ', + linkText: 'Vista general', + promptEnd: '.', + cta: 'Entendido', }, errors: { distanceMustBePositive: 'La distancia debe ser mayor que 0.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0b38146ff8a0..9044fbb57b2c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3441,8 +3441,6 @@ ${amount} pour ${merchant} - ${date}`, legalFirstName: 'Prénom légal', legalLastName: 'Nom de famille légal', address: 'Adresse', - homeAddress: 'Adresse personnelle', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilise cette adresse pour les exclusions de trajet.`, error: { dateShouldBeBefore: (dateString: string) => `La date doit être antérieure au ${dateString}`, dateShouldBeAfter: (dateString: string) => `La date doit être postérieure au ${dateString}`, @@ -6781,17 +6779,19 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. summaryDisabled: 'Pas d’exclusion du trajet domicile-travail', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclure ${distance} ${unit} par demande`, optionDisabledTitle: 'Ne pas exclure les trajets domicile-travail', - optionDisabledHelp: 'Aucune exclusion de trajet domicile-travail n’est appliquée.', + optionDisabledHelp: 'Aucun trajet n’est retiré des réclamations.', optionFixedDistanceTitle: 'Exclure une distance fixe par demande', optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', distanceLabel: 'Distance', - summaryHomeAndOffice: 'Utiliser les emplacements du domicile et du bureau', + summaryHomeAndOffice: 'Calculer par domicile et bureau', optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', - optionHomeAndOfficeHelp: 'Soustrayez le trajet de chaque membre entre son domicile et l’adresse de l’espace de travail de chaque réclamation de distance.', + optionHomeAndOfficeHelp: 'Utilise l’adresse personnelle, le mode de travail et l’affectation au bureau du membre pour calculer les exclusions de trajet.', workspaceAddressRequired: { - title: 'Oups, pas si vite...', - prompt: 'Cet espace de travail n’a pas encore d’adresse. Ajoutez-en une pour que les exclusions de trajet puissent l’utiliser.', - cta: 'Ajouter une adresse', + title: 'Pas si vite...', + promptStart: 'Vous ne pouvez pas activer le calcul par domicile et bureau tant que vous n’avez pas ajouté une adresse de bureau dans ', + linkText: 'Vue d’ensemble', + promptEnd: '.', + cta: 'Compris', }, errors: { distanceMustBePositive: 'La distance doit être supérieure à 0.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 42a493e03c7f..e04ec21cfc5f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3425,8 +3425,6 @@ ${amount} per ${merchant} - ${date}`, legalFirstName: 'Nome legale di battesimo', legalLastName: 'Cognome legale', address: 'Indirizzo', - homeAddress: 'Indirizzo di casa', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa questo indirizzo per le esclusioni dei tragitti.`, error: { dateShouldBeBefore: (dateString: string) => `La data deve essere precedente a ${dateString}`, dateShouldBeAfter: (dateString: string) => `La data deve essere successiva a ${dateString}`, @@ -6741,17 +6739,19 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. summaryDisabled: 'Nessuna esclusione per il tragitto', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Escludi ${distance} ${unit} per rimborso`, optionDisabledTitle: 'Non escludere gli spostamenti casa-lavoro', - optionDisabledHelp: 'Non è applicata alcuna esclusione per il tragitto casa-lavoro.', + optionDisabledHelp: 'Nessun tragitto viene rimosso dalle richieste.', optionFixedDistanceTitle: 'Escludi una distanza fissa per richiesta', optionFixedDistanceHelp: 'Detrai la stessa distanza del tragitto casa-lavoro da ogni richiesta. Ideale per chi invia una richiesta per ogni giorno lavorativo.', distanceLabel: 'Distanza', - summaryHomeAndOffice: 'Usa le località di casa e ufficio', + summaryHomeAndOffice: 'Calcola in base a casa e ufficio', optionHomeAndOfficeTitle: 'Calcola in base a casa e ufficio', - optionHomeAndOfficeHelp: 'Sottrai da ogni richiesta di distanza il tragitto di ciascun membro tra la sua casa e l’indirizzo dell’area di lavoro.', + optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa, la modalità di lavoro e l’assegnazione dell’ufficio del membro per calcolare le esclusioni dei tragitti.', workspaceAddressRequired: { - title: 'Ops, non così in fretta...', - prompt: 'Quest’area di lavoro non ha ancora un indirizzo. Aggiungine uno in modo che le esclusioni dei tragitti possano usarlo.', - cta: 'Aggiungi indirizzo', + title: 'Non così in fretta...', + promptStart: 'Non puoi attivare il calcolo in base a casa e ufficio finché non aggiungi un indirizzo dell’ufficio in ', + linkText: 'Panoramica', + promptEnd: '.', + cta: 'Ho capito', }, errors: { distanceMustBePositive: 'La distanza deve essere maggiore di 0.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c3f90e7cde84..9f5302593510 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3401,8 +3401,6 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' legalFirstName: '法的な名', legalLastName: '法的な姓', address: '住所', - homeAddress: '自宅住所', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} は通勤控除のためにこの住所を使用します。`, error: { dateShouldBeBefore: (dateString: string) => `日付は${dateString}より前である必要があります`, dateShouldBeAfter: (dateString: string) => `日付は${dateString}より後の日付にしてください`, @@ -6669,17 +6667,19 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO summaryDisabled: '通勤除外なし', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `申請ごとに ${distance} ${unit} を除外します`, optionDisabledTitle: '通勤を除外しない', - optionDisabledHelp: '通勤除外は適用されていません。', + optionDisabledHelp: '請求から通勤距離は差し引かれません。', optionFixedDistanceTitle: '申請ごとに一定距離を除外します', optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', distanceLabel: '距離', - summaryHomeAndOffice: '自宅とオフィスの位置を使用', + summaryHomeAndOffice: '自宅とオフィスで計算', optionHomeAndOfficeTitle: '自宅とオフィスで計算', - optionHomeAndOfficeHelp: '各メンバーの自宅とワークスペースの住所間の通勤距離を、各距離請求から差し引きます。', + optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィスの割り当てを使用して通勤控除を計算します。', workspaceAddressRequired: { - title: 'おっと、ちょっと待って...', - prompt: 'このワークスペースにはまだ住所がありません。通勤控除が使用できるように追加してください。', - cta: '住所を追加', + title: 'ちょっと待って...', + promptStart: '自宅とオフィスで計算する設定を有効にするには、まず ', + linkText: '概要', + promptEnd: ' でオフィスの住所を追加してください。', + cta: 'OK', }, errors: { distanceMustBePositive: '距離は0より大きい値でなければなりません。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6f9f13a6394e..47c9b0dca07d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3422,8 +3422,6 @@ ${amount} voor ${merchant} - ${date}`, legalFirstName: 'Juridische voornaam', legalLastName: 'Wettelijke achternaam', address: 'Adres', - homeAddress: 'Huisadres', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} gebruikt dit adres voor woon-werkuitsluitingen.`, error: { dateShouldBeBefore: (dateString: string) => `Datum moet vóór ${dateString} zijn`, dateShouldBeAfter: (dateString: string) => `Datum moet na ${dateString} liggen`, @@ -6720,17 +6718,19 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ summaryDisabled: 'Geen woon-werkuitzondering', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Sluit ${distance} ${unit} per declaratie uit`, optionDisabledTitle: 'Woon-werkverkeer niet uitsluiten', - optionDisabledHelp: 'Er is geen woon-werkuitzondering toegepast.', + optionDisabledHelp: 'Geen woon-werkverkeer wordt uit claims verwijderd.', optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', distanceLabel: 'Afstand', - summaryHomeAndOffice: 'Gebruik thuis- en kantoorlocaties', + summaryHomeAndOffice: 'Berekenen via huis en kantoor', optionHomeAndOfficeTitle: 'Berekenen via huis en kantoor', - optionHomeAndOfficeHelp: 'Trek van elke afstandsclaim het woon-werkverkeer van elk lid tussen huis en het werkruimteadres af.', + optionHomeAndOfficeHelp: 'Gebruik het huisadres, de werkregeling en de kantoorindeling van het lid om woon-werkuitsluitingen te berekenen.', workspaceAddressRequired: { - title: 'Oeps, niet zo snel...', - prompt: 'Deze werkruimte heeft nog geen adres. Voeg er een toe zodat woon-werkuitsluitingen het kunnen gebruiken.', - cta: 'Adres toevoegen', + title: 'Niet zo snel...', + promptStart: 'Je kunt de instelling Berekenen via huis en kantoor pas inschakelen nadat je een kantooradres hebt toegevoegd in ', + linkText: 'Overzicht', + promptEnd: '.', + cta: 'Begrepen', }, errors: { distanceMustBePositive: 'Afstand moet groter zijn dan 0.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 88c2473be1f9..cc3087c8a9e5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3415,8 +3415,6 @@ ${amount} dla ${merchant} - ${date}`, legalFirstName: 'Imię (zgodnie z dokumentem tożsamości)', legalLastName: 'Nazwisko zgodne z dokumentami', address: 'Adres', - homeAddress: 'Adres domowy', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} używa tego adresu do wykluczeń dojazdów.`, error: { dateShouldBeBefore: (dateString: string) => `Data powinna być wcześniejsza niż ${dateString}`, dateShouldBeAfter: (dateString: string) => `Data powinna być po ${dateString}`, @@ -6714,17 +6712,19 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy summaryDisabled: 'Bez wykluczenia dojazdów', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Wyklucz ${distance} ${unit} na zgłoszenie`, optionDisabledTitle: 'Nie wykluczaj dojazdów', - optionDisabledHelp: 'Nie zastosowano wyłączenia dojazdów.', + optionDisabledHelp: 'Z roszczeń nie jest usuwany żaden dojazd.', optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', distanceLabel: 'Dystans', - summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', + summaryHomeAndOffice: 'Oblicz na podstawie domu i biura', optionHomeAndOfficeTitle: 'Oblicz na podstawie domu i biura', - optionHomeAndOfficeHelp: 'Odejmij od każdego roszczenia o dystans dojazd każdego członka między jego domem a adresem przestrzeni roboczej.', + optionHomeAndOfficeHelp: 'Użyj adresu domowego, modelu pracy i przypisania biura członka do obliczenia wykluczeń dojazdów.', workspaceAddressRequired: { - title: 'Hej, nie tak szybko...', - prompt: 'Ta przestrzeń robocza nie ma jeszcze adresu. Dodaj go, aby wykluczenia dojazdów mogły z niego korzystać.', - cta: 'Dodaj adres', + title: 'Nie tak szybko...', + promptStart: 'Nie możesz włączyć ustawienia oblicz na podstawie domu i biura, dopóki nie dodasz adresu biura w sekcji ', + linkText: 'Przegląd', + promptEnd: '.', + cta: 'Rozumiem', }, errors: { distanceMustBePositive: 'Dystans musi być większy niż 0.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 1f8faa5986ce..d2d71b314855 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3412,8 +3412,6 @@ ${amount} para ${merchant} - ${date}`, legalFirstName: 'Primeiro nome legal', legalLastName: 'Sobrenome legal', address: 'Endereço', - homeAddress: 'Endereço residencial', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa este endereço para exclusões de trajeto.`, error: { dateShouldBeBefore: (dateString: string) => `A data deve ser anterior a ${dateString}`, dateShouldBeAfter: (dateString: string) => `A data deve ser posterior a ${dateString}`, @@ -6715,17 +6713,19 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS summaryDisabled: 'Sem exclusão de deslocamento', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por solicitação`, optionDisabledTitle: 'Não excluir deslocamentos de casa para o trabalho', - optionDisabledHelp: 'Nenhuma exclusão de deslocamento é aplicada.', + optionDisabledHelp: 'Nenhum trajeto é removido das solicitações.', optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', distanceLabel: 'Distância', - summaryHomeAndOffice: 'Usar locais de casa e escritório', + summaryHomeAndOffice: 'Calcular por casa e escritório', optionHomeAndOfficeTitle: 'Calcular por casa e escritório', - optionHomeAndOfficeHelp: 'Subtraia em cada solicitação de distância o trajeto de cada membro entre sua casa e o endereço do espaço de trabalho.', + optionHomeAndOfficeHelp: 'Use o endereço residencial, o regime de trabalho e a alocação de escritório do membro para calcular as exclusões de trajeto.', workspaceAddressRequired: { - title: 'Ops, espere um momento...', - prompt: 'Este espaço de trabalho ainda não tem endereço. Adicione um para que as exclusões de trajeto possam usá-lo.', - cta: 'Adicionar endereço', + title: 'Espere um momento...', + promptStart: 'Você não pode ativar a configuração calcular por casa e escritório enquanto não adicionar um endereço de escritório em ', + linkText: 'Visão geral', + promptEnd: '.', + cta: 'Entendi', }, errors: { distanceMustBePositive: 'A distância deve ser maior que 0.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 90d73d161c00..2c7855a075d6 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3333,8 +3333,6 @@ ${amount},商户:${merchant} - 日期:${date}`, legalFirstName: '法定名(名)', legalLastName: '法定姓氏', address: '地址', - homeAddress: '家庭地址', - commuterExclusionsNote: ({workspaceName}: {workspaceName: string}) => `${workspaceName} 将此地址用于通勤排除规则。`, error: { dateShouldBeBefore: (dateString: string) => `日期应早于 ${dateString}`, dateShouldBeAfter: (dateString: string) => `日期应晚于 ${dateString}`, @@ -6547,17 +6545,19 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM summaryDisabled: '不排除通勤', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `每次报销排除 ${distance} ${unit}`, optionDisabledTitle: '不要排除通勤', - optionDisabledHelp: '未应用通勤排除规则。', + optionDisabledHelp: '不会从申报中扣除任何通勤距离。', optionFixedDistanceTitle: '为每笔报销排除固定距离', optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', distanceLabel: '距离', - summaryHomeAndOffice: '使用家庭和办公室位置', + summaryHomeAndOffice: '按家庭和办公室计算', optionHomeAndOfficeTitle: '按家庭和办公室计算', - optionHomeAndOfficeHelp: '从每次距离申报中扣除每位成员住所与工作区地址之间的通勤距离。', + optionHomeAndOfficeHelp: '使用成员的家庭地址、工作安排和办公室分配来计算通勤排除。', workspaceAddressRequired: { - title: '哎呀,等一下...', - prompt: '此工作区还没有地址。请添加一个,以便通勤排除规则可以使用它。', - cta: '添加地址', + title: '等一下...', + promptStart: '在你在', + linkText: '概览', + promptEnd: '中添加办公室地址之前,无法启用按家庭和办公室计算的设置。', + cta: '知道了', }, errors: { distanceMustBePositive: '距离必须大于 0。', diff --git a/src/pages/AddressPage.tsx b/src/pages/AddressPage.tsx index 85114a906598..284ead67aa7e 100644 --- a/src/pages/AddressPage.tsx +++ b/src/pages/AddressPage.tsx @@ -6,7 +6,6 @@ import AddressForm from '@components/AddressForm'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -29,12 +28,6 @@ type AddressPageProps = { /** Title of address page */ title: string; - /** - * Optional muted helper text shown directly under the header. Used to surface contextual - * notes about the address (e.g. which workspace consumes it for commuter exclusions). - */ - helperText?: string; - /** * When true, the underlying AddressForm also requires the zip/postal code. Used by callers * that need a complete address (e.g. workspace addresses backing homeAndOffice exclusions). @@ -44,7 +37,7 @@ type AddressPageProps = { defaultCountry?: Country; } & BackToParams; -function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo, defaultCountry, helperText, shouldRequireZip}: AddressPageProps) { +function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo, defaultCountry, shouldRequireZip}: AddressPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -121,22 +114,19 @@ function AddressPage({title, address, updateAddress, isLoadingApp = true, backTo /> ) : ( - <> - {!!helperText && {helperText}} - - + )} diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx index 7b24facb575c..7f553e3c3d5b 100644 --- a/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/PersonalAddressPage.tsx @@ -7,7 +7,6 @@ import {getCurrentAddress} from '@libs/PersonalDetailsUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import AddressPage from '@pages/AddressPage'; import type {FormOnyxValues} from '@src/components/Form/types'; -import CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import {updateAddress as updateAddressPersonalDetails} from '@src/libs/actions/PersonalDetails'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -35,17 +34,6 @@ function PersonalAddressPage() { const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [defaultCountry, defaultCountryStatus] = useOnyx(ONYXKEYS.COUNTRY); - // Find the first workspace using the homeAndOffice commuter-exclusion method so we can surface - // a contextual note to the member explaining where this address is consumed. Multiple workspaces - // may rely on it; we name the first one for brevity since the policy is identical for all. - const [commuterHomeAndOfficePolicyName] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - selector: (policies) => - Object.values(policies ?? {}).find( - (policy) => - policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && - policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - )?.name, - }); const isLoading = isLoadingOnyxValue(defaultCountryStatus); const address = useMemo(() => normalizeCountryCode(getCurrentAddress(privatePersonalDetails)) as Address, [privatePersonalDetails]); const reasonAttributes: SkeletonSpanReasonAttributes = { @@ -62,10 +50,7 @@ function PersonalAddressPage() { address={address} isLoadingApp={isLoadingApp} updateAddress={(values) => updateAddress(values, privatePersonalDetails?.addresses ?? [])} - title={translate('privatePersonalDetails.homeAddress')} - helperText={ - commuterHomeAndOfficePolicyName ? translate('privatePersonalDetails.commuterExclusionsNote', {workspaceName: commuterHomeAndOfficePolicyName}) : undefined - } + title={translate('privatePersonalDetails.address')} /> ); } diff --git a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx index 0148553a5eb9..b483e54f68e1 100644 --- a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx @@ -4,19 +4,19 @@ import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import TextLink from '@components/TextLink'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDistanceRateCustomUnit} from '@libs/PolicyUtils'; @@ -26,7 +26,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import {clearPolicyCommuterExclusionsErrors, disablePolicyCommuterExclusions, setPolicyCommuterExclusions} from '@userActions/Policy/DistanceRate'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; type PolicyCommuterExclusionsPageProps = PlatformStackScreenProps; @@ -70,7 +70,7 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps ? existingMethod : CONST.POLICY.COMMUTER_EXCLUSION_TYPE.DISABLED; const [selectedKey, setSelectedKey] = useState(initialSelectedKey); - const {showConfirmModal} = useConfirmModal(); + const {showConfirmModal, closeModal} = useConfirmModal(); const [fixedDistanceInput, setFixedDistanceInput] = useState(() => (existingCommuterExclusions?.fixedDistance != null ? String(existingCommuterExclusions.fixedDistance) : '')); const [inlineError, setInlineError] = useState(''); @@ -81,25 +81,35 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps Navigation.goBack(ROUTES.WORKSPACE_DISTANCE_RATES_SETTINGS.getRoute(policyID)); }; + const goToWorkspaceOverview = () => { + // Dismiss the modal first so the user lands on the overview page with a clean stack instead + // of having the confirm modal animate away on top of the navigation. + closeModal(); + Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW.getRoute(policyID)); + }; + const onSelectRow = (item: ExclusionOption) => { if (item.keyForList === selectedKey) { return; } - // homeAndOffice depends on the workspace's own address - if it's missing, route the admin to - // fill it in before letting them persist the selection. The chosen option is not stored - // locally until they actually have a workspace address. + // homeAndOffice depends on the workspace's own address - if it's missing, block the change + // and point the admin to the Overview page via an inline link. The chosen option is not + // stored locally until they actually have a workspace address. if (item.keyForList === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !policyData?.hasWorkspaceAddress) { showConfirmModal({ title: translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.title'), - prompt: translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.prompt'), + prompt: ( + + {translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.promptStart')} + + {translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.linkText')} + + {translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.promptEnd')} + + ), confirmText: translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.cta'), - cancelText: translate('common.cancel'), - }).then(({action}) => { - if (action !== ModalActions.CONFIRM) { - return; - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_OVERVIEW_ADDRESS.path)); + shouldShowCancelButton: false, }); return; } From ab192d9d0ae160fd947fcb5dd1b2546661147dc9 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 22 Jun 2026 17:42:51 +0200 Subject: [PATCH 023/466] better import --- src/ROUTES.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1a2e7269f2bd..3cef57ee6a6e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4257,3 +4257,5 @@ type Route = { type DynamicRouteSuffix = (typeof DYNAMIC_ROUTES)[keyof typeof DYNAMIC_ROUTES]['path']; export type {Route, DynamicRouteSuffix}; + +export type {Route, DynamicRouteSuffix}; From 386d6ff6a62dd159573299ad7a1d2ba4369c9f68 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 23 Jun 2026 12:55:10 +0200 Subject: [PATCH 024/466] better display --- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 491c7d5548ac..fc7c45ad50a8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6772,7 +6772,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', distanceLabel: 'Entfernung', - summaryHomeAndOffice: 'Nach Zuhause und Büro berechnen', + summaryHomeAndOffice: 'Heim- und Bürostandorte verwenden', optionHomeAndOfficeTitle: 'Nach Zuhause und Büro berechnen', optionHomeAndOfficeHelp: 'Verwende die Heimadresse, das Arbeitsmodell und die Bürozuweisung des Mitglieds, um Pendelausschlüsse zu berechnen.', workspaceAddressRequired: { diff --git a/src/languages/en.ts b/src/languages/en.ts index f286c2edd312..d8d587acb091 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6914,7 +6914,7 @@ const translations = { title: 'Exclude commutes', summaryDisabled: 'No commute exclusion', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclude ${distance} ${unit} per claim`, - summaryHomeAndOffice: 'Calculate by home and office', + summaryHomeAndOffice: 'Use home and office locations', optionDisabledTitle: 'Do not exclude commutes', optionDisabledHelp: 'No commute is removed from claims.', optionFixedDistanceTitle: 'Exclude a fixed distance per claim', diff --git a/src/languages/es.ts b/src/languages/es.ts index fa123bc78b6e..81f40729622a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6683,7 +6683,7 @@ ${amount} para ${merchant} - ${date}`, title: 'Excluir desplazamientos al trabajo', summaryDisabled: 'Sin exclusión por desplazamiento al trabajo', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, - summaryHomeAndOffice: 'Calcular por casa y oficina', + summaryHomeAndOffice: 'Usar las ubicaciones de casa y oficina', optionDisabledTitle: 'No excluir los desplazamientos al trabajo', optionDisabledHelp: 'No se elimina ningún desplazamiento de las reclamaciones.', optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4f8bb098d9cb..b20ee1a824f6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6798,7 +6798,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. optionFixedDistanceTitle: 'Exclure une distance fixe par demande', optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', distanceLabel: 'Distance', - summaryHomeAndOffice: 'Calculer par domicile et bureau', + summaryHomeAndOffice: 'Utiliser les emplacements du domicile et du bureau', optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', optionHomeAndOfficeHelp: 'Utilise l’adresse personnelle, le mode de travail et l’affectation au bureau du membre pour calculer les exclusions de trajet.', workspaceAddressRequired: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 7f9e232a1ebd..9267f196928b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6758,7 +6758,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. optionFixedDistanceTitle: 'Escludi una distanza fissa per richiesta', optionFixedDistanceHelp: 'Detrai la stessa distanza del tragitto casa-lavoro da ogni richiesta. Ideale per chi invia una richiesta per ogni giorno lavorativo.', distanceLabel: 'Distanza', - summaryHomeAndOffice: 'Calcola in base a casa e ufficio', + summaryHomeAndOffice: 'Usa le località di casa e ufficio', optionHomeAndOfficeTitle: 'Calcola in base a casa e ufficio', optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa, la modalità di lavoro e l’assegnazione dell’ufficio del membro per calcolare le esclusioni dei tragitti.', workspaceAddressRequired: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d018e0c9a1af..adff9dbc8ae1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6686,7 +6686,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO optionFixedDistanceTitle: '申請ごとに一定距離を除外します', optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', distanceLabel: '距離', - summaryHomeAndOffice: '自宅とオフィスで計算', + summaryHomeAndOffice: '自宅とオフィスの位置を使用', optionHomeAndOfficeTitle: '自宅とオフィスで計算', optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィスの割り当てを使用して通勤控除を計算します。', workspaceAddressRequired: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 031738f6e67a..2550e9403997 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6737,7 +6737,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', distanceLabel: 'Afstand', - summaryHomeAndOffice: 'Berekenen via huis en kantoor', + summaryHomeAndOffice: 'Gebruik thuis- en kantoorlocaties', optionHomeAndOfficeTitle: 'Berekenen via huis en kantoor', optionHomeAndOfficeHelp: 'Gebruik het huisadres, de werkregeling en de kantoorindeling van het lid om woon-werkuitsluitingen te berekenen.', workspaceAddressRequired: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 6d3e3999b06b..c46cec691b4a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6731,7 +6731,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', distanceLabel: 'Dystans', - summaryHomeAndOffice: 'Oblicz na podstawie domu i biura', + summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', optionHomeAndOfficeTitle: 'Oblicz na podstawie domu i biura', optionHomeAndOfficeHelp: 'Użyj adresu domowego, modelu pracy i przypisania biura członka do obliczenia wykluczeń dojazdów.', workspaceAddressRequired: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4edbc5861c7a..e5f505c4447c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6732,7 +6732,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', distanceLabel: 'Distância', - summaryHomeAndOffice: 'Calcular por casa e escritório', + summaryHomeAndOffice: 'Usar locais de casa e escritório', optionHomeAndOfficeTitle: 'Calcular por casa e escritório', optionHomeAndOfficeHelp: 'Use o endereço residencial, o regime de trabalho e a alocação de escritório do membro para calcular as exclusões de trajeto.', workspaceAddressRequired: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5a906e52ecfd..9e8384699d49 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6559,7 +6559,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM optionFixedDistanceTitle: '为每笔报销排除固定距离', optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', distanceLabel: '距离', - summaryHomeAndOffice: '按家庭和办公室计算', + summaryHomeAndOffice: '使用家庭和办公室位置', optionHomeAndOfficeTitle: '按家庭和办公室计算', optionHomeAndOfficeHelp: '使用成员的家庭地址、工作安排和办公室分配来计算通勤排除。', workspaceAddressRequired: { From 006c8c075c30f4ec3e18d002d1d0b5ca59e272b1 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 23 Jun 2026 15:11:15 +0200 Subject: [PATCH 025/466] add new component --- src/ROUTES.ts | 4 +- src/hooks/useHomeAddressGateForDistance.ts | 65 +++++++++++++++++++ src/languages/de.ts | 8 +++ src/languages/en.ts | 8 +++ src/languages/es.ts | 8 +++ src/languages/fr.ts | 8 +++ src/languages/it.ts | 8 +++ src/languages/ja.ts | 8 +++ src/languages/nl.ts | 8 +++ src/languages/pl.ts | 8 +++ src/languages/pt-BR.ts | 8 +++ src/languages/zh-hans.ts | 8 +++ .../hooks/useTimeSensitiveHomeAddress.ts | 31 +++++++++ .../items/AddHomeAddress.tsx | 30 +++++++++ .../request/step/IOURequestStepDistance.tsx | 36 ++++++++++ .../step/confirmation/useExpenseSubmission.ts | 16 +++++ 16 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useHomeAddressGateForDistance.ts create mode 100644 src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts create mode 100644 src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 9cea364704c2..437d8b897f57 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4290,6 +4290,4 @@ type Route = { type DynamicRouteSuffix = (typeof DYNAMIC_ROUTES)[keyof typeof DYNAMIC_ROUTES]['path']; -export type {Route, DynamicRouteSuffix}; - -export type {Route, DynamicRouteSuffix}; +export type {Route, DynamicRouteSuffix}; \ No newline at end of file diff --git a/src/hooks/useHomeAddressGateForDistance.ts b/src/hooks/useHomeAddressGateForDistance.ts new file mode 100644 index 000000000000..90924b9d660f --- /dev/null +++ b/src/hooks/useHomeAddressGateForDistance.ts @@ -0,0 +1,65 @@ +import {useCallback} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; +import type Policy from '@src/types/onyx/Policy'; +import useConfirmModal from './useConfirmModal'; +import {useMemoizedLazyIllustrations} from './useLazyAsset'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; + +type UseHomeAddressGateForDistanceResult = { + /** + * True when the destination workspace uses homeAndOffice commuter exclusions but the current + * user has no saved home address. Callers should bail (and call `promptForHomeAddress`) before + * starting any distance request submission while this is true. + */ + needsHomeAddressPrompt: boolean; + + /** + * Show the blocking "Home address is required" modal. Confirming sends the user to the address + * section of their private personal details; the X dismiss just closes the modal. + */ + promptForHomeAddress: () => void; +}; + +/** + * Shared gate for distance-request flows: when the destination workspace uses the homeAndOffice + * commuter-exclusion method the per-member commute can't be computed without a saved home address. + * Used by every entry point that submits a distance request (start screen, confirmation step, + * quick actions) so dismissing the proactive modal can't slip through to an actual API call. + */ +function useHomeAddressGateForDistance(policy: OnyxEntry): UseHomeAddressGateForDistanceResult { + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const illustrations = useMemoizedLazyIllustrations(['House']); + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + + const usesHomeAndOfficeExclusions = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; + const memberHasHomeAddress = (privatePersonalDetails?.addresses ?? []).some((addressEntry) => !!addressEntry?.street?.trim()); + const needsHomeAddressPrompt = usesHomeAndOfficeExclusions && !memberHasHomeAddress; + + const promptForHomeAddress = useCallback(() => { + showConfirmModal({ + image: illustrations.House, + title: translate('iou.homeAddressRequired.title'), + prompt: translate('iou.homeAddressRequired.prompt', {workspaceName: policy?.name ?? ''}), + confirmText: translate('iou.homeAddressRequired.cta'), + shouldShowCancelButton: false, + shouldShowDismissIcon: true, + }).then(({action: modalAction}) => { + if (modalAction !== ModalActions.CONFIRM) { + return; + } + Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.getRoute(INPUT_IDS.ADDRESS_LINE_1)); + }); + }, [illustrations.House, policy?.name, showConfirmModal, translate]); + + return {needsHomeAddressPrompt, promptForHomeAddress}; +} + +export default useHomeAddressGateForDistance; diff --git a/src/languages/de.ts b/src/languages/de.ts index fc7c45ad50a8..74588801fd6b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1207,6 +1207,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Zeitaufwand erstellen', }, iou: { + homeAddressRequired: { + title: 'Heimadresse erforderlich', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Bevor du Distanz erfassen kannst, musst du deine Heimadresse zu deinem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` + : 'Bevor du Distanz erfassen kannst, musst du deine Heimadresse zu deinem privaten Profil hinzufügen. Dieser Arbeitsbereich verwendet diese Adresse für Pendlerabzüge.', + cta: 'Heimadresse hinzufügen', + }, amount: 'Betrag', percent: 'Prozent', date: 'Datum', diff --git a/src/languages/en.ts b/src/languages/en.ts index d8d587acb091..f764150d7758 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1271,6 +1271,14 @@ const translations = { createTimeExpense: 'Create time expense', }, iou: { + homeAddressRequired: { + title: 'Home address is required', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Before you track distance, you need to add your home address to your private profile. ${workspaceName} uses this address for commuter deductions.` + : 'Before you track distance, you need to add your home address to your private profile. This workspace uses this address for commuter deductions.', + cta: 'Add Home Address', + }, amount: 'Amount', percent: 'Percent', date: 'Date', diff --git a/src/languages/es.ts b/src/languages/es.ts index 81f40729622a..fde1445cf736 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1172,6 +1172,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Crear gasto de tiempo', }, iou: { + homeAddressRequired: { + title: 'Se requiere la dirección de casa', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Antes de registrar distancia, debes añadir tu dirección de casa a tu perfil privado. ${workspaceName} usa esta dirección para las deducciones de desplazamientos.` + : 'Antes de registrar distancia, debes añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo usa esta dirección para las deducciones de desplazamientos.', + cta: 'Añadir dirección de casa', + }, amount: 'Importe', percent: 'Porcentaje', date: 'Fecha', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b20ee1a824f6..24e84578289e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1211,6 +1211,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Créer une dépense de temps', }, iou: { + homeAddressRequired: { + title: 'Adresse personnelle requise', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Avant de pouvoir suivre la distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions de trajet.` + : 'Avant de pouvoir suivre la distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions de trajet.', + cta: 'Ajouter une adresse personnelle', + }, amount: 'Montant', percent: 'Pourcentage', date: 'Date', diff --git a/src/languages/it.ts b/src/languages/it.ts index 9267f196928b..06fb367f1de1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1207,6 +1207,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Crea nota spese tempo', }, iou: { + homeAddressRequired: { + title: 'È necessario un indirizzo di casa', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Prima di registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le deduzioni dei tragitti.` + : 'Prima di registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Quest’area di lavoro usa questo indirizzo per le deduzioni dei tragitti.', + cta: 'Aggiungi indirizzo di casa', + }, amount: 'Importo', percent: 'Percentuale', date: 'Data', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index adff9dbc8ae1..b49620720510 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1193,6 +1193,14 @@ const translations: TranslationDeepObject = { createTimeExpense: '時間経費を作成', }, iou: { + homeAddressRequired: { + title: '自宅住所が必要です', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `距離を記録するには、まずプライベートプロフィールに自宅住所を追加してください。${workspaceName} はこの住所を通勤控除に使用します。` + : '距離を記録するには、まずプライベートプロフィールに自宅住所を追加してください。このワークスペースはこの住所を通勤控除に使用します。', + cta: '自宅住所を追加', + }, amount: '金額', percent: 'パーセント', date: '日付', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2550e9403997..94d59b336e40 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1205,6 +1205,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Tijdkosten aanmaken', }, iou: { + homeAddressRequired: { + title: 'Huisadres is vereist', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Voordat je afstand kunt registreren, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werkaftrekken.` + : 'Voordat je afstand kunt registreren, moet je je huisadres toevoegen aan je privéprofiel. Deze werkruimte gebruikt dit adres voor woon-werkaftrekken.', + cta: 'Huisadres toevoegen', + }, amount: 'Bedrag', percent: 'Procent', date: 'Datum', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index c46cec691b4a..5380e15e3aaf 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1205,6 +1205,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Utwórz wydatek czasowy', }, iou: { + homeAddressRequired: { + title: 'Wymagany adres domowy', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Zanim zaczniesz rejestrować dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń dojazdów.` + : 'Zanim zaczniesz rejestrować dystans, musisz dodać swój adres domowy do prywatnego profilu. Ta przestrzeń robocza używa tego adresu do odliczeń dojazdów.', + cta: 'Dodaj adres domowy', + }, amount: 'Kwota', percent: 'Procent', date: 'Data', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e5f505c4447c..4351ceab2877 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1205,6 +1205,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Criar despesa de tempo', }, iou: { + homeAddressRequired: { + title: 'Endereço residencial obrigatório', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Antes de registrar distância, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa este endereço para deduções de trajeto.` + : 'Antes de registrar distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este espaço de trabalho usa este endereço para deduções de trajeto.', + cta: 'Adicionar endereço residencial', + }, amount: 'Valor', percent: 'Porcentagem', date: 'Data', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 9e8384699d49..4a84b2a68c0b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1162,6 +1162,14 @@ const translations: TranslationDeepObject = { createTimeExpense: '创建工时报销', }, iou: { + homeAddressRequired: { + title: '需要家庭地址', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `在记录距离之前,您需要在私人资料中添加家庭地址。${workspaceName} 将此地址用于通勤扣除。` + : '在记录距离之前,您需要在私人资料中添加家庭地址。此工作区将此地址用于通勤扣除。', + cta: '添加家庭地址', + }, amount: '金额', percent: '百分比', date: '日期', diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts new file mode 100644 index 000000000000..fd1a8df0fa9d --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts @@ -0,0 +1,31 @@ +import useOnyx from '@hooks/useOnyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +/** + * Surfaces the "Add a home address" item under Home > Time sensitive when the user belongs to at + * least one active workspace that has the homeAndOffice commuter-exclusion method enabled but has + * no home address recorded in their private personal details. + * + * Subscribes to the raw COLLECTION.POLICY (no selector) so every nested change to any policy + * - including switching method to fixedDistance or removing commuterExclusions entirely - is + * picked up reliably. Matches the pattern of the other useTimeSensitive* hooks in this folder. + */ +function useTimeSensitiveHomeAddress() { + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + + const hasHomeAndOfficeWorkspace = Object.values(allPolicies ?? {}).some( + (policy) => + policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && + policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + + const hasHomeAddress = (privatePersonalDetails?.addresses ?? []).some((address) => !!address?.street?.trim()); + + return { + shouldShowAddHomeAddress: hasHomeAndOfficeWorkspace && !hasHomeAddress, + }; +} + +export default useTimeSensitiveHomeAddress; diff --git a/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx b/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx new file mode 100644 index 000000000000..1fd79500fbf8 --- /dev/null +++ b/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import BaseWidgetItem from '@components/BaseWidgetItem'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; +import colors from '@styles/theme/colors'; +import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; + +function AddHomeAddress() { + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Home']); + + return ( + Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.getRoute(INPUT_IDS.ADDRESS_LINE_1))} + buttonProps={{success: true}} + /> + ); +} + +export default AddHomeAddress; diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 22dd35223b61..db25b283bd8e 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -15,6 +15,7 @@ import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useFetchRoute from '@hooks/useFetchRoute'; +import useHomeAddressGateForDistance from '@hooks/useHomeAddressGateForDistance'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -271,6 +272,24 @@ function IOURequestStepDistance({ return stop; }, []); + // When the destination workspace requires home/office commuter exclusions, the per-member + // commute deduction can't be computed unless the member has a home address. Surface a + // proactive modal as soon as the policy is known; submission-time gating lives in + // useExpenseSubmission so it covers every distance entry point. + const {needsHomeAddressPrompt, promptForHomeAddress} = useHomeAddressGateForDistance(policy); + const hasShownHomeAddressModalRef = useRef(false); + useEffect(() => { + if (!needsHomeAddressPrompt) { + hasShownHomeAddressModalRef.current = false; + return; + } + if (hasShownHomeAddressModalRef.current) { + return; + } + hasShownHomeAddressModalRef.current = true; + promptForHomeAddress(); + }, [needsHomeAddressPrompt, promptForHomeAddress]); + useEffect(() => { if (numberOfWaypoints <= numberOfPreviousWaypoints) { return; @@ -430,6 +449,13 @@ function IOURequestStepDistance({ ); const submitWaypoints = useCallback(() => { + // Hard block: if the destination workspace requires home/office commuter exclusions and the + // member has no home address, the per-member commute can't be computed - re-show the modal + // instead of letting them save. + if (needsHomeAddressPrompt) { + promptForHomeAddress(); + return; + } // If there is any error or loading state, don't let user go to next page. if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || (!isEditing && isLoading)) { setShouldShowAtLeastTwoDifferentWaypointsError(true); @@ -495,6 +521,8 @@ function IOURequestStepDistance({ navigateToNextStep(); }, [ + needsHomeAddressPrompt, + promptForHomeAddress, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, @@ -528,6 +556,12 @@ function IOURequestStepDistance({ ]); const submitManualDistance = useCallback(() => { + // Same home-address guard as submitWaypoints - block manual saves too in case the user + // dismissed the proactive modal and switched tabs. + if (needsHomeAddressPrompt) { + promptForHomeAddress(); + return; + } isManuallyEditing.current = false; // For a map-based distance edit, require valid waypoints even when saving from the Manual tab. @@ -599,6 +633,8 @@ function IOURequestStepDistance({ removeBackupTransaction(transaction?.transactionID); navigateBackAfterSave(); }, [ + needsHomeAddressPrompt, + promptForHomeAddress, translate, distanceRate, transactionID, diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 8b0c0b477ba5..0ff99d7ff538 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -3,6 +3,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import {useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import useActivePolicy from '@hooks/useActivePolicy'; +import useHomeAddressGateForDistance from '@hooks/useHomeAddressGateForDistance'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import useLocalize from '@hooks/useLocalize'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; @@ -288,6 +289,13 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const transactionIDs = transactions?.map((tx) => tx.transactionID); const [storedTransactions] = useTransactionsByID(transactionIDs); + // Centralised gate: any distance flow that submits through this hook is blocked while the + // user is missing a home address on a homeAndOffice workspace. The IOURequestStepDistance + // screen surfaces the same modal proactively when it mounts, but this catches every other + // entry point (e.g. quick actions, deep-links that skip the step) as well as the case where + // the user dismissed the proactive modal and went straight to confirmation. + const {needsHomeAddressPrompt: distanceNeedsHomeAddress, promptForHomeAddress: promptForDistanceHomeAddress} = useHomeAddressGateForDistance(policy); + function performPostBatchCleanup({ participant, shouldHandleNavigation, @@ -762,6 +770,14 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } function createTransaction(locationPermissionGranted = false, shouldHandleNavigation = true) { + // Distance-request home-address gate: bail before flipping any submission state so a + // missing home address never produces a half-submitted distance expense. We re-show the + // blocking modal here even if the user dismissed the proactive one on the step screen. + if (isDistanceRequest && distanceNeedsHomeAddress) { + promptForDistanceHomeAddress(); + return; + } + setIsConfirmed(true); const trimmedComment = transaction?.comment?.comment?.trim() ?? ''; From 1cbdc40b1e14c85a822d39302472531ffeb6cc7f Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 23 Jun 2026 15:40:02 +0200 Subject: [PATCH 026/466] prettier --- src/ROUTES.ts | 2 +- src/languages/es.ts | 3 ++- src/languages/fr.ts | 3 ++- src/languages/it.ts | 3 ++- src/libs/actions/Policy/DistanceRate.ts | 9 ++------- .../hooks/useTimeSensitiveHomeAddress.ts | 4 +--- .../distanceRates/PolicyCommuterExclusionsPage.tsx | 4 +--- .../distanceRates/PolicyDistanceRatesSettingsPage.tsx | 6 +----- 8 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 437d8b897f57..ea12ca2a6b09 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -4290,4 +4290,4 @@ type Route = { type DynamicRouteSuffix = (typeof DYNAMIC_ROUTES)[keyof typeof DYNAMIC_ROUTES]['path']; -export type {Route, DynamicRouteSuffix}; \ No newline at end of file +export type {Route, DynamicRouteSuffix}; diff --git a/src/languages/es.ts b/src/languages/es.ts index fde1445cf736..e6cdf4cc0b75 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -931,7 +931,8 @@ const translations: TranslationDeepObject = { }, addHomeAddress: { title: 'Añade una dirección de casa', - subtitle: 'Tu administrador habilitó exclusiones de desplazamientos para los gastos por distancia. Añade una dirección de casa para que podamos aplicarlas a tus reclamaciones.', + subtitle: + 'Tu administrador habilitó exclusiones de desplazamientos para los gastos por distancia. Añade una dirección de casa para que podamos aplicarlas a tus reclamaciones.', cta: 'Añadir dirección', }, fixFailedBilling: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 24e84578289e..431d8da9ba84 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -974,7 +974,8 @@ const translations: TranslationDeepObject = { validateAccount: {title: 'Validez votre compte', subtitle: 'Compte', cta: 'Valider'}, addHomeAddress: { title: 'Ajouter une adresse personnelle', - subtitle: 'Votre administrateur a activé les exclusions de trajet pour les dépenses de distance. Ajoutez une adresse personnelle pour que nous puissions les appliquer à vos réclamations.', + subtitle: + 'Votre administrateur a activé les exclusions de trajet pour les dépenses de distance. Ajoutez une adresse personnelle pour que nous puissions les appliquer à vos réclamations.', cta: 'Ajouter une adresse', }, fixFailedBilling: {title: 'Nous n’avons pas pu débiter votre carte enregistrée', subtitle: 'Abonnement'}, diff --git a/src/languages/it.ts b/src/languages/it.ts index 06fb367f1de1..14b5fa130c9a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -972,7 +972,8 @@ const translations: TranslationDeepObject = { validateAccount: {title: 'Conferma il tuo account', subtitle: 'Account', cta: 'Conferma'}, addHomeAddress: { title: 'Aggiungi un indirizzo di casa', - subtitle: 'Il tuo amministratore ha abilitato le esclusioni dei tragitti per le spese di distanza. Aggiungi un indirizzo di casa così possiamo applicarle alle tue richieste.', + subtitle: + 'Il tuo amministratore ha abilitato le esclusioni dei tragitti per le spese di distanza. Aggiungi un indirizzo di casa così possiamo applicarle alle tue richieste.', cta: 'Aggiungi indirizzo', }, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index 8b6b8530f1f7..ed188cf21a72 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -541,10 +541,7 @@ function setPolicyCommuterExclusions( ) { const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; - const optimisticCommuterExclusions: CommuterExclusions = - method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE - ? {method, fixedDistance, fixedDistanceUnit} - : {method}; + const optimisticCommuterExclusions: CommuterExclusions = method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE ? {method, fixedDistance, fixedDistanceUnit} : {method}; const onyxData: OnyxData = { optimisticData: [ @@ -582,9 +579,7 @@ function setPolicyCommuterExclusions( // Only send distance when the server actually needs it; homeAndOffice ignores the field. const parameters: SetPolicyCommuterExclusionsParams = - method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE - ? {policyID, commuterExclusionMethod: method, distance: fixedDistance} - : {policyID, commuterExclusionMethod: method}; + method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE ? {policyID, commuterExclusionMethod: method, distance: fixedDistance} : {policyID, commuterExclusionMethod: method}; API.write(WRITE_COMMANDS.SET_POLICY_COMMUTER_EXCLUSIONS, parameters, onyxData); } diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts index fd1a8df0fa9d..c1b0a6cc5538 100644 --- a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts @@ -16,9 +16,7 @@ function useTimeSensitiveHomeAddress() { const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); const hasHomeAndOfficeWorkspace = Object.values(allPolicies ?? {}).some( - (policy) => - policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && - policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + (policy) => policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); const hasHomeAddress = (privatePersonalDetails?.addresses ?? []).some((address) => !!address?.street?.trim()); diff --git a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx index b483e54f68e1..827d5af7d6e5 100644 --- a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx @@ -102,9 +102,7 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps prompt: ( {translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.promptStart')} - - {translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.linkText')} - + {translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.linkText')} {translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.promptEnd')} ), diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index b3926c4b5332..3f14f70853df 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -34,11 +34,7 @@ import type {CommuterExclusions, CustomUnit} from '@src/types/onyx/Policy'; type PolicyDistanceRatesSettingsPageProps = PlatformStackScreenProps; -function getCommuterExclusionsSummary( - commuterExclusions: CommuterExclusions | undefined, - defaultUnit: string | undefined, - translate: ReturnType['translate'], -): string { +function getCommuterExclusionsSummary(commuterExclusions: CommuterExclusions | undefined, defaultUnit: string | undefined, translate: ReturnType['translate']): string { if (commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { return translate('workspace.distanceRates.commuterExclusions.summaryHomeAndOffice'); } From a29ca9676cda9240d6438ac35d9f452522646c5a Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 24 Jun 2026 13:56:23 +0200 Subject: [PATCH 027/466] add files --- src/CONST/index.ts | 1 + .../API/parameters/ApprovalWorkflowParams.ts | 13 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/WorkflowUtils.ts | 762 ++++++++++++++++++ src/libs/actions/Workflow.ts | 84 +- src/types/onyx/ApprovalWorkflowRules.ts | 72 ++ src/types/onyx/Policy.ts | 10 +- 8 files changed, 943 insertions(+), 2 deletions(-) create mode 100644 src/libs/API/parameters/ApprovalWorkflowParams.ts create mode 100644 src/types/onyx/ApprovalWorkflowRules.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 01959d09842d..8274c3f09f30 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7043,6 +7043,7 @@ const CONST = { IS: 'is', REPORT_FIELD: 'reportField', EXPORTED_TO: 'exportedTo', + PREVIOUS_APPROVER: 'previousApprover', }, REPORT_FIELD: { // All report fields start with this, so use this to check if a search key is a report field diff --git a/src/libs/API/parameters/ApprovalWorkflowParams.ts b/src/libs/API/parameters/ApprovalWorkflowParams.ts new file mode 100644 index 000000000000..4eca1d7e3ec7 --- /dev/null +++ b/src/libs/API/parameters/ApprovalWorkflowParams.ts @@ -0,0 +1,13 @@ +type SetApprovalWorkflowParams = { + /** The policy ID whose approval-workflow rules are being mutated. */ + policyID: string; + + /** + * JSON-stringified object keyed by ruleID, mapping to either: + * - a full `ApprovalWorkflowRule` to add or replace, or + * - `null` / `{}` to remove the rule from the policy. + */ + rules: string; +}; + +export type {SetApprovalWorkflowParams}; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index f769632dfe53..cccf7141341a 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -527,6 +527,7 @@ export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; export type {default as SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams} from './SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams'; export type {default as SetPolicyCodingRuleParams} from './SetPolicyCodingRuleParams'; +export type {SetApprovalWorkflowParams} from './ApprovalWorkflowParams'; export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; export type {default as RevokeMultifactorAuthenticationCredentialsParams} from './RevokeMultifactorAuthenticationCredentialsParams'; export type {default as TroubleshootMultifactorAuthenticationParams} from './TroubleshootMultifactorAuthenticationParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index b359d5b38127..6f0cd7121759 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -273,6 +273,7 @@ const WRITE_COMMANDS = { SET_POLICY_TIME_TRACKING_DEFAULT_RATE: 'SetPolicyTimeTrackingDefaultRate', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', SET_POLICY_CODING_RULE: 'SetPolicyCodingRule', + SET_APPROVAL_WORKFLOW: 'SetApprovalWorkflow', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT: 'SetPolicyExpenseMaxAmountNoReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'SetPolicyExpenseMaxAmountNoItemizedReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT: 'SetPolicyExpenseMaxAmount', @@ -909,6 +910,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_TIME_TRACKING]: Parameters.EnablePolicyTimeTrackingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; [WRITE_COMMANDS.SET_POLICY_CODING_RULE]: Parameters.SetPolicyCodingRuleParams; + [WRITE_COMMANDS.SET_APPROVAL_WORKFLOW]: Parameters.SetApprovalWorkflowParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED]: Parameters.SetPolicyRequireCompanyCardsEnabledParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED]: Parameters.SetPolicyCategoryAttendeesRequiredParams; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index c5cff6e27412..031ffab87103 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -7,6 +7,7 @@ import CONST from '@src/CONST'; import type {BankAccountList} from '@src/types/onyx'; import type {ApprovalWorkflowOnyx, Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import type {ApprovalWorkflowFilter, ApprovalWorkflowFilterComparison, ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; import type PersonalDetails from '@src/types/onyx/PersonalDetails'; import type Policy from '@src/types/onyx/Policy'; @@ -14,6 +15,7 @@ import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; import {isBankAccountPartiallySetup} from './BankAccountUtils'; import {getHRAdvancedModeFinalApprover, getHRFinalApprover} from './HRUtils'; +import {rand64} from './NumberUtils'; import {getDefaultApprover, isExpensifyTeam, shouldFilterExpensifyTeam} from './PolicyUtils'; const INITIAL_APPROVAL_WORKFLOW: ApprovalWorkflowOnyx = { @@ -716,8 +718,763 @@ function mergeWorkflowMembersWithAvailableMembers(workflowMembers: Member[], all return [...workflowMembers, ...additionalMembers]; } +// --------------------------------------------------------------------------- +// Approval-workflow rules (rules.approvalWorkflows on the policy) +// --------------------------------------------------------------------------- +// +// These helpers translate the in-app `ApprovalWorkflow` model into the rule +// format the backend stores under `Policy.rules.approvalWorkflows`, and +// reconcile a workflow change against the existing rules so we can hand a +// minimal diff to `Auth/SetApprovalWorkflow`. +// +// Diff convention: a value of `ApprovalWorkflowRule` upserts the rule; a value +// of `null` removes it. JSON-stringified, that matches what Auth expects. + +const APPROVAL_WORKFLOW_FORWARD_ACTION = 'forward' as const; + +/** Diff shape passed to the backend: ruleID -> rule (upsert) or null (remove). */ +type ApprovalWorkflowRulesDiff = Record; + +/** Build a leaf comparison: ` `. */ +function buildComparison( + operator: ValueOf, + left: ApprovalWorkflowFilterComparison['left'], + right: ApprovalWorkflowFilterComparison['right'], +): ApprovalWorkflowFilterComparison { + return {operator, left, right}; +} + +/** Build the `from in [emails]` leaf used by every approval-workflow rule. */ +function buildFromComparison(memberEmails: string[]): ApprovalWorkflowFilterComparison { + return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, [...memberEmails]); +} + +/** Build the `previousApprover = email` leaf used by all non-initial-hop rules. */ +function buildPreviousApproverComparison(email: string): ApprovalWorkflowFilterComparison { + return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER, email); +} + +/** + * Build the `rules.approvalWorkflows` rule chain for a single `ApprovalWorkflow`. + * + * For a workflow whose approver chain is `[A0, A1, ..., A_{N-1}]` and whose members are `M`, + * we emit one rule per approver position. The rule at position `i` routes the report TO + * `approvers[i]`: + * + * - i == 0 (initial submission): filter is `from ∈ M`, nextReceiver is `A0.email`. + * - i > 0 (after `A_{i-1}` approves): filter is `from ∈ M AND previousApprover == A_{i-1}.email`, + * nextReceiver is `A_i.email`. + * + * When `A_{i-1}` has both `approvalLimit > 0` and `overLimitForwardsTo` set, the hop after + * them splits into two rules — an under-limit rule that continues to `A_i` and an over-limit + * counterpart that jumps directly to `A_{i-1}.overLimitForwardsTo` instead. + * + * No rule is emitted "after the last approver" because nothing forwards past the terminal + * approver in the chain. + */ +function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): ApprovalWorkflowRule[] { + const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); + const approvers = approvalWorkflow.approvers; + + if (memberEmails.length === 0 || approvers.length === 0) { + return []; + } + + const fromComparison = buildFromComparison(memberEmails); + const rules: ApprovalWorkflowRule[] = []; + + for (let i = 0; i < approvers.length; i++) { + const current = approvers[i]; + const previous = i > 0 ? approvers.at(i - 1) : undefined; + + // Initial-submission rule: degenerate single-comparison filter, no previousApprover gate. + if (!previous) { + rules.push({ + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: fromComparison}, + action: APPROVAL_WORKFLOW_FORWARD_ACTION, + nextReceiver: current.email, + }); + continue; + } + + const previousApproverComparison = buildPreviousApproverComparison(previous.email); + const hasLimitSplit = !!previous.approvalLimit && previous.approvalLimit > 0 && !!previous.overLimitForwardsTo; + + if (!hasLimitSplit) { + rules.push({ + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: fromComparison, right: previousApproverComparison}, + action: APPROVAL_WORKFLOW_FORWARD_ACTION, + nextReceiver: current.email, + }); + continue; + } + + // Split: amount < limit continues to the next approver, amount >= limit jumps to overLimit. + const limit = previous.approvalLimit as number; + const overLimitTarget = previous.overLimitForwardsTo as string; + + rules.push({ + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: fromComparison, + right: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: previousApproverComparison, + right: buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit), + }, + }, + action: APPROVAL_WORKFLOW_FORWARD_ACTION, + nextReceiver: current.email, + }); + + rules.push({ + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: fromComparison, + right: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: previousApproverComparison, + right: buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit), + }, + }, + action: APPROVAL_WORKFLOW_FORWARD_ACTION, + nextReceiver: overLimitTarget, + }); + } + + return rules; +} + +// --------------------------------------------------------------------------- +// Rule introspection helpers (used by reconciliation) +// --------------------------------------------------------------------------- + +/** True when a comparison node targets the `from` field with an equality operator. */ +function isFromComparison(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): boolean { + return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && (node as ApprovalWorkflowFilterComparison).left === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM; +} + +/** Walk a filter tree and call `visitor` on every `from` comparison leaf. */ +function forEachFromComparison(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined, visitor: (leaf: ApprovalWorkflowFilterComparison) => void): void { + if (!node) { + return; + } + if (isFromComparison(node)) { + visitor(node as ApprovalWorkflowFilterComparison); + return; + } + // Only filter nodes (which always carry a left filter) and AND/OR nodes have children to recurse into. + const leftIsNode = typeof (node as ApprovalWorkflowFilter).left === 'object' && (node as ApprovalWorkflowFilter).left !== null; + if (leftIsNode) { + forEachFromComparison((node as ApprovalWorkflowFilter).left, visitor); + } + const right = (node as ApprovalWorkflowFilter).right as ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined; + if (right && typeof right === 'object') { + forEachFromComparison(right, visitor); + } +} + +/** Extract the union of email values across every `from` leaf in a rule. */ +function extractFromEmails(rule: ApprovalWorkflowRule): string[] { + const emails = new Set(); + forEachFromComparison(rule.filters, (leaf) => { + const right = leaf.right; + if (Array.isArray(right)) { + right.forEach((email) => { + if (typeof email === 'string') { + emails.add(email); + } + }); + } else if (typeof right === 'string') { + emails.add(right); + } + }); + return Array.from(emails); +} + +/** + * Return a structural fingerprint of a rule with the `right` values of every `from` leaf + * stripped. Two rules with the same fingerprint differ only in their submitter list, which + * is what we look for when deciding whether to merge two workflows into a shared rule. + */ +function structuralFingerprint(rule: ApprovalWorkflowRule): string { + const stripFromValues = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): unknown => { + if (!node) { + return node; + } + if (isFromComparison(node)) { + return {operator: node.operator, left: (node as ApprovalWorkflowFilterComparison).left}; + } + const out: Record = {operator: node.operator}; + const left = (node as ApprovalWorkflowFilter).left; + if (left !== undefined) { + out.left = typeof left === 'object' && left !== null ? stripFromValues(left) : left; + } + const right = (node as ApprovalWorkflowFilter).right as unknown; + if (right !== undefined) { + out.right = typeof right === 'object' && right !== null ? stripFromValues(right as ApprovalWorkflowFilter) : right; + } + return out; + }; + + return JSON.stringify({ + filters: stripFromValues(rule.filters), + action: rule.action, + nextReceiver: rule.nextReceiver, + }); +} + +/** Replace the `right` value on every `from` leaf with `newEmails`. */ +function withFromEmails(rule: ApprovalWorkflowRule, newEmails: string[]): ApprovalWorkflowRule { + const rewrite = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison => { + if (isFromComparison(node)) { + return {...node, right: [...newEmails]} as ApprovalWorkflowFilterComparison; + } + const left = (node as ApprovalWorkflowFilter).left; + const right = (node as ApprovalWorkflowFilter).right as ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined; + return { + ...node, + ...(typeof left === 'object' && left !== null ? {left: rewrite(left)} : {}), + ...(right && typeof right === 'object' ? {right: rewrite(right)} : {}), + } as ApprovalWorkflowFilter; + }; + return {...rule, filters: rewrite(rule.filters) as ApprovalWorkflowFilter}; +} + +/** Union of `existingEmails` and `additions`, preserving the order of `existingEmails`. */ +function unionEmails(existingEmails: string[], additions: string[]): string[] { + const seen = new Set(existingEmails); + const result = [...existingEmails]; + additions.forEach((email) => { + if (seen.has(email)) { + return; + } + seen.add(email); + result.push(email); + }); + return result; +} + +/** `existingEmails` with everything in `removals` filtered out. */ +function differenceEmails(existingEmails: string[], removals: string[]): string[] { + const removalSet = new Set(removals); + return existingEmails.filter((email) => !removalSet.has(email)); +} + +// --------------------------------------------------------------------------- +// Reconciliation against the existing `rules.approvalWorkflows` map +// --------------------------------------------------------------------------- + +type ReconcileContext = { + /** The existing `Policy.rules.approvalWorkflows` snapshot, defaulting to `{}`. */ + existingRules: Record; +}; + +/** + * Reconcile a freshly created workflow against the existing rules. New rules that match an + * existing rule's structure (ignoring `from`) are folded into that existing rule by appending + * the new workflow's members to its `from` list. Anything left over is saved under a fresh + * client-generated ruleID. + */ +function reconcileApprovalWorkflowRulesForCreate(newRules: ApprovalWorkflowRule[], memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { + const diff: ApprovalWorkflowRulesDiff = {}; + const existingEntries = Object.entries(context.existingRules); + + for (const newRule of newRules) { + const fingerprint = structuralFingerprint(newRule); + const match = existingEntries.find(([, existing]) => structuralFingerprint(existing) === fingerprint); + + if (match) { + const [existingID, existingRule] = match; + const mergedEmails = unionEmails(extractFromEmails(existingRule), memberEmails); + diff[existingID] = withFromEmails(existingRule, mergedEmails); + continue; + } + + diff[rand64()] = newRule; + } + + return diff; +} + +/** + * Reconcile an edit to an existing workflow. Returns add/replace/remove instructions to + * morph the existing rule set into the new chain while preserving rules shared with other + * workflows. + * + * Two passes: + * 1. Walk every existing rule that contains any of the workflow's members. If a new rule + * structurally matches, leave it alone; otherwise either drop this workflow's members + * from the rule (when it's shared with other workflows) or remove the rule entirely. + * 2. For every new rule that didn't already exist, look for a structurally matching rule + * under a different membership and fold this workflow into it; failing that, create + * a fresh rule with a new ruleID. + */ +function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { + const diff: ApprovalWorkflowRulesDiff = {}; + const memberSet = new Set(memberEmails); + const newFingerprints = new Set(newRules.map(structuralFingerprint)); + + // Track which existing rules we've already turned into a no-op match so we don't drop them in pass 2. + const matchedExistingIDs = new Set(); + + // Pass 1: walk existing rules that belong (at least partially) to this workflow. + for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { + const ruleEmails = extractFromEmails(existingRule); + const sharedWithThisWorkflow = ruleEmails.some((email) => memberSet.has(email)); + if (!sharedWithThisWorkflow) { + continue; + } + + const fingerprint = structuralFingerprint(existingRule); + if (newFingerprints.has(fingerprint)) { + // Structurally identical to a new rule: leave it alone but remember it as "covered". + matchedExistingIDs.add(ruleID); + continue; + } + + const remaining = differenceEmails(ruleEmails, memberEmails); + if (remaining.length === 0) { + // No other workflow shares this rule — drop it entirely. + diff[ruleID] = null; + } else { + // Keep the rule for the other workflows that still need it, minus our members. + diff[ruleID] = withFromEmails(existingRule, remaining); + } + } + + // Pass 2: create or extend rules for any new rule that wasn't already covered. + const existingEntries = Object.entries(context.existingRules); + for (const newRule of newRules) { + const fingerprint = structuralFingerprint(newRule); + + // Already in place via a pass-1 match — nothing to do. + const alreadyCovered = existingEntries.some(([id, existing]) => matchedExistingIDs.has(id) && structuralFingerprint(existing) === fingerprint); + if (alreadyCovered) { + continue; + } + + // Look for a rule belonging to a different workflow that we can fold into. + const foreignMatch = existingEntries.find(([id, existing]) => { + if (matchedExistingIDs.has(id)) { + return false; + } + if (structuralFingerprint(existing) !== fingerprint) { + return false; + } + const existingEmails = extractFromEmails(existing); + // "Different workflow" => no overlap with our members. + return !existingEmails.some((email) => memberSet.has(email)); + }); + + if (foreignMatch) { + const [existingID, existingRule] = foreignMatch; + const mergedEmails = unionEmails(extractFromEmails(existingRule), memberEmails); + diff[existingID] = withFromEmails(existingRule, mergedEmails); + continue; + } + + diff[rand64()] = newRule; + } + + return diff; +} + +/** + * Reconcile the deletion of a workflow. Rules that listed only this workflow's members in + * their `from` filter are removed outright; rules shared with other workflows have just + * this workflow's members stripped from `from`. + */ +function reconcileApprovalWorkflowRulesForRemove(memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { + const diff: ApprovalWorkflowRulesDiff = {}; + const memberSet = new Set(memberEmails); + + for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { + const ruleEmails = extractFromEmails(existingRule); + const sharedWithThisWorkflow = ruleEmails.some((email) => memberSet.has(email)); + if (!sharedWithThisWorkflow) { + continue; + } + + const remaining = differenceEmails(ruleEmails, memberEmails); + diff[ruleID] = remaining.length === 0 ? null : withFromEmails(existingRule, remaining); + } + + return diff; +} + +/** + * Reconcile a member-only change (the approver chain is unchanged but the workflow's + * member list changed). For every existing rule that includes any of the previous members + * we add the new members and drop any previous members that aren't part of the new set. + */ +function reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails: string[], newMemberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { + const diff: ApprovalWorkflowRulesDiff = {}; + const previousSet = new Set(previousMemberEmails); + const newSet = new Set(newMemberEmails); + const removed = previousMemberEmails.filter((email) => !newSet.has(email)); + + for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { + const ruleEmails = extractFromEmails(existingRule); + if (!ruleEmails.some((email) => previousSet.has(email))) { + continue; + } + + const afterRemoval = differenceEmails(ruleEmails, removed); + const updatedEmails = unionEmails(afterRemoval, newMemberEmails); + + // No effective change to this rule's `from` — skip the round-trip. + if (updatedEmails.length === ruleEmails.length && updatedEmails.every((email, idx) => email === ruleEmails[idx])) { + continue; + } + + diff[ruleID] = updatedEmails.length === 0 ? null : withFromEmails(existingRule, updatedEmails); + } + + return diff; +} + +// --------------------------------------------------------------------------- +// Reconstruct ApprovalWorkflow[] from `policy.rules.approvalWorkflows` +// --------------------------------------------------------------------------- +// +// Inverse of `buildApprovalWorkflowRules`. Given the policy's rule set we walk +// each submitter's hop chain and rebuild the same `PolicyConversionResult` the +// legacy employeeList-based converter produces, so the rest of the workflows UI +// keeps working unchanged. +// +// Per-hop employeeList fallback: when a hop has no matching rule we fall back +// to that approver's employeeList entry. This keeps pre-beta workflows visible +// during the migration window. We deliberately don't fuse legacy chains with +// rule-based chains even when they share a shape — the legacy chains will go +// away on their own as workflows are re-saved. + +/** A leaf comparison node — its `left` is a primitive (field name), unlike a boolean filter whose `left` is another node. */ +function isComparisonLeaf(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): node is ApprovalWorkflowFilterComparison { + if (!node) { + return false; + } + const nodeLeft = (node as ApprovalWorkflowFilter).left; + return typeof nodeLeft !== 'object' || nodeLeft === null || Array.isArray(nodeLeft); +} + +/** Return the first comparison leaf in the filter tree whose `left` field matches. */ +function findComparisonByLeft(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined, leftKey: string): ApprovalWorkflowFilterComparison | undefined { + if (!node) { + return undefined; + } + if (isComparisonLeaf(node)) { + return node.left === leftKey ? node : undefined; + } + const fromLeft = findComparisonByLeft((node as ApprovalWorkflowFilter).left, leftKey); + if (fromLeft) { + return fromLeft; + } + const right = (node as ApprovalWorkflowFilter).right as ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined; + return findComparisonByLeft(right, leftKey); +} + +/** Initial-submission rule for a submitter — `from` includes them and no `previousApprover` filter exists. */ +function findInitialSubmissionRule(submitter: string, rules: Record): ApprovalWorkflowRule | undefined { + for (const rule of Object.values(rules)) { + if (!extractFromEmails(rule).includes(submitter)) { + continue; + } + if (!findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER)) { + return rule; + } + } + return undefined; +} + +/** Resolved hop info — where the report goes after the current approver acts, and any limit split that controlled it. */ +type ApprovalWorkflowHopInfo = { + /** Next approver email (under-limit path, or the only path when no split). */ + forwardsTo: string; + /** Amount threshold from the under/over-limit split, if any. */ + approvalLimit?: number; + /** Over-limit forward target, if the hop is split. */ + overLimitForwardsTo?: string; +}; + +/** + * Look for the rules describing what happens after `currentApproverEmail` approves a report + * submitted by `submitter`. Returns: + * - {forwardsTo, approvalLimit, overLimitForwardsTo} when both halves of a limit split exist + * - {forwardsTo} when there's a single normal rule (or only one half of a partial/broken split) + * - undefined when no matching rule exists, signaling the caller to fall back to employeeList + */ +function extractHopFromRules(submitter: string, currentApproverEmail: string, rules: Record): ApprovalWorkflowHopInfo | undefined { + let underLimitRule: ApprovalWorkflowRule | undefined; + let overLimitRule: ApprovalWorkflowRule | undefined; + let normalRule: ApprovalWorkflowRule | undefined; + let approvalLimit: number | undefined; + + for (const rule of Object.values(rules)) { + if (!extractFromEmails(rule).includes(submitter)) { + continue; + } + const previousApproverLeaf = findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER); + if (!previousApproverLeaf || previousApproverLeaf.right !== currentApproverEmail) { + continue; + } + + const amountLeaf = findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT); + if (amountLeaf && typeof amountLeaf.right === 'number') { + if (amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN) { + underLimitRule = rule; + approvalLimit = amountLeaf.right; + } else if (amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO) { + overLimitRule = rule; + approvalLimit = approvalLimit ?? amountLeaf.right; + } else { + normalRule = rule; + } + } else { + normalRule = rule; + } + } + + if (underLimitRule && overLimitRule) { + return { + forwardsTo: underLimitRule.nextReceiver, + approvalLimit, + overLimitForwardsTo: overLimitRule.nextReceiver, + }; + } + if (normalRule) { + return {forwardsTo: normalRule.nextReceiver}; + } + // Partial split (one half missing): use whichever rule we found as the next hop. This is a + // best-effort recovery from a corrupted rule pair rather than an expected state. + const partial = underLimitRule ?? overLimitRule; + if (partial) { + return {forwardsTo: partial.nextReceiver}; + } + return undefined; +} + +type BuildApproverChainFromRulesParams = { + submitter: string; + rules: Record; + employees: PolicyEmployeeList; + personalDetailsByEmail: PersonalDetailsList; +}; + +/** + * Walk a submitter's approval chain by following rules from their initial-submission rule + * forward. Each hop's `forwardsTo` / `approvalLimit` / `overLimitForwardsTo` is taken from + * the rule when one exists, and falls back to the current approver's `employeeList` entry + * when the rule chain runs out. Stops at circular references (matching `calculateApprovers`). + */ +function buildApproverChainFromRules({submitter, rules, employees, personalDetailsByEmail}: BuildApproverChainFromRulesParams): Approver[] { + // First approver: prefer the initial-submission rule, fall back to employeeList.submitsTo for + // pre-beta workflows that haven't been re-saved yet. + const initialRule = findInitialSubmissionRule(submitter, rules); + let currentEmail: string | undefined = initialRule?.nextReceiver ?? employees[submitter]?.submitsTo; + if (!currentEmail) { + return []; + } + + const chain: Approver[] = []; + const seenEmails = new Set(); + + while (currentEmail) { + const isCircularReference = seenEmails.has(currentEmail); + const employee: PolicyEmployee | undefined = employees[currentEmail]; + + const hopInfo = extractHopFromRules(submitter, currentEmail, rules); + const approvalLimit: number | null = hopInfo?.approvalLimit ?? employee?.approvalLimit ?? null; + const overLimitForwardsTo: string | undefined = hopInfo?.overLimitForwardsTo ?? employee?.overLimitForwardsTo; + const forwardsTo: string | undefined = hopInfo?.forwardsTo ?? employee?.forwardsTo; + + chain.push({ + email: currentEmail, + forwardsTo, + avatar: personalDetailsByEmail[currentEmail]?.avatar, + displayName: personalDetailsByEmail[currentEmail]?.displayName ?? currentEmail, + isCircularReference, + approvalLimit, + overLimitForwardsTo, + overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(overLimitForwardsTo, personalDetailsByEmail), + pendingAction: employee?.pendingAction, + errors: employee?.errors, + }); + + if (isCircularReference) { + break; + } + seenEmails.add(currentEmail); + currentEmail = forwardsTo; + } + + return chain; +} + +/** Structural identity of a chain — used to fold submitters with identical chains into one workflow. */ +function approverChainFingerprint(chain: Approver[]): string { + return JSON.stringify( + chain.map((approver) => ({ + email: approver.email, + approvalLimit: approver.approvalLimit ?? null, + overLimitForwardsTo: approver.overLimitForwardsTo ?? null, + isCircularReference: !!approver.isCircularReference, + })), + ); +} + +/** True when at least one rule mentions this submitter in any `from` filter. */ +function isSubmitterCoveredByRules(submitter: string, rules: Record): boolean { + for (const rule of Object.values(rules)) { + if (extractFromEmails(rule).includes(submitter)) { + return true; + } + } + return false; +} + +/** + * Beta-enabled counterpart to `convertPolicyEmployeesToApprovalWorkflows`: rebuild the same + * `PolicyConversionResult` shape from `policy.rules.approvalWorkflows`, with per-hop + * fallback to `employeeList` for any chain step the rules don't cover. Rule-based chains + * are kept separate from legacy chains even when their shapes match — the legacy ones are + * expected to disappear as workflows are migrated. + */ +function convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, firstApprover, localeCompare, currentUserLogin}: PolicyConversionParams): PolicyConversionResult { + const employees = policy?.employeeList ?? {}; + const rules = policy?.rules?.approvalWorkflows ?? {}; + const defaultApprover = getHRFinalApprover(policy) ?? getDefaultApprover(policy); + const shouldFilterOutExpensifyTeam = shouldFilterExpensifyTeam(policy?.owner, currentUserLogin); + const hrAdvancedModeFinalApproverEmail = getHRAdvancedModeFinalApprover(policy); + + const personalDetailsByEmail: PersonalDetailsList = {}; + for (const [key, value] of Object.entries(personalDetails)) { + personalDetailsByEmail[value?.login ?? key] = value; + } + + // Source-tagged fingerprint groups so a legacy chain and a rule-based chain with the same + // shape stay in separate workflows. Tag values: 'r' (any rule mentions the submitter) or 'l'. + type WorkflowGroup = { + chain: Approver[]; + members: Member[]; + isDefault: boolean; + pendingAction: ApprovalWorkflow['pendingAction']; + }; + const groupedByFingerprint = new Map(); + const usedApproverEmails = new Set(); + const availableMembers: Member[] = []; + + for (const employee of Object.values(employees)) { + const {email, submitsTo, pendingAction} = employee; + if (!email) { + continue; + } + if (shouldFilterOutExpensifyTeam && isExpensifyTeam(email)) { + continue; + } + + const member = buildMemberFromEmployee(employee, personalDetailsByEmail, email); + + if (pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + availableMembers.push(member); + } + + const initialRule = findInitialSubmissionRule(email, rules); + if (!initialRule && (!submitsTo || (!employees[submitsTo] && !hrAdvancedModeFinalApproverEmail))) { + // Mirrors the existing legacy filter: skip submitters whose first approver isn't reachable. + continue; + } + + let chain = buildApproverChainFromRules({submitter: email, rules, employees, personalDetailsByEmail}); + if (chain.length === 0) { + continue; + } + + if (shouldFilterOutExpensifyTeam) { + chain = chain.filter((approver) => !isExpensifyTeam(approver.email)); + } + if (hrAdvancedModeFinalApproverEmail) { + const last = chain.at(-1); + if (last && last.email !== hrAdvancedModeFinalApproverEmail) { + chain.push({ + email: hrAdvancedModeFinalApproverEmail, + forwardsTo: undefined, + avatar: personalDetailsByEmail[hrAdvancedModeFinalApproverEmail]?.avatar, + displayName: personalDetailsByEmail[hrAdvancedModeFinalApproverEmail]?.displayName ?? hrAdvancedModeFinalApproverEmail, + isCircularReference: false, + }); + usedApproverEmails.add(hrAdvancedModeFinalApproverEmail); + } + } + if (chain.length === 0) { + continue; + } + + const firstApproverEmail = chain[0].email; + if (firstApproverEmail !== firstApprover) { + for (const approver of chain) { + usedApproverEmails.add(approver.email); + } + } + + const sourceTag = isSubmitterCoveredByRules(email, rules) ? 'r' : 'l'; + const fingerprint = `${sourceTag}|${approverChainFingerprint(chain)}`; + const existingGroup = groupedByFingerprint.get(fingerprint); + + if (existingGroup) { + if (pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + existingGroup.members.push(member); + } + if (pendingAction && pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + existingGroup.pendingAction = pendingAction; + } + continue; + } + + const workflowPendingAction = pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? pendingAction : undefined; + groupedByFingerprint.set(fingerprint, { + chain, + members: pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? [member] : [], + isDefault: firstApproverEmail === defaultApprover, + pendingAction: workflowPendingAction, + }); + } + + const sortedApprovalWorkflows: ApprovalWorkflow[] = Array.from(groupedByFingerprint.values()) + .map(({chain, members, isDefault, pendingAction}) => ({ + members, + approvers: chain, + isDefault, + pendingAction, + })) + .sort((a, b) => { + if (a.isDefault) { + return -1; + } + if (b.isDefault) { + return 1; + } + return localeCompare(a.approvers.at(0)?.displayName ?? '', b.approvers.at(0)?.displayName ?? ''); + }); + + const firstWorkflow = sortedApprovalWorkflows.at(0); + if (firstWorkflow && !firstWorkflow.isDefault) { + sortedApprovalWorkflows.unshift({ + members: [], + approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail}), + isDefault: true, + }); + } + + availableMembers.sort((a, b) => localeCompare(a.displayName ?? a.email, b.displayName ?? b.email)); + + return {approvalWorkflows: sortedApprovalWorkflows, usedApproverEmails: [...usedApproverEmails], availableMembers}; +} + export { + buildApprovalWorkflowRules, calculateApprovers, + convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, getApprovalLimitDescription, @@ -726,5 +1483,10 @@ export { getOverLimitForwardsToDisplayName, INITIAL_APPROVAL_WORKFLOW, mergeWorkflowMembersWithAvailableMembers, + reconcileApprovalWorkflowRulesForCreate, + reconcileApprovalWorkflowRulesForEdit, + reconcileApprovalWorkflowRulesForMembersChange, + reconcileApprovalWorkflowRulesForRemove, updateWorkflowDataOnApproverRemoval, }; +export type {ApprovalWorkflowRulesDiff}; diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 1bf80b830df2..6f0c09e6b669 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -2,9 +2,11 @@ import lodashDropRightWhile from 'lodash/dropRightWhile'; import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; -import type {CreateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams, UpdateWorkspaceApprovalParams} from '@libs/API/parameters'; +import type {CreateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams, SetApprovalWorkflowParams, UpdateWorkspaceApprovalParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; import {getDefaultApprover} from '@libs/PolicyUtils'; +import type {ApprovalWorkflowRulesDiff} from '@libs/WorkflowUtils'; import {calculateApprovers, convertApprovalWorkflowToPolicyEmployees, getOverLimitForwardsToDisplayName, mergeWorkflowMembersWithAvailableMembers} from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -12,6 +14,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ApprovalWorkflowOnyx, PersonalDetailsList, Policy, Report} from '@src/types/onyx'; import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {completeTask} from './Task'; @@ -262,6 +265,84 @@ function removeApprovalWorkflow(approvalWorkflow: ApprovalWorkflow, policy: Onyx API.write(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); } +type SetApprovalWorkflowRulesParams = { + policyID: string; + + /** + * Diff to apply to `Policy.rules.approvalWorkflows`. A value of `ApprovalWorkflowRule` + * upserts the rule under its `ruleID`; a value of `null` removes it. Produced by the + * `reconcileApprovalWorkflowRules*` helpers in `WorkflowUtils`. + */ + rulesDiff: ApprovalWorkflowRulesDiff; + + /** Snapshot of the policy's `rules.approvalWorkflows` taken before applying `rulesDiff`. Used to roll back on failure. */ + previousApprovalWorkflows: Record; +}; + +/** + * Apply a set of approval-workflow rule changes to a policy via the new SetApprovalWorkflow + * Auth command. Mirrors the optimistic/success/failure dance of `createApprovalWorkflow`, + * but operates on `rules.approvalWorkflows` instead of `employeeList`. + * + * No-op when `rulesDiff` is empty — there's nothing to send to the backend. + */ +function setApprovalWorkflowRules({policyID, rulesDiff, previousApprovalWorkflows}: SetApprovalWorkflowRulesParams) { + if (!policyID || isEmptyObject(rulesDiff)) { + return; + } + + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + + // Build the rollback shape: for every rule we're about to touch, either restore the + // previous value (if one existed) or clear the optimistic upsert with `null`. Without + // the explicit `null`s, newly-added rules would survive the failure rollback. + const failureApprovalWorkflows: Record = {}; + for (const ruleID of Object.keys(rulesDiff)) { + failureApprovalWorkflows[ruleID] = previousApprovalWorkflows[ruleID] ?? null; + } + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + rules: {approvalWorkflows: rulesDiff}, + pendingFields: {approvalWorkflows: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + errorFields: {approvalWorkflows: null}, + }, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + pendingFields: {approvalWorkflows: null}, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + rules: {approvalWorkflows: failureApprovalWorkflows}, + pendingFields: {approvalWorkflows: null}, + errorFields: {approvalWorkflows: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + }, + ]; + + const parameters: SetApprovalWorkflowParams = { + policyID, + rules: JSON.stringify(rulesDiff), + }; + + API.write(WRITE_COMMANDS.SET_APPROVAL_WORKFLOW, parameters, {optimisticData, successData, failureData}); +} + /** Set the members of the approval workflow that is currently edited */ function setApprovalWorkflowMembers(members: Member[]) { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {members, errors: null}); @@ -428,6 +509,7 @@ export { setApprovalWorkflowMembers, setApprovalWorkflowApprover, setApprovalWorkflow, + setApprovalWorkflowRules, selectApprovalWorkflowForEdit, clearApprovalWorkflowApprover, clearApprovalWorkflowApprovers, diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts new file mode 100644 index 000000000000..63dba2016ef2 --- /dev/null +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -0,0 +1,72 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +/** + * Operand of a comparison in an approval-workflow filter. + * + * - The left operand is a field identifier — one of the search-syntax filter keys (extended + * with `previousApprover` for approval rules). + * - The right operand is the literal value the field is compared against. For email-typed + * fields like `from`, the value may be a list of emails to match against. + */ +type ApprovalWorkflowFilterOperand = ValueOf | string | number | string[]; + +/** + * A single comparison node: ` `. + * + * `right` is optional because a future operator may be unary, but for the operators currently + * in use (`eq`, `lt`, `gte`, ...) both sides are required. + */ +type ApprovalWorkflowFilterComparison = { + /** The comparison operator. */ + operator: ValueOf; + + /** The field identifier being compared. */ + left: ApprovalWorkflowFilterOperand; + + /** The literal value being compared against. */ + right?: ApprovalWorkflowFilterOperand; +}; + +/** + * Top-level filter for an approval-workflow rule. Combines two child nodes with a boolean + * operator. `left` / `right` may be either leaf comparisons or nested filters, so the tree + * can express arbitrarily deep `from AND (previousApprover AND amount < limit)` shapes. + * + * For this project the operator is constrained to `AND` at runtime, but the type uses + * `ValueOf` so future operators (e.g. `OR`) can slot in without changing + * the contract. + */ +type ApprovalWorkflowFilter = { + /** Boolean combinator — currently always `AND` in production. */ + operator: ValueOf; + + /** Left-hand child: leaf comparison or nested boolean filter. */ + left: ApprovalWorkflowFilterComparison | ApprovalWorkflowFilter; + + /** Right-hand child. Optional so a degenerate single-comparison filter can be represented. */ + right?: ApprovalWorkflowFilterComparison | ApprovalWorkflowFilter; +}; + +/** + * A single approval-workflow rule, keyed by ruleID inside `Policy.rules.approvalWorkflows`. + * When all `filters` match a report, the report is forwarded to `nextReceiver`. + */ +type ApprovalWorkflowRule = { + /** Conditions that must match the report for the rule to fire. */ + filters: ApprovalWorkflowFilter; + + /** Currently the only supported action. Kept as a literal to leave room for future actions. */ + action: 'forward'; + + /** Email of the user the report is forwarded to when the rule matches. */ + nextReceiver: string; +}; + +/** + * Map of approval-workflow rules keyed by ruleID, as stored on the policy under + * `rules.approvalWorkflows`. + */ +type ApprovalWorkflowRules = Record; + +export type {ApprovalWorkflowFilter, ApprovalWorkflowFilterComparison, ApprovalWorkflowFilterOperand, ApprovalWorkflowRule, ApprovalWorkflowRules}; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 7473b11e5d4c..6e9d6d6b6903 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -5,6 +5,7 @@ import type CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type {MergeHRProviderSlug} from '@src/CONST/MERGE_HR_PROVIDERS'; import type * as OnyxTypes from '.'; +import type {ApprovalWorkflowRule} from './ApprovalWorkflowRules'; import type * as OnyxCommon from './OnyxCommon'; import type {WorkspaceTravelSettings} from './TravelSettings'; @@ -2293,6 +2294,13 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** A set of Agent rules defined with natural language - The rules are run by the "RuleBot" */ agentRules?: Record; + + /** + * Approval-workflow rules keyed by ruleID. Each rule routes a report to a `nextReceiver` + * when its `filters` match. Multiple workflows can share the same rule by listing more + * than one submitter in the `from` filter. + */ + approvalWorkflows?: Record; }; /** The "RuleBot" agent account ID */ @@ -2439,7 +2447,7 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the policy requires purchases to be on a company card */ requireCompanyCardsEnabled?: boolean; } & Partial, - 'addWorkspaceRoom' | keyof ACHAccount | keyof Attributes | 'isHREnabled' | 'isTimeTrackingEnabled' | 'timeTrackingDefaultRate' + 'addWorkspaceRoom' | keyof ACHAccount | keyof Attributes | 'isHREnabled' | 'isTimeTrackingEnabled' | 'timeTrackingDefaultRate' | 'approvalWorkflows' >; /** Stages of policy connection sync */ From 88c4fae4907ae438cdded3a06f808ab89f5b5325 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Fri, 26 Jun 2026 00:18:38 +0500 Subject: [PATCH 028/466] =?UTF-8?q?Concern=201=20(4800651927)=20=E2=80=94?= =?UTF-8?q?=20isSearchStringMatch=20framing=20&=20eager=20.map()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/libs/OptionsListUtils/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index da5e0fd22197..271b763da0f0 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -537,8 +537,8 @@ function getAlternateText( function isSearchStringMatch(searchValue: string, searchText?: string | null, participantNames = new Set(), isReportChatRoom = false): boolean { const searchWords = Array.from(new Set(searchValue.replaceAll(',', ' ').split(/\s+/).filter(Boolean))); const valueToSearch = searchText?.replaceAll(new RegExp(/ /g), ''); - const compiledRegexes = searchWords.map((word) => ({word, regex: new RegExp(Str.escapeForRegExp(word), 'i')})); - for (const {word, regex} of compiledRegexes) { + for (const word of searchWords) { + const regex = new RegExp(Str.escapeForRegExp(word), 'i'); if (!(regex.test(valueToSearch ?? '') || (!isReportChatRoom && participantNames.has(word)))) { return false; } From 420b7a265469dab0527c7acb8afa3341add5e9bb Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 26 Jun 2026 15:50:05 +0200 Subject: [PATCH 029/466] better rule building --- src/CONST/index.ts | 1 + src/libs/WorkflowUtils.ts | 116 ++++++++++-------- src/libs/actions/Workflow.ts | 36 +++++- .../WorkspaceWorkflowsApprovalsCreatePage.tsx | 12 +- src/types/onyx/ApprovalWorkflowRules.ts | 8 +- 5 files changed, 113 insertions(+), 60 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8274c3f09f30..211d316ca31e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1042,6 +1042,7 @@ const CONST = { VENDOR_MATCHING: 'vendorMatching', RULES_REVAMP: 'rulesRevamp', COMMUTER_EXCLUSIONS: 'commuterExclusions', + MULTIPLE_APPROVERS: 'multipleApprovers', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 031ffab87103..6ba167b73ecb 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -754,23 +754,49 @@ function buildPreviousApproverComparison(email: string): ApprovalWorkflowFilterC return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER, email); } +/** + * Assemble the filter tree for a single rule from its parts. The `from` comparison is always + * present; the `previousApprover` gate and `amount` comparison are optional. When neither is + * present we return the bare `from` comparison rather than wrapping it in an `AND` with no `right`. + */ +function buildLevelFilters( + fromComparison: ApprovalWorkflowFilterComparison, + previousApproverComparison: ApprovalWorkflowFilterComparison | undefined, + amountComparison: ApprovalWorkflowFilterComparison | undefined, +): ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison { + let right: ApprovalWorkflowFilterComparison | ApprovalWorkflowFilter | undefined; + if (previousApproverComparison && amountComparison) { + right = {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: previousApproverComparison, right: amountComparison}; + } else { + right = previousApproverComparison ?? amountComparison; + } + + if (!right) { + return fromComparison; + } + + return {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: fromComparison, right}; +} + /** * Build the `rules.approvalWorkflows` rule chain for a single `ApprovalWorkflow`. * - * For a workflow whose approver chain is `[A0, A1, ..., A_{N-1}]` and whose members are `M`, - * we emit one rule per approver position. The rule at position `i` routes the report TO - * `approvers[i]`: - * - * - i == 0 (initial submission): filter is `from ∈ M`, nextReceiver is `A0.email`. - * - i > 0 (after `A_{i-1}` approves): filter is `from ∈ M AND previousApprover == A_{i-1}.email`, - * nextReceiver is `A_i.email`. + * For a workflow whose approver chain is `[A0, A1, ..., A_{N-1}]` and whose members are `M`, we emit + * the rule(s) that route the report INTO each level. The split happens at the level being routed + * into: at level `i`, the approver `A_i` and their over-limit approver `A_i.overLimitForwardsTo` + * share the same `previousApprover` gate — `A_{i-1}.email` — or NO `previousApprover` at all for the + * first level (a freshly-submitted report). The level's `A_i.approvalLimit` drives the mirrored + * amount pair: * - * When `A_{i-1}` has both `approvalLimit > 0` and `overLimitForwardsTo` set, the hop after - * them splits into two rules — an under-limit rule that continues to `A_i` and an over-limit - * counterpart that jumps directly to `A_{i-1}.overLimitForwardsTo` instead. + * - i == 0, no limit: `from ∈ M` → A0 + * - i == 0, limit: `from ∈ M AND amount < limit` → A0 + * `from ∈ M AND amount >= limit` → A0.overLimitForwardsTo + * - i > 0, no limit: `from ∈ M AND previousApprover == A_{i-1}` → A_i + * - i > 0, limit: `from ∈ M AND (previousApprover == A_{i-1} AND amount < limit)` → A_i + * `from ∈ M AND (previousApprover == A_{i-1} AND amount >= limit)` → A_i.overLimitForwardsTo * - * No rule is emitted "after the last approver" because nothing forwards past the terminal - * approver in the chain. + * The last approver still gets an "into" rule (routed from the previous level); there's just no rule + * forwarding back OUT of them, since nothing forwards past the terminal approver. */ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): ApprovalWorkflowRule[] { const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); @@ -784,61 +810,43 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva const rules: ApprovalWorkflowRule[] = []; for (let i = 0; i < approvers.length; i++) { - const current = approvers[i]; + const approver = approvers.at(i); + if (!approver) { + continue; + } + + // The level's `previousApprover` gate is the approver from the previous level. The first + // level has none — a freshly-submitted report is routed by `from` (and amount) alone. const previous = i > 0 ? approvers.at(i - 1) : undefined; + const previousApproverComparison = previous ? buildPreviousApproverComparison(previous.email) : undefined; + + const hasLimitSplit = !!approver.approvalLimit && approver.approvalLimit > 0 && !!approver.overLimitForwardsTo; - // Initial-submission rule: degenerate single-comparison filter, no previousApprover gate. - if (!previous) { + if (hasLimitSplit) { + const limit = approver.approvalLimit as number; + const underAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit); + const overAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit); + + // The approver and their over-limit approver share the same previousApprover gate (or lack + // of one at the first level) and differ only by the mirrored amount comparison. rules.push({ - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: fromComparison}, + filters: buildLevelFilters(fromComparison, previousApproverComparison, underAmount), action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: current.email, + nextReceiver: approver.email, }); - continue; - } - - const previousApproverComparison = buildPreviousApproverComparison(previous.email); - const hasLimitSplit = !!previous.approvalLimit && previous.approvalLimit > 0 && !!previous.overLimitForwardsTo; - - if (!hasLimitSplit) { rules.push({ - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: fromComparison, right: previousApproverComparison}, + filters: buildLevelFilters(fromComparison, previousApproverComparison, overAmount), action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: current.email, + nextReceiver: approver.overLimitForwardsTo as string, }); continue; } - // Split: amount < limit continues to the next approver, amount >= limit jumps to overLimit. - const limit = previous.approvalLimit as number; - const overLimitTarget = previous.overLimitForwardsTo as string; - + // No limit at this level: a single rule routes the report to this approver. rules.push({ - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: fromComparison, - right: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: previousApproverComparison, - right: buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit), - }, - }, - action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: current.email, - }); - - rules.push({ - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: fromComparison, - right: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: previousApproverComparison, - right: buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit), - }, - }, + filters: buildLevelFilters(fromComparison, previousApproverComparison, undefined), action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: overLimitTarget, + nextReceiver: approver.email, }); } diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 6f0c09e6b669..ec6301bbcc98 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -7,7 +7,14 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import {getDefaultApprover} from '@libs/PolicyUtils'; import type {ApprovalWorkflowRulesDiff} from '@libs/WorkflowUtils'; -import {calculateApprovers, convertApprovalWorkflowToPolicyEmployees, getOverLimitForwardsToDisplayName, mergeWorkflowMembersWithAvailableMembers} from '@libs/WorkflowUtils'; +import { + buildApprovalWorkflowRules, + calculateApprovers, + convertApprovalWorkflowToPolicyEmployees, + getOverLimitForwardsToDisplayName, + mergeWorkflowMembersWithAvailableMembers, + reconcileApprovalWorkflowRulesForCreate, +} from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -343,6 +350,32 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousApprovalWorkflow API.write(WRITE_COMMANDS.SET_APPROVAL_WORKFLOW, parameters, {optimisticData, successData, failureData}); } +/** + * Create an approval workflow using the new rules-based backend (SetApprovalWorkflow), gated + * behind the `multipleApprovers` beta. Mirrors `createApprovalWorkflow`'s signature and its + * onboarding-task side-effect, but writes to `Policy.rules.approvalWorkflows` instead of + * `employeeList`. + */ +function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport}: CreateApprovalWorkflowParams) { + if (!policy) { + return; + } + + const newRules = buildApprovalWorkflowRules(approvalWorkflow); + const memberEmails = approvalWorkflow.members.map((member) => member.email); + const existingRules = policy.rules?.approvalWorkflows ?? {}; + const rulesDiff = reconcileApprovalWorkflowRulesForCreate(newRules, memberEmails, {existingRules}); + + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousApprovalWorkflows: existingRules}); + + if ( + addExpenseApprovalsTaskReport && + (addExpenseApprovalsTaskReport.stateNum !== CONST.REPORT.STATE_NUM.APPROVED || addExpenseApprovalsTaskReport.statusNum !== CONST.REPORT.STATUS_NUM.APPROVED) + ) { + completeTask(addExpenseApprovalsTaskReport, false, false, undefined, undefined, undefined, false); + } +} + /** Set the members of the approval workflow that is currently edited */ function setApprovalWorkflowMembers(members: Member[]) { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {members, errors: null}); @@ -504,6 +537,7 @@ function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): appro export { createApprovalWorkflow, + createApprovalWorkflowRules, updateApprovalWorkflow, removeApprovalWorkflow, setApprovalWorkflowMembers, diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx index 28f5ec4e179c..2c629f0ea94f 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx @@ -10,6 +10,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {isAnyHRReadOnlyWorkflowMode} from '@libs/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -19,7 +20,7 @@ import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; -import {createApprovalWorkflow as createApprovalWorkflowAction, validateApprovalWorkflow} from '@userActions/Workflow'; +import {createApprovalWorkflow as createApprovalWorkflowAction, createApprovalWorkflowRules, validateApprovalWorkflow} from '@userActions/Workflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -32,6 +33,7 @@ type WorkspaceWorkflowsApprovalsCreatePageProps = WithPolicyAndFullscreenLoading function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsCreatePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); const [approvalWorkflow] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const addExpenseApprovalsTaskReportID = introSelected?.addExpenseApprovals; @@ -50,9 +52,13 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr return; } - createApprovalWorkflowAction({approvalWorkflow, policy, addExpenseApprovalsTaskReport}); + if (isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS)) { + createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport}); + } else { + createApprovalWorkflowAction({approvalWorkflow, policy, addExpenseApprovalsTaskReport}); + } Navigation.dismissModal(); - }, [approvalWorkflow, policy, addExpenseApprovalsTaskReport]); + }, [approvalWorkflow, policy, addExpenseApprovalsTaskReport, isBetaEnabled]); const submitButtonContainerStyles = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: true, style: [styles.mb5, styles.mh5]}); diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index 63dba2016ef2..92b3ecc78c78 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -53,8 +53,12 @@ type ApprovalWorkflowFilter = { * When all `filters` match a report, the report is forwarded to `nextReceiver`. */ type ApprovalWorkflowRule = { - /** Conditions that must match the report for the rule to fire. */ - filters: ApprovalWorkflowFilter; + /** + * Conditions that must match the report for the rule to fire. Usually an `AND` filter, but a + * rule with a single condition (e.g. the initial-submission rule that only checks `from`) is + * stored as a bare comparison rather than a degenerate `AND` with no `right`. + */ + filters: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison; /** Currently the only supported action. Kept as a literal to leave room for future actions. */ action: 'forward'; From df701b64b228fe03f341dc6a6adc39f2b3ce9376 Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 26 Jun 2026 16:19:56 +0200 Subject: [PATCH 030/466] better edition handling --- src/libs/WorkflowUtils.ts | 75 ++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 6ba167b73ecb..8d7b703528f4 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -791,9 +791,13 @@ function buildLevelFilters( * - i == 0, no limit: `from ∈ M` → A0 * - i == 0, limit: `from ∈ M AND amount < limit` → A0 * `from ∈ M AND amount >= limit` → A0.overLimitForwardsTo - * - i > 0, no limit: `from ∈ M AND previousApprover == A_{i-1}` → A_i - * - i > 0, limit: `from ∈ M AND (previousApprover == A_{i-1} AND amount < limit)` → A_i - * `from ∈ M AND (previousApprover == A_{i-1} AND amount >= limit)` → A_i.overLimitForwardsTo + * - i > 0, no limit: `from ∈ M AND previousApprover == P` → A_i + * - i > 0, limit: `from ∈ M AND (previousApprover == P AND amount < limit)` → A_i + * `from ∈ M AND (previousApprover == P AND amount >= limit)` → A_i.overLimitForwardsTo + * + * Because the previous level's over-limit approver also approves and forwards onward, the report + * can reach level `i` from EITHER the previous level's base approver or its over-limit approver. So + * `P` ranges over every "exit" of the previous level, and we emit the level's rule(s) once per `P`. * * The last approver still gets an "into" rule (routed from the previous level); there's just no rule * forwarding back OUT of them, since nothing forwards past the terminal approver. @@ -809,45 +813,50 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva const fromComparison = buildFromComparison(memberEmails); const rules: ApprovalWorkflowRule[] = []; + // The approvers that could have approved at the PREVIOUS level and forwarded here. An empty list + // means "no previous approver" — the first level / a freshly-submitted report. + let previousApproverEmails: string[] = []; + for (let i = 0; i < approvers.length; i++) { const approver = approvers.at(i); if (!approver) { continue; } - // The level's `previousApprover` gate is the approver from the previous level. The first - // level has none — a freshly-submitted report is routed by `from` (and amount) alone. - const previous = i > 0 ? approvers.at(i - 1) : undefined; - const previousApproverComparison = previous ? buildPreviousApproverComparison(previous.email) : undefined; - const hasLimitSplit = !!approver.approvalLimit && approver.approvalLimit > 0 && !!approver.overLimitForwardsTo; - - if (hasLimitSplit) { - const limit = approver.approvalLimit as number; - const underAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit); - const overAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit); - - // The approver and their over-limit approver share the same previousApprover gate (or lack - // of one at the first level) and differ only by the mirrored amount comparison. - rules.push({ - filters: buildLevelFilters(fromComparison, previousApproverComparison, underAmount), - action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: approver.email, - }); - rules.push({ - filters: buildLevelFilters(fromComparison, previousApproverComparison, overAmount), - action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: approver.overLimitForwardsTo as string, - }); - continue; + const limit = hasLimitSplit ? (approver.approvalLimit as number) : undefined; + const underAmount = limit !== undefined ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit) : undefined; + const overAmount = limit !== undefined ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit) : undefined; + + // One gate per possible previous approver (or a single "no previousApprover" gate at level 0). + const gates = previousApproverEmails.length === 0 ? [undefined] : previousApproverEmails.map((email) => buildPreviousApproverComparison(email)); + + for (const gate of gates) { + if (hasLimitSplit) { + // The approver and their over-limit approver share this previousApprover gate (or its + // absence at level 0) and differ only by the mirrored amount comparison. + rules.push({ + filters: buildLevelFilters(fromComparison, gate, underAmount), + action: APPROVAL_WORKFLOW_FORWARD_ACTION, + nextReceiver: approver.email, + }); + rules.push({ + filters: buildLevelFilters(fromComparison, gate, overAmount), + action: APPROVAL_WORKFLOW_FORWARD_ACTION, + nextReceiver: approver.overLimitForwardsTo as string, + }); + } else { + rules.push({ + filters: buildLevelFilters(fromComparison, gate, undefined), + action: APPROVAL_WORKFLOW_FORWARD_ACTION, + nextReceiver: approver.email, + }); + } } - // No limit at this level: a single rule routes the report to this approver. - rules.push({ - filters: buildLevelFilters(fromComparison, previousApproverComparison, undefined), - action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: approver.email, - }); + // Exits from this level feed the next level's previousApprover gates: when this level splits, + // both the base approver and the over-limit approver can have approved and forwarded onward. + previousApproverEmails = hasLimitSplit ? [approver.email, approver.overLimitForwardsTo as string] : [approver.email]; } return rules; From f121f2c2200563bf7d15c74b2be0e1a544e95af5 Mon Sep 17 00:00:00 2001 From: alberto Date: Fri, 26 Jun 2026 17:46:07 +0200 Subject: [PATCH 031/466] better handling of rule generation --- src/ROUTES.ts | 5 +- src/libs/Navigation/types.ts | 2 + src/libs/WorkflowUtils.ts | 164 ++++++++++++------ src/libs/actions/Workflow.ts | 82 ++++++++- .../workflows/WorkspaceWorkflowsPage.tsx | 38 ++-- .../WorkspaceWorkflowsApprovalsEditPage.tsx | 47 ++++- ...paceWorkflowsApprovalsExpensesFromPage.tsx | 35 +++- 7 files changed, 290 insertions(+), 83 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ad3fcc28e61b..84fd9f9f324e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2341,7 +2341,10 @@ const ROUTES = { }, WORKSPACE_WORKFLOWS_APPROVALS_EDIT: { route: 'workspaces/:policyID/workflows/approvals/:firstApproverEmail/edit', - getRoute: (policyID: string, firstApproverEmail: string) => `workspaces/${policyID}/workflows/approvals/${encodeURIComponent(firstApproverEmail)}/edit` as const, + // `memberEmail` disambiguates workflows that share a first approver (possible once rule-based + // chains diverge) — a member belongs to exactly one workflow, so it uniquely identifies it. + getRoute: (policyID: string, firstApproverEmail: string, memberEmail?: string) => + `workspaces/${policyID}/workflows/approvals/${encodeURIComponent(firstApproverEmail)}/edit${memberEmail ? `?memberEmail=${encodeURIComponent(memberEmail)}` : ''}` as const, }, WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM: { route: 'workspaces/:policyID/workflows/approvals/expenses-from', diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e4a2393095c3..5c2da6224543 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2787,6 +2787,8 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EDIT]: { policyID: string; firstApproverEmail: string; + /** Disambiguates workflows that share a first approver (rule-based diverging chains). A member belongs to exactly one workflow. */ + memberEmail?: string; }; [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EXPENSES_FROM]: { policyID: string; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 8d7b703528f4..db6c30e66fca 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1151,6 +1151,23 @@ function reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails: st return diff; } +/** + * Apply an `ApprovalWorkflowRulesDiff` to a rule map, returning a new map. A `null` value removes + * the rule; any other value upserts it. Lets us chain reconcilers — e.g. apply a membership change, + * then reconcile the approver chain against the result. + */ +function applyApprovalWorkflowRulesDiff(existingRules: Record, diff: ApprovalWorkflowRulesDiff): Record { + const result: Record = {...existingRules}; + for (const [ruleID, value] of Object.entries(diff)) { + if (value === null) { + delete result[ruleID]; + } else { + result[ruleID] = value; + } + } + return result; +} + // --------------------------------------------------------------------------- // Reconstruct ApprovalWorkflow[] from `policy.rules.approvalWorkflows` // --------------------------------------------------------------------------- @@ -1191,37 +1208,30 @@ function findComparisonByLeft(node: ApprovalWorkflowFilter | ApprovalWorkflowFil return findComparisonByLeft(right, leftKey); } -/** Initial-submission rule for a submitter — `from` includes them and no `previousApprover` filter exists. */ -function findInitialSubmissionRule(submitter: string, rules: Record): ApprovalWorkflowRule | undefined { - for (const rule of Object.values(rules)) { - if (!extractFromEmails(rule).includes(submitter)) { - continue; - } - if (!findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER)) { - return rule; - } - } - return undefined; -} - -/** Resolved hop info — where the report goes after the current approver acts, and any limit split that controlled it. */ -type ApprovalWorkflowHopInfo = { - /** Next approver email (under-limit path, or the only path when no split). */ - forwardsTo: string; - /** Amount threshold from the under/over-limit split, if any. */ +/** The approver a level routes the report INTO, plus that approver's own limit split (if any). */ +type ApprovalWorkflowLevelInfo = { + /** The approver the level routes to (under-limit / only path). */ + email: string; + /** This approver's own approval limit, from the level's amount split. */ approvalLimit?: number; - /** Over-limit forward target, if the hop is split. */ + /** This approver's own over-limit target, from the level's amount split. */ overLimitForwardsTo?: string; }; /** - * Look for the rules describing what happens after `currentApproverEmail` approves a report - * submitted by `submitter`. Returns: - * - {forwardsTo, approvalLimit, overLimitForwardsTo} when both halves of a limit split exist - * - {forwardsTo} when there's a single normal rule (or only one half of a partial/broken split) - * - undefined when no matching rule exists, signaling the caller to fall back to employeeList + * Resolve the level routed into by the rules that gate on `previousApproverEmail` (or the rules with + * NO `previousApprover` filter when it's `undefined` — the initial-submission level). Mirrors a single + * "into a level" step of `buildApprovalWorkflowRules`: + * - both halves of a split present → {email: under target, approvalLimit, overLimitForwardsTo: over target} + * - a single non-amount rule → {email: target} + * - only one half of a split → {email: that target} (best-effort recovery from a broken pair) + * - no matching rule → undefined, so the caller can fall back to employeeList + * + * The level's amount split describes the *target* approver's own limit/over (the approver being routed + * into), not the approver who forwarded here — that's the inverse of how `buildApprovalWorkflowRules` + * emits them. */ -function extractHopFromRules(submitter: string, currentApproverEmail: string, rules: Record): ApprovalWorkflowHopInfo | undefined { +function resolveLevelFromRules(submitter: string, previousApproverEmail: string | undefined, rules: Record): ApprovalWorkflowLevelInfo | undefined { let underLimitRule: ApprovalWorkflowRule | undefined; let overLimitRule: ApprovalWorkflowRule | undefined; let normalRule: ApprovalWorkflowRule | undefined; @@ -1232,7 +1242,12 @@ function extractHopFromRules(submitter: string, currentApproverEmail: string, ru continue; } const previousApproverLeaf = findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER); - if (!previousApproverLeaf || previousApproverLeaf.right !== currentApproverEmail) { + if (previousApproverEmail === undefined) { + // Initial-submission level: only rules with no previousApprover gate. + if (previousApproverLeaf) { + continue; + } + } else if (!previousApproverLeaf || previousApproverLeaf.right !== previousApproverEmail) { continue; } @@ -1254,19 +1269,18 @@ function extractHopFromRules(submitter: string, currentApproverEmail: string, ru if (underLimitRule && overLimitRule) { return { - forwardsTo: underLimitRule.nextReceiver, + email: underLimitRule.nextReceiver, approvalLimit, overLimitForwardsTo: overLimitRule.nextReceiver, }; } if (normalRule) { - return {forwardsTo: normalRule.nextReceiver}; + return {email: normalRule.nextReceiver}; } - // Partial split (one half missing): use whichever rule we found as the next hop. This is a - // best-effort recovery from a corrupted rule pair rather than an expected state. + // Partial split (one half missing): use whichever rule we found. Best-effort recovery. const partial = underLimitRule ?? overLimitRule; if (partial) { - return {forwardsTo: partial.nextReceiver}; + return {email: partial.nextReceiver}; } return undefined; } @@ -1279,19 +1293,23 @@ type BuildApproverChainFromRulesParams = { }; /** - * Walk a submitter's approval chain by following rules from their initial-submission rule - * forward. Each hop's `forwardsTo` / `approvalLimit` / `overLimitForwardsTo` is taken from - * the rule when one exists, and falls back to the current approver's `employeeList` entry - * when the rule chain runs out. Stops at circular references (matching `calculateApprovers`). + * Walk a submitter's approval chain forward from their initial-submission level. At each step we + * resolve the level the report is routed into to learn the next approver's email AND that + * approver's own `approvalLimit` / `overLimitForwardsTo`; the current approver's `forwardsTo` is the + * next approver's email. Each piece falls back to the current/next approver's `employeeList` entry + * when no rule covers that hop, so pre-beta (legacy) workflows still resolve. Stops at circular + * references (matching `calculateApprovers`). */ function buildApproverChainFromRules({submitter, rules, employees, personalDetailsByEmail}: BuildApproverChainFromRulesParams): Approver[] { - // First approver: prefer the initial-submission rule, fall back to employeeList.submitsTo for - // pre-beta workflows that haven't been re-saved yet. - const initialRule = findInitialSubmissionRule(submitter, rules); - let currentEmail: string | undefined = initialRule?.nextReceiver ?? employees[submitter]?.submitsTo; + // The first approver, and its own limit/over, come from the initial (no-previousApprover) level. + // Fall back to employeeList.submitsTo for pre-beta workflows that haven't been re-saved yet. + const initialLevel = resolveLevelFromRules(submitter, undefined, rules); + let currentEmail: string | undefined = initialLevel?.email ?? employees[submitter]?.submitsTo; if (!currentEmail) { return []; } + let currentApprovalLimit: number | null = initialLevel?.approvalLimit ?? employees[currentEmail]?.approvalLimit ?? null; + let currentOverLimitForwardsTo: string | undefined = initialLevel?.overLimitForwardsTo ?? employees[currentEmail]?.overLimitForwardsTo; const chain: Approver[] = []; const seenEmails = new Set(); @@ -1300,10 +1318,11 @@ function buildApproverChainFromRules({submitter, rules, employees, personalDetai const isCircularReference = seenEmails.has(currentEmail); const employee: PolicyEmployee | undefined = employees[currentEmail]; - const hopInfo = extractHopFromRules(submitter, currentEmail, rules); - const approvalLimit: number | null = hopInfo?.approvalLimit ?? employee?.approvalLimit ?? null; - const overLimitForwardsTo: string | undefined = hopInfo?.overLimitForwardsTo ?? employee?.overLimitForwardsTo; - const forwardsTo: string | undefined = hopInfo?.forwardsTo ?? employee?.forwardsTo; + // The rules gated on `previousApprover == currentEmail` describe the NEXT approver: their + // email is where the current approver forwards, and the level also carries the NEXT + // approver's own limit/over. + const nextLevel = resolveLevelFromRules(submitter, currentEmail, rules); + const forwardsTo: string | undefined = nextLevel?.email ?? employee?.forwardsTo; chain.push({ email: currentEmail, @@ -1311,18 +1330,23 @@ function buildApproverChainFromRules({submitter, rules, employees, personalDetai avatar: personalDetailsByEmail[currentEmail]?.avatar, displayName: personalDetailsByEmail[currentEmail]?.displayName ?? currentEmail, isCircularReference, - approvalLimit, - overLimitForwardsTo, - overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(overLimitForwardsTo, personalDetailsByEmail), + approvalLimit: currentApprovalLimit, + overLimitForwardsTo: currentOverLimitForwardsTo, + overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(currentOverLimitForwardsTo, personalDetailsByEmail), pendingAction: employee?.pendingAction, errors: employee?.errors, }); - if (isCircularReference) { + if (isCircularReference || !forwardsTo) { break; } seenEmails.add(currentEmail); + + // Advance: the next approver's own limit/over come from the level that routed into them + // (the `nextLevel` we just resolved), falling back to their employeeList entry. currentEmail = forwardsTo; + currentApprovalLimit = nextLevel?.approvalLimit ?? employees[currentEmail]?.approvalLimit ?? null; + currentOverLimitForwardsTo = nextLevel?.overLimitForwardsTo ?? employees[currentEmail]?.overLimitForwardsTo; } return chain; @@ -1340,14 +1364,40 @@ function approverChainFingerprint(chain: Approver[]): string { ); } +/** The sorted set of ruleIDs whose `from` filter includes this submitter — their exact rule membership. */ +function getSubmitterRuleIDs(submitter: string, rules: Record): string[] { + return Object.entries(rules) + .filter(([, rule]) => extractFromEmails(rule).includes(submitter)) + .map(([ruleID]) => ruleID) + .sort(); +} + /** True when at least one rule mentions this submitter in any `from` filter. */ function isSubmitterCoveredByRules(submitter: string, rules: Record): boolean { + return getSubmitterRuleIDs(submitter, rules).length > 0; +} + +/** + * Map every submitter found in the rules to their workflow's first approver (the `nextReceiver` of + * their initial, no-`previousApprover` rule). Used to warn when a member is about to be moved out of + * an existing workflow into another one. + */ +function getRulesSubmitterToFirstApprover(rules: Record): Record { + const submitters = new Set(); for (const rule of Object.values(rules)) { - if (extractFromEmails(rule).includes(submitter)) { - return true; + for (const email of extractFromEmails(rule)) { + submitters.add(email); } } - return false; + + const result: Record = {}; + for (const submitter of submitters) { + const initialLevel = resolveLevelFromRules(submitter, undefined, rules); + if (initialLevel?.email) { + result[submitter] = initialLevel.email; + } + } + return result; } /** @@ -1396,8 +1446,8 @@ function convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, first availableMembers.push(member); } - const initialRule = findInitialSubmissionRule(email, rules); - if (!initialRule && (!submitsTo || (!employees[submitsTo] && !hrAdvancedModeFinalApproverEmail))) { + const hasInitialRule = !!resolveLevelFromRules(email, undefined, rules); + if (!hasInitialRule && (!submitsTo || (!employees[submitsTo] && !hrAdvancedModeFinalApproverEmail))) { // Mirrors the existing legacy filter: skip submitters whose first approver isn't reachable. continue; } @@ -1434,8 +1484,12 @@ function convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, first } } - const sourceTag = isSubmitterCoveredByRules(email, rules) ? 'r' : 'l'; - const fingerprint = `${sourceTag}|${approverChainFingerprint(chain)}`; + // Group submitters that share the same rules into one workflow. A submitter's "rules" is the + // exact set of ruleIDs whose `from` includes them, so two submitters that belong to the same + // rules land in the same workflow (and submitters whose chains diverge — different rule sets — + // stay separate). Legacy submitters (no rules) still group by their reconstructed chain. + const submitterRuleIDs = getSubmitterRuleIDs(email, rules); + const fingerprint = submitterRuleIDs.length > 0 ? `r|${submitterRuleIDs.join(',')}` : `l|${approverChainFingerprint(chain)}`; const existingGroup = groupedByFingerprint.get(fingerprint); if (existingGroup) { @@ -1489,12 +1543,14 @@ function convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, first } export { + applyApprovalWorkflowRulesDiff, buildApprovalWorkflowRules, calculateApprovers, convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, getApprovalLimitDescription, + getRulesSubmitterToFirstApprover, getEligibleExistingBusinessBankAccounts, getOpenConnectedToPolicyBusinessBankAccounts, getOverLimitForwardsToDisplayName, diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index ec6301bbcc98..feb7e6c95fa3 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -8,12 +8,16 @@ import * as ErrorUtils from '@libs/ErrorUtils'; import {getDefaultApprover} from '@libs/PolicyUtils'; import type {ApprovalWorkflowRulesDiff} from '@libs/WorkflowUtils'; import { + applyApprovalWorkflowRulesDiff, buildApprovalWorkflowRules, calculateApprovers, convertApprovalWorkflowToPolicyEmployees, getOverLimitForwardsToDisplayName, mergeWorkflowMembersWithAvailableMembers, reconcileApprovalWorkflowRulesForCreate, + reconcileApprovalWorkflowRulesForEdit, + reconcileApprovalWorkflowRulesForMembersChange, + reconcileApprovalWorkflowRulesForRemove, } from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -361,10 +365,18 @@ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprov return; } - const newRules = buildApprovalWorkflowRules(approvalWorkflow); - const memberEmails = approvalWorkflow.members.map((member) => member.email); const existingRules = policy.rules?.approvalWorkflows ?? {}; - const rulesDiff = reconcileApprovalWorkflowRulesForCreate(newRules, memberEmails, {existingRules}); + const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); + + // A submitter can only belong to one workflow, so first drop these members from any OTHER + // workflow's rules, then add them to the new workflow. + const removeDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); + const rulesAfterRemoval = applyApprovalWorkflowRulesDiff(existingRules, removeDiff); + + const newRules = buildApprovalWorkflowRules(approvalWorkflow); + const createDiff = reconcileApprovalWorkflowRulesForCreate(newRules, memberEmails, {existingRules: rulesAfterRemoval}); + + const rulesDiff = {...removeDiff, ...createDiff}; setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousApprovalWorkflows: existingRules}); @@ -376,6 +388,68 @@ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprov } } +type UpdateApprovalWorkflowRulesParams = { + approvalWorkflow: ApprovalWorkflow; + initialApprovalWorkflow: ApprovalWorkflow; + policy: OnyxEntry; +}; + +/** + * Update an existing workflow using the new rules-based backend, gated behind the `multipleApprovers` + * beta. A single edit can change both the membership and the approver chain, so we reconcile in two + * composable steps against the existing `rules.approvalWorkflows`: + * 1. Membership: add joiners / drop leavers across the workflow's rules (so an added member lands in + * the same rules and stays in the same workflow rather than splitting off). + * 2. Chain: reconcile the (possibly changed) approver chain against the membership-updated rules. + * The two diffs are merged (the chain diff, computed against the membership-updated rules, wins on + * overlap) and sent as one SetApprovalWorkflow command. + */ +function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy}: UpdateApprovalWorkflowRulesParams) { + if (!policy) { + return; + } + + const existingRules = policy.rules?.approvalWorkflows ?? {}; + const previousMemberEmails = initialApprovalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); + const newMemberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); + + // 1. A submitter can only belong to one workflow, so drop joining members (those not previously in + // this workflow) from any OTHER workflow's rules first. Joiners aren't in this workflow's rules + // yet, so this only touches other workflows. + const joiningMemberEmails = newMemberEmails.filter((email) => !previousMemberEmails.includes(email)); + const removeFromOthersDiff = reconcileApprovalWorkflowRulesForRemove(joiningMemberEmails, {existingRules}); + const rulesAfterRemoval = applyApprovalWorkflowRulesDiff(existingRules, removeFromOthersDiff); + + // 2. Sync membership across this workflow's rules (add joiners, drop leavers). + const memberDiff = reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails, newMemberEmails, {existingRules: rulesAfterRemoval}); + const rulesAfterMembers = applyApprovalWorkflowRulesDiff(rulesAfterRemoval, memberDiff); + + // 3. Reconcile the approver chain against the membership-updated rules. + const newRules = buildApprovalWorkflowRules(approvalWorkflow); + const chainDiff = reconcileApprovalWorkflowRulesForEdit(newRules, newMemberEmails, {existingRules: rulesAfterMembers}); + + const rulesDiff = {...removeFromOthersDiff, ...memberDiff, ...chainDiff}; + + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousApprovalWorkflows: existingRules}); +} + +/** + * Delete a workflow using the new rules-based backend, gated behind the `multipleApprovers` beta. + * Strips the workflow's members from every rule's `from` filter and removes any rule that ends up + * with no submitters, via the SetApprovalWorkflow command. + */ +function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry) { + if (!policy) { + return; + } + + const existingRules = policy.rules?.approvalWorkflows ?? {}; + const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); + const rulesDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); + + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousApprovalWorkflows: existingRules}); +} + /** Set the members of the approval workflow that is currently edited */ function setApprovalWorkflowMembers(members: Member[]) { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {members, errors: null}); @@ -538,7 +612,9 @@ function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): appro export { createApprovalWorkflow, createApprovalWorkflowRules, + removeApprovalWorkflowRules, updateApprovalWorkflow, + updateApprovalWorkflowRules, removeApprovalWorkflow, setApprovalWorkflowMembers, setApprovalWorkflowApprover, diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index ec3947ea78bb..86652b5af967 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -64,7 +64,7 @@ import { } from '@libs/PolicyUtils'; import {hasInProgressVBBA} from '@libs/ReimbursementAccountUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; -import {convertPolicyEmployeesToApprovalWorkflows, getEligibleExistingBusinessBankAccounts, INITIAL_APPROVAL_WORKFLOW} from '@libs/WorkflowUtils'; +import {convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, getEligibleExistingBusinessBankAccounts, INITIAL_APPROVAL_WORKFLOW} from '@libs/WorkflowUtils'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import ExpenseReportRulesSection from '@pages/workspace/rules/ExpenseReportRulesSection'; @@ -171,16 +171,16 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const delegateAccountID = useDelegateAccountID(); const {accountID: currentUserAccountID, email: currentUserEmail = '', login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const isUserReimburser = policy?.achAccount?.reimburser !== undefined && account?.primaryLogin !== undefined && policy?.achAccount?.reimburser === account?.primaryLogin; - const {approvalWorkflows, availableMembers, usedApproverEmails} = useMemo( - () => - convertPolicyEmployeesToApprovalWorkflows({ - policy, - personalDetails: personalDetails ?? {}, - localeCompare, - currentUserLogin, - }), - [policy, personalDetails, localeCompare, currentUserLogin], - ); + const isMultipleApproversBetaEnabled = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS); + const {approvalWorkflows, availableMembers, usedApproverEmails} = useMemo(() => { + const params = { + policy, + personalDetails: personalDetails ?? {}, + localeCompare, + currentUserLogin, + }; + return isMultipleApproversBetaEnabled ? convertApprovalWorkflowRulesToWorkflows(params) : convertPolicyEmployeesToApprovalWorkflows(params); + }, [policy, personalDetails, localeCompare, currentUserLogin, isMultipleApproversBetaEnabled]); const canAccessSubmit2026Features = canAccessSubmitWorkspaceFeatures(policy, isSubmit2026BetaEnabled); const hasValidExistingAccounts = getEligibleExistingBusinessBankAccounts(bankAccountList, policy?.outputCurrency, true).length > 0; @@ -294,8 +294,15 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const isHRAdvancedModeEnabled = isHRAdvancedMode(policy); const hrFinalApproverEmail = getHRFinalApprover(policy) ?? undefined; + // With the multipleApprovers beta on, `approvalWorkflows` is reconstructed from + // `rules.approvalWorkflows` and already contains exactly the workflows that exist, so we don't + // collapse to the default workflow based on `approvalMode` (which the rules-create path doesn't + // bump to ADVANCED). const filteredApprovalWorkflows = - policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.ADVANCED || policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL || isHRAdvancedModeEnabled + isMultipleApproversBetaEnabled || + policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.ADVANCED || + policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL || + isHRAdvancedModeEnabled ? approvalWorkflows : approvalWorkflows.filter((workflow) => workflow.isDefault); @@ -524,10 +531,13 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { /> {displayedWorkflows.map((workflow) => { const firstApproverEmail = workflow.approvers.at(0)?.email ?? ''; + // A workflow's first approver isn't unique once rule-based chains diverge, but a member + // belongs to exactly one workflow — use it to disambiguate both the React key and the edit route. + const firstMemberEmail = workflow.members.at(0)?.email ?? ''; return ( { // Discard stale onyx edits or the Edit page's resume check would surface a prior abandoned session. clearApprovalWorkflow(); - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApproverEmail)); + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApproverEmail, firstMemberEmail)); } } onShowAllMembersPress={ diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index a5e96772d802..9cf736b86503 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -12,17 +12,26 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {isAnyHRReadOnlyWorkflowMode} from '@libs/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {canEditWorkspaceSettings, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; -import {convertPolicyEmployeesToApprovalWorkflows} from '@libs/WorkflowUtils'; +import {convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; -import {clearApprovalWorkflow, removeApprovalWorkflow, selectApprovalWorkflowForEdit, updateApprovalWorkflow, validateApprovalWorkflow} from '@userActions/Workflow'; +import { + clearApprovalWorkflow, + removeApprovalWorkflow, + removeApprovalWorkflowRules, + selectApprovalWorkflowForEdit, + updateApprovalWorkflow, + updateApprovalWorkflowRules, + validateApprovalWorkflow, +} from '@userActions/Workflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -41,6 +50,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const [approvalWorkflow, approvalWorkflowMetadata] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); const isLoadingApprovalWorkflow = isLoadingOnyxValue(approvalWorkflowMetadata); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); const [initialApprovalWorkflow, setInitialApprovalWorkflow] = useState(); const formRef = useRef(null); const {showConfirmModal} = useConfirmModal(); @@ -55,6 +65,15 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true return; } + if (isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS)) { + Navigation.dismissModal({ + afterTransition: () => { + updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy}); + }, + }); + return; + } + // We need to remove members and approvers that are no longer in the updated workflow const membersToRemove = initialApprovalWorkflow.members.filter((initialMember) => !approvalWorkflow.members.some((member) => member.email === initialMember.email)); const approversToRemove = initialApprovalWorkflow.approvers.filter((initialApprover) => !approvalWorkflow.approvers.some((approver) => approver.email === initialApprover.email)); @@ -72,10 +91,15 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true // Mark as deleting to prevent the useEffect from clearing the workflow and causing a blink isDeleting.current = true; + const useRulesBackend = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS); Navigation.dismissModal({ afterTransition: () => { // Remove the approval workflow using the initial data as it could be already edited - removeApprovalWorkflow(initialApprovalWorkflow, policy); + if (useRulesBackend) { + removeApprovalWorkflowRules(initialApprovalWorkflow, policy); + } else { + removeApprovalWorkflow(initialApprovalWorkflow, policy); + } }, }); }; @@ -86,18 +110,29 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true } const firstApprover = route.params.firstApproverEmail; - const result = convertPolicyEmployeesToApprovalWorkflows({ + const conversionParams = { policy, personalDetails, firstApprover, localeCompare, currentUserLogin: currentUserPersonalDetails?.login, - }); + }; + const result = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS) + ? convertApprovalWorkflowRulesToWorkflows(conversionParams) + : convertPolicyEmployeesToApprovalWorkflows(conversionParams); + + // `firstApproverEmail` isn't unique once rule-based chains diverge, so prefer matching on the + // member that opened this workflow (a member belongs to exactly one workflow). Fall back to the + // first-approver match for the default workflow (no members) and legacy/unspecified cases. + const memberEmail = route.params.memberEmail; + const currentApprovalWorkflow = + (memberEmail ? result.approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === memberEmail)) : undefined) ?? + result.approvalWorkflows.find((workflow) => workflow.approvers.at(0)?.email === firstApprover); return { defaultWorkflowMembers: result.availableMembers, usedApproverEmails: result.usedApproverEmails, - currentApprovalWorkflow: result.approvalWorkflows.find((workflow) => workflow.approvers.at(0)?.email === firstApprover), + currentApprovalWorkflow, }; }; diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index abead1019d05..1f501223f031 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -9,6 +9,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearInviteDraft, setWorkspaceInviteMembersDraft} from '@libs/actions/Policy/Member'; @@ -22,6 +23,7 @@ import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {canEditWorkspaceSettings, getDefaultApprover, getExcludedUsers, getMemberAccountIDsForWorkspace, isPendingDeletePolicy} from '@libs/PolicyUtils'; +import {getRulesSubmitterToFirstApprover} from '@libs/WorkflowUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; @@ -55,6 +57,8 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID}`); const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const {showConfirmModal} = useConfirmModal(); + const {isBetaEnabled} = usePermissions(); + const isMultipleApproversBetaEnabled = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS); const personalDetailLogins = useMemo(() => Object.fromEntries(Object.entries(personalDetails ?? {}).map(([id, details]) => [id, details?.login])), [personalDetails]); @@ -95,8 +99,14 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const isCreateAction = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE; const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); - // Build a map of member emails to their existing workflow's approver email (for non-default workflows only) + // Build a map of member emails to their existing workflow's first approver. With the beta on this + // is derived from `rules.approvalWorkflows`; otherwise it falls back to the legacy employeeList + // `submitsTo` (non-default workflows only). const membersInExistingWorkflows = (() => { + if (isMultipleApproversBetaEnabled) { + return new Map(Object.entries(getRulesSubmitterToFirstApprover(policy?.rules?.approvalWorkflows ?? {}))); + } + const employees = policy?.employeeList ?? {}; const defaultApprover = getDefaultApprover(policy); const map = new Map(); @@ -469,12 +479,16 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat setSelectedMembers(nextMembers); }; - // Only show warning when creating a new workflow and a member is being added (not removed) - if (isCreateAction && members.length > selectedMembers.length) { + // Warn when adding a member who already belongs to another workflow. With the beta on this + // applies to both create and edit (a submitter can only be in one workflow, so confirming + // moves them); legacy keeps the create-only behavior. The `!== firstApprover` check skips + // members who are already in the workflow being edited. + const shouldCheckCrossWorkflow = isCreateAction || isMultipleApproversBetaEnabled; + if (shouldCheckCrossWorkflow && members.length > selectedMembers.length) { const newMember = members.find((m) => !selectedMembers.some((s) => s.login === m.login)); const existingApproverEmail = newMember?.login ? membersInExistingWorkflows.get(newMember.login) : undefined; - if (newMember && existingApproverEmail) { + if (newMember && existingApproverEmail && existingApproverEmail !== firstApprover) { const memberName = Str.removeSMSDomain(newMember.text ?? newMember.login ?? ''); const approverDetails = getPersonalDetailByEmail(existingApproverEmail); const approverName = Str.removeSMSDomain(approverDetails?.displayName ?? existingApproverEmail); @@ -496,7 +510,18 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat applySelection(members); }, - [policy?.employeeList, invitedEmailsToAccountIDsDraft, route.params.policyID, isCreateAction, selectedMembers, membersInExistingWorkflows, showConfirmModal, translate], + [ + policy?.employeeList, + invitedEmailsToAccountIDsDraft, + route.params.policyID, + isCreateAction, + isMultipleApproversBetaEnabled, + firstApprover, + selectedMembers, + membersInExistingWorkflows, + showConfirmModal, + translate, + ], ); return ( From 9602c8638b703c655d08a3c6eca11ab223ea501b Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 13:18:19 +0200 Subject: [PATCH 032/466] some cleanup --- src/libs/actions/Workflow.ts | 36 +++++++++--------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index feb7e6c95fa3..747b8680d563 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -281,8 +281,7 @@ type SetApprovalWorkflowRulesParams = { /** * Diff to apply to `Policy.rules.approvalWorkflows`. A value of `ApprovalWorkflowRule` - * upserts the rule under its `ruleID`; a value of `null` removes it. Produced by the - * `reconcileApprovalWorkflowRules*` helpers in `WorkflowUtils`. + * sets/replaces the rule under its `ruleID`; a value of `null` removes it */ rulesDiff: ApprovalWorkflowRulesDiff; @@ -291,11 +290,9 @@ type SetApprovalWorkflowRulesParams = { }; /** - * Apply a set of approval-workflow rule changes to a policy via the new SetApprovalWorkflow - * Auth command. Mirrors the optimistic/success/failure dance of `createApprovalWorkflow`, - * but operates on `rules.approvalWorkflows` instead of `employeeList`. + * Apply a set of approval-workflow rule changes to a policy via the SetApprovalWorkflow + * Auth command. * - * No-op when `rulesDiff` is empty — there's nothing to send to the backend. */ function setApprovalWorkflowRules({policyID, rulesDiff, previousApprovalWorkflows}: SetApprovalWorkflowRulesParams) { if (!policyID || isEmptyObject(rulesDiff)) { @@ -304,9 +301,7 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousApprovalWorkflow const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; - // Build the rollback shape: for every rule we're about to touch, either restore the - // previous value (if one existed) or clear the optimistic upsert with `null`. Without - // the explicit `null`s, newly-added rules would survive the failure rollback. + // Build the rollback shape const failureApprovalWorkflows: Record = {}; for (const ruleID of Object.keys(rulesDiff)) { failureApprovalWorkflows[ruleID] = previousApprovalWorkflows[ruleID] ?? null; @@ -355,10 +350,7 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousApprovalWorkflow } /** - * Create an approval workflow using the new rules-based backend (SetApprovalWorkflow), gated - * behind the `multipleApprovers` beta. Mirrors `createApprovalWorkflow`'s signature and its - * onboarding-task side-effect, but writes to `Policy.rules.approvalWorkflows` instead of - * `employeeList`. + * Create an approval workflow using the rules-based backend structure, */ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport}: CreateApprovalWorkflowParams) { if (!policy) { @@ -395,14 +387,7 @@ type UpdateApprovalWorkflowRulesParams = { }; /** - * Update an existing workflow using the new rules-based backend, gated behind the `multipleApprovers` - * beta. A single edit can change both the membership and the approver chain, so we reconcile in two - * composable steps against the existing `rules.approvalWorkflows`: - * 1. Membership: add joiners / drop leavers across the workflow's rules (so an added member lands in - * the same rules and stays in the same workflow rather than splitting off). - * 2. Chain: reconcile the (possibly changed) approver chain against the membership-updated rules. - * The two diffs are merged (the chain diff, computed against the membership-updated rules, wins on - * overlap) and sent as one SetApprovalWorkflow command. + * Update an existing workflow using the rules-based backend structure */ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy}: UpdateApprovalWorkflowRulesParams) { if (!policy) { @@ -414,8 +399,7 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, const newMemberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); // 1. A submitter can only belong to one workflow, so drop joining members (those not previously in - // this workflow) from any OTHER workflow's rules first. Joiners aren't in this workflow's rules - // yet, so this only touches other workflows. + // this workflow) from any OTHER workflow's rules first. const joiningMemberEmails = newMemberEmails.filter((email) => !previousMemberEmails.includes(email)); const removeFromOthersDiff = reconcileApprovalWorkflowRulesForRemove(joiningMemberEmails, {existingRules}); const rulesAfterRemoval = applyApprovalWorkflowRulesDiff(existingRules, removeFromOthersDiff); @@ -424,7 +408,7 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, const memberDiff = reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails, newMemberEmails, {existingRules: rulesAfterRemoval}); const rulesAfterMembers = applyApprovalWorkflowRulesDiff(rulesAfterRemoval, memberDiff); - // 3. Reconcile the approver chain against the membership-updated rules. + // 3. Reconcile the approver chain with the membership-updated rules. const newRules = buildApprovalWorkflowRules(approvalWorkflow); const chainDiff = reconcileApprovalWorkflowRulesForEdit(newRules, newMemberEmails, {existingRules: rulesAfterMembers}); @@ -434,9 +418,7 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, } /** - * Delete a workflow using the new rules-based backend, gated behind the `multipleApprovers` beta. - * Strips the workflow's members from every rule's `from` filter and removes any rule that ends up - * with no submitters, via the SetApprovalWorkflow command. + * Delete an approval workflow using the rules-based backend structure, gated behind the `multipleApprovers` beta. */ function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry) { if (!policy) { From 0af1aa7b8ef97f5f92392a529b775f210e2dd900 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 13:54:29 +0200 Subject: [PATCH 033/466] rename helper functions --- .../API/parameters/ApprovalWorkflowParams.ts | 4 +- src/libs/Navigation/types.ts | 1 - src/libs/WorkflowUtils.ts | 78 +++++-------------- 3 files changed, 20 insertions(+), 63 deletions(-) diff --git a/src/libs/API/parameters/ApprovalWorkflowParams.ts b/src/libs/API/parameters/ApprovalWorkflowParams.ts index 4eca1d7e3ec7..bab03b2bf134 100644 --- a/src/libs/API/parameters/ApprovalWorkflowParams.ts +++ b/src/libs/API/parameters/ApprovalWorkflowParams.ts @@ -3,9 +3,7 @@ type SetApprovalWorkflowParams = { policyID: string; /** - * JSON-stringified object keyed by ruleID, mapping to either: - * - a full `ApprovalWorkflowRule` to add or replace, or - * - `null` / `{}` to remove the rule from the policy. + * JSON-stringified object keyed by ruleID */ rules: string; }; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index fb52e8f24631..d813fb74e0b0 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2791,7 +2791,6 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EDIT]: { policyID: string; firstApproverEmail: string; - /** Disambiguates workflows that share a first approver (rule-based diverging chains). A member belongs to exactly one workflow. */ memberEmail?: string; }; [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EXPENSES_FROM]: { diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index db6c30e66fca..e6801d144cd3 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -718,24 +718,14 @@ function mergeWorkflowMembersWithAvailableMembers(workflowMembers: Member[], all return [...workflowMembers, ...additionalMembers]; } -// --------------------------------------------------------------------------- -// Approval-workflow rules (rules.approvalWorkflows on the policy) -// --------------------------------------------------------------------------- -// // These helpers translate the in-app `ApprovalWorkflow` model into the rule // format the backend stores under `Policy.rules.approvalWorkflows`, and -// reconcile a workflow change against the existing rules so we can hand a -// minimal diff to `Auth/SetApprovalWorkflow`. -// -// Diff convention: a value of `ApprovalWorkflowRule` upserts the rule; a value -// of `null` removes it. JSON-stringified, that matches what Auth expects. - +// handle any changes const APPROVAL_WORKFLOW_FORWARD_ACTION = 'forward' as const; -/** Diff shape passed to the backend: ruleID -> rule (upsert) or null (remove). */ type ApprovalWorkflowRulesDiff = Record; -/** Build a leaf comparison: ` `. */ +/** Build a filter: ` `. */ function buildComparison( operator: ValueOf, left: ApprovalWorkflowFilterComparison['left'], @@ -744,20 +734,19 @@ function buildComparison( return {operator, left, right}; } -/** Build the `from in [emails]` leaf used by every approval-workflow rule. */ -function buildFromComparison(memberEmails: string[]): ApprovalWorkflowFilterComparison { +/** Build the `from in [emails]` filter used by every approval-workflow rule. */ +function buildSubmitterFilter(memberEmails: string[]): ApprovalWorkflowFilterComparison { return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, [...memberEmails]); } -/** Build the `previousApprover = email` leaf used by all non-initial-hop rules. */ +/** Build the `previousApprover = email` filter used by all non-initial rules. */ function buildPreviousApproverComparison(email: string): ApprovalWorkflowFilterComparison { return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER, email); } /** * Assemble the filter tree for a single rule from its parts. The `from` comparison is always - * present; the `previousApprover` gate and `amount` comparison are optional. When neither is - * present we return the bare `from` comparison rather than wrapping it in an `AND` with no `right`. + * present; the `previousApprover` gate and `amount` comparison are optional. */ function buildLevelFilters( fromComparison: ApprovalWorkflowFilterComparison, @@ -780,27 +769,6 @@ function buildLevelFilters( /** * Build the `rules.approvalWorkflows` rule chain for a single `ApprovalWorkflow`. - * - * For a workflow whose approver chain is `[A0, A1, ..., A_{N-1}]` and whose members are `M`, we emit - * the rule(s) that route the report INTO each level. The split happens at the level being routed - * into: at level `i`, the approver `A_i` and their over-limit approver `A_i.overLimitForwardsTo` - * share the same `previousApprover` gate — `A_{i-1}.email` — or NO `previousApprover` at all for the - * first level (a freshly-submitted report). The level's `A_i.approvalLimit` drives the mirrored - * amount pair: - * - * - i == 0, no limit: `from ∈ M` → A0 - * - i == 0, limit: `from ∈ M AND amount < limit` → A0 - * `from ∈ M AND amount >= limit` → A0.overLimitForwardsTo - * - i > 0, no limit: `from ∈ M AND previousApprover == P` → A_i - * - i > 0, limit: `from ∈ M AND (previousApprover == P AND amount < limit)` → A_i - * `from ∈ M AND (previousApprover == P AND amount >= limit)` → A_i.overLimitForwardsTo - * - * Because the previous level's over-limit approver also approves and forwards onward, the report - * can reach level `i` from EITHER the previous level's base approver or its over-limit approver. So - * `P` ranges over every "exit" of the previous level, and we emit the level's rule(s) once per `P`. - * - * The last approver still gets an "into" rule (routed from the previous level); there's just no rule - * forwarding back OUT of them, since nothing forwards past the terminal approver. */ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): ApprovalWorkflowRule[] { const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); @@ -810,11 +778,11 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva return []; } - const fromComparison = buildFromComparison(memberEmails); + const fromComparison = buildSubmitterFilter(memberEmails); const rules: ApprovalWorkflowRule[] = []; // The approvers that could have approved at the PREVIOUS level and forwarded here. An empty list - // means "no previous approver" — the first level / a freshly-submitted report. + // means "no previous approver" let previousApproverEmails: string[] = []; for (let i = 0; i < approvers.length; i++) { @@ -828,13 +796,11 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva const underAmount = limit !== undefined ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit) : undefined; const overAmount = limit !== undefined ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit) : undefined; - // One gate per possible previous approver (or a single "no previousApprover" gate at level 0). + // One per possible previous approver const gates = previousApproverEmails.length === 0 ? [undefined] : previousApproverEmails.map((email) => buildPreviousApproverComparison(email)); for (const gate of gates) { if (hasLimitSplit) { - // The approver and their over-limit approver share this previousApprover gate (or its - // absence at level 0) and differ only by the mirrored amount comparison. rules.push({ filters: buildLevelFilters(fromComparison, gate, underAmount), action: APPROVAL_WORKFLOW_FORWARD_ACTION, @@ -854,48 +820,42 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva } } - // Exits from this level feed the next level's previousApprover gates: when this level splits, - // both the base approver and the over-limit approver can have approved and forwarded onward. previousApproverEmails = hasLimitSplit ? [approver.email, approver.overLimitForwardsTo as string] : [approver.email]; } return rules; } -// --------------------------------------------------------------------------- -// Rule introspection helpers (used by reconciliation) -// --------------------------------------------------------------------------- - /** True when a comparison node targets the `from` field with an equality operator. */ -function isFromComparison(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): boolean { +function isSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): boolean { return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && (node as ApprovalWorkflowFilterComparison).left === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM; } -/** Walk a filter tree and call `visitor` on every `from` comparison leaf. */ -function forEachFromComparison(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined, visitor: (leaf: ApprovalWorkflowFilterComparison) => void): void { +/** Walk a filter tree and call `visitor` on every submitter filter (the `from` leaf) in it. */ +function forEachSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined, visitor: (filter: ApprovalWorkflowFilterComparison) => void): void { if (!node) { return; } - if (isFromComparison(node)) { + if (isSubmitterFilter(node)) { visitor(node as ApprovalWorkflowFilterComparison); return; } // Only filter nodes (which always carry a left filter) and AND/OR nodes have children to recurse into. const leftIsNode = typeof (node as ApprovalWorkflowFilter).left === 'object' && (node as ApprovalWorkflowFilter).left !== null; if (leftIsNode) { - forEachFromComparison((node as ApprovalWorkflowFilter).left, visitor); + forEachSubmitterFilter((node as ApprovalWorkflowFilter).left, visitor); } const right = (node as ApprovalWorkflowFilter).right as ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined; if (right && typeof right === 'object') { - forEachFromComparison(right, visitor); + forEachSubmitterFilter(right, visitor); } } /** Extract the union of email values across every `from` leaf in a rule. */ function extractFromEmails(rule: ApprovalWorkflowRule): string[] { const emails = new Set(); - forEachFromComparison(rule.filters, (leaf) => { - const right = leaf.right; + forEachSubmitterFilter(rule.filters, (filter) => { + const right = filter.right; if (Array.isArray(right)) { right.forEach((email) => { if (typeof email === 'string') { @@ -919,7 +879,7 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { if (!node) { return node; } - if (isFromComparison(node)) { + if (isSubmitterFilter(node)) { return {operator: node.operator, left: (node as ApprovalWorkflowFilterComparison).left}; } const out: Record = {operator: node.operator}; @@ -944,7 +904,7 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { /** Replace the `right` value on every `from` leaf with `newEmails`. */ function withFromEmails(rule: ApprovalWorkflowRule, newEmails: string[]): ApprovalWorkflowRule { const rewrite = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison => { - if (isFromComparison(node)) { + if (isSubmitterFilter(node)) { return {...node, right: [...newEmails]} as ApprovalWorkflowFilterComparison; } const left = (node as ApprovalWorkflowFilter).left; From 6a1eadc623d02c56074c1661492e46c6bf847af4 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 14:52:03 +0200 Subject: [PATCH 034/466] better naming --- src/libs/WorkflowUtils.ts | 62 +++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index e6801d144cd3..e4333a98a901 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -831,28 +831,28 @@ function isSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilter return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && (node as ApprovalWorkflowFilterComparison).left === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM; } -/** Walk a filter tree and call `visitor` on every submitter filter (the `from` leaf) in it. */ -function forEachSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined, visitor: (filter: ApprovalWorkflowFilterComparison) => void): void { +/** Walk a filter tree and call `callback` on every submitter filter (the `from` leaf) in it. */ +function forEachSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined, callback: (filter: ApprovalWorkflowFilterComparison) => void): void { if (!node) { return; } if (isSubmitterFilter(node)) { - visitor(node as ApprovalWorkflowFilterComparison); + callback(node as ApprovalWorkflowFilterComparison); return; } // Only filter nodes (which always carry a left filter) and AND/OR nodes have children to recurse into. const leftIsNode = typeof (node as ApprovalWorkflowFilter).left === 'object' && (node as ApprovalWorkflowFilter).left !== null; if (leftIsNode) { - forEachSubmitterFilter((node as ApprovalWorkflowFilter).left, visitor); + forEachSubmitterFilter((node as ApprovalWorkflowFilter).left, callback); } const right = (node as ApprovalWorkflowFilter).right as ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined; if (right && typeof right === 'object') { - forEachSubmitterFilter(right, visitor); + forEachSubmitterFilter(right, callback); } } /** Extract the union of email values across every `from` leaf in a rule. */ -function extractFromEmails(rule: ApprovalWorkflowRule): string[] { +function extractSubmitterEmails(rule: ApprovalWorkflowRule): string[] { const emails = new Set(); forEachSubmitterFilter(rule.filters, (filter) => { const right = filter.right; @@ -902,7 +902,7 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { } /** Replace the `right` value on every `from` leaf with `newEmails`. */ -function withFromEmails(rule: ApprovalWorkflowRule, newEmails: string[]): ApprovalWorkflowRule { +function replaceSubmitterEmails(rule: ApprovalWorkflowRule, newEmails: string[]): ApprovalWorkflowRule { const rewrite = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison => { if (isSubmitterFilter(node)) { return {...node, right: [...newEmails]} as ApprovalWorkflowFilterComparison; @@ -918,11 +918,11 @@ function withFromEmails(rule: ApprovalWorkflowRule, newEmails: string[]): Approv return {...rule, filters: rewrite(rule.filters) as ApprovalWorkflowFilter}; } -/** Union of `existingEmails` and `additions`, preserving the order of `existingEmails`. */ -function unionEmails(existingEmails: string[], additions: string[]): string[] { +/** Merge `emailsToAdd` into `existingEmails`, preserving the order of `existingEmails` and dropping duplicates. */ +function mergeEmails(existingEmails: string[], emailsToAdd: string[]): string[] { const seen = new Set(existingEmails); const result = [...existingEmails]; - additions.forEach((email) => { + emailsToAdd.forEach((email) => { if (seen.has(email)) { return; } @@ -932,9 +932,9 @@ function unionEmails(existingEmails: string[], additions: string[]): string[] { return result; } -/** `existingEmails` with everything in `removals` filtered out. */ -function differenceEmails(existingEmails: string[], removals: string[]): string[] { - const removalSet = new Set(removals); +/** Remove everything in `emailsToRemove` from `existingEmails`, preserving the order of `existingEmails`. */ +function removeEmails(existingEmails: string[], emailsToRemove: string[]): string[] { + const removalSet = new Set(emailsToRemove); return existingEmails.filter((email) => !removalSet.has(email)); } @@ -963,8 +963,8 @@ function reconcileApprovalWorkflowRulesForCreate(newRules: ApprovalWorkflowRule[ if (match) { const [existingID, existingRule] = match; - const mergedEmails = unionEmails(extractFromEmails(existingRule), memberEmails); - diff[existingID] = withFromEmails(existingRule, mergedEmails); + const mergedEmails = mergeEmails(extractSubmitterEmails(existingRule), memberEmails); + diff[existingID] = replaceSubmitterEmails(existingRule, mergedEmails); continue; } @@ -997,7 +997,7 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], // Pass 1: walk existing rules that belong (at least partially) to this workflow. for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { - const ruleEmails = extractFromEmails(existingRule); + const ruleEmails = extractSubmitterEmails(existingRule); const sharedWithThisWorkflow = ruleEmails.some((email) => memberSet.has(email)); if (!sharedWithThisWorkflow) { continue; @@ -1010,13 +1010,13 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], continue; } - const remaining = differenceEmails(ruleEmails, memberEmails); + const remaining = removeEmails(ruleEmails, memberEmails); if (remaining.length === 0) { // No other workflow shares this rule — drop it entirely. diff[ruleID] = null; } else { // Keep the rule for the other workflows that still need it, minus our members. - diff[ruleID] = withFromEmails(existingRule, remaining); + diff[ruleID] = replaceSubmitterEmails(existingRule, remaining); } } @@ -1039,15 +1039,15 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], if (structuralFingerprint(existing) !== fingerprint) { return false; } - const existingEmails = extractFromEmails(existing); + const existingEmails = extractSubmitterEmails(existing); // "Different workflow" => no overlap with our members. return !existingEmails.some((email) => memberSet.has(email)); }); if (foreignMatch) { const [existingID, existingRule] = foreignMatch; - const mergedEmails = unionEmails(extractFromEmails(existingRule), memberEmails); - diff[existingID] = withFromEmails(existingRule, mergedEmails); + const mergedEmails = mergeEmails(extractSubmitterEmails(existingRule), memberEmails); + diff[existingID] = replaceSubmitterEmails(existingRule, mergedEmails); continue; } @@ -1067,14 +1067,14 @@ function reconcileApprovalWorkflowRulesForRemove(memberEmails: string[], context const memberSet = new Set(memberEmails); for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { - const ruleEmails = extractFromEmails(existingRule); + const ruleEmails = extractSubmitterEmails(existingRule); const sharedWithThisWorkflow = ruleEmails.some((email) => memberSet.has(email)); if (!sharedWithThisWorkflow) { continue; } - const remaining = differenceEmails(ruleEmails, memberEmails); - diff[ruleID] = remaining.length === 0 ? null : withFromEmails(existingRule, remaining); + const remaining = removeEmails(ruleEmails, memberEmails); + diff[ruleID] = remaining.length === 0 ? null : replaceSubmitterEmails(existingRule, remaining); } return diff; @@ -1092,20 +1092,20 @@ function reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails: st const removed = previousMemberEmails.filter((email) => !newSet.has(email)); for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { - const ruleEmails = extractFromEmails(existingRule); + const ruleEmails = extractSubmitterEmails(existingRule); if (!ruleEmails.some((email) => previousSet.has(email))) { continue; } - const afterRemoval = differenceEmails(ruleEmails, removed); - const updatedEmails = unionEmails(afterRemoval, newMemberEmails); + const afterRemoval = removeEmails(ruleEmails, removed); + const updatedEmails = mergeEmails(afterRemoval, newMemberEmails); // No effective change to this rule's `from` — skip the round-trip. if (updatedEmails.length === ruleEmails.length && updatedEmails.every((email, idx) => email === ruleEmails[idx])) { continue; } - diff[ruleID] = updatedEmails.length === 0 ? null : withFromEmails(existingRule, updatedEmails); + diff[ruleID] = updatedEmails.length === 0 ? null : replaceSubmitterEmails(existingRule, updatedEmails); } return diff; @@ -1198,7 +1198,7 @@ function resolveLevelFromRules(submitter: string, previousApproverEmail: string let approvalLimit: number | undefined; for (const rule of Object.values(rules)) { - if (!extractFromEmails(rule).includes(submitter)) { + if (!extractSubmitterEmails(rule).includes(submitter)) { continue; } const previousApproverLeaf = findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER); @@ -1327,7 +1327,7 @@ function approverChainFingerprint(chain: Approver[]): string { /** The sorted set of ruleIDs whose `from` filter includes this submitter — their exact rule membership. */ function getSubmitterRuleIDs(submitter: string, rules: Record): string[] { return Object.entries(rules) - .filter(([, rule]) => extractFromEmails(rule).includes(submitter)) + .filter(([, rule]) => extractSubmitterEmails(rule).includes(submitter)) .map(([ruleID]) => ruleID) .sort(); } @@ -1345,7 +1345,7 @@ function isSubmitterCoveredByRules(submitter: string, rules: Record): Record { const submitters = new Set(); for (const rule of Object.values(rules)) { - for (const email of extractFromEmails(rule)) { + for (const email of extractSubmitterEmails(rule)) { submitters.add(email); } } From ae82565d4d394992dfbbf470682da4b5888d315e Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 14:58:03 +0200 Subject: [PATCH 035/466] some more naming/doc changes --- src/libs/WorkflowUtils.ts | 55 +++++---------------------------------- 1 file changed, 6 insertions(+), 49 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index e4333a98a901..c552221dbcd4 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -938,10 +938,6 @@ function removeEmails(existingEmails: string[], emailsToRemove: string[]): strin return existingEmails.filter((email) => !removalSet.has(email)); } -// --------------------------------------------------------------------------- -// Reconciliation against the existing `rules.approvalWorkflows` map -// --------------------------------------------------------------------------- - type ReconcileContext = { /** The existing `Policy.rules.approvalWorkflows` snapshot, defaulting to `{}`. */ existingRules: Record; @@ -1113,8 +1109,6 @@ function reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails: st /** * Apply an `ApprovalWorkflowRulesDiff` to a rule map, returning a new map. A `null` value removes - * the rule; any other value upserts it. Lets us chain reconcilers — e.g. apply a membership change, - * then reconcile the approver chain against the result. */ function applyApprovalWorkflowRulesDiff(existingRules: Record, diff: ApprovalWorkflowRulesDiff): Record { const result: Record = {...existingRules}; @@ -1128,20 +1122,10 @@ function applyApprovalWorkflowRulesDiff(existingRules: Record): ApprovalWorkflowLevelInfo | undefined { let underLimitRule: ApprovalWorkflowRule | undefined; @@ -1253,12 +1227,9 @@ type BuildApproverChainFromRulesParams = { }; /** - * Walk a submitter's approval chain forward from their initial-submission level. At each step we + * Loop through a submitter's approval chain forward from their initial-submission level. At each step we * resolve the level the report is routed into to learn the next approver's email AND that - * approver's own `approvalLimit` / `overLimitForwardsTo`; the current approver's `forwardsTo` is the - * next approver's email. Each piece falls back to the current/next approver's `employeeList` entry - * when no rule covers that hop, so pre-beta (legacy) workflows still resolve. Stops at circular - * references (matching `calculateApprovers`). + * approver's own `approvalLimit` / `overLimitForwardsTo`; */ function buildApproverChainFromRules({submitter, rules, employees, personalDetailsByEmail}: BuildApproverChainFromRulesParams): Approver[] { // The first approver, and its own limit/over, come from the initial (no-previousApprover) level. @@ -1278,9 +1249,6 @@ function buildApproverChainFromRules({submitter, rules, employees, personalDetai const isCircularReference = seenEmails.has(currentEmail); const employee: PolicyEmployee | undefined = employees[currentEmail]; - // The rules gated on `previousApprover == currentEmail` describe the NEXT approver: their - // email is where the current approver forwards, and the level also carries the NEXT - // approver's own limit/over. const nextLevel = resolveLevelFromRules(submitter, currentEmail, rules); const forwardsTo: string | undefined = nextLevel?.email ?? employee?.forwardsTo; @@ -1301,9 +1269,6 @@ function buildApproverChainFromRules({submitter, rules, employees, personalDetai break; } seenEmails.add(currentEmail); - - // Advance: the next approver's own limit/over come from the level that routed into them - // (the `nextLevel` we just resolved), falling back to their employeeList entry. currentEmail = forwardsTo; currentApprovalLimit = nextLevel?.approvalLimit ?? employees[currentEmail]?.approvalLimit ?? null; currentOverLimitForwardsTo = nextLevel?.overLimitForwardsTo ?? employees[currentEmail]?.overLimitForwardsTo; @@ -1332,15 +1297,8 @@ function getSubmitterRuleIDs(submitter: string, rules: Record): boolean { - return getSubmitterRuleIDs(submitter, rules).length > 0; -} - /** - * Map every submitter found in the rules to their workflow's first approver (the `nextReceiver` of - * their initial, no-`previousApprover` rule). Used to warn when a member is about to be moved out of - * an existing workflow into another one. + * Map every submitter found in the rules to their workflow's first approver */ function getRulesSubmitterToFirstApprover(rules: Record): Record { const submitters = new Set(); @@ -1446,8 +1404,7 @@ function convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, first // Group submitters that share the same rules into one workflow. A submitter's "rules" is the // exact set of ruleIDs whose `from` includes them, so two submitters that belong to the same - // rules land in the same workflow (and submitters whose chains diverge — different rule sets — - // stay separate). Legacy submitters (no rules) still group by their reconstructed chain. + // rules land in the same workflow const submitterRuleIDs = getSubmitterRuleIDs(email, rules); const fingerprint = submitterRuleIDs.length > 0 ? `r|${submitterRuleIDs.join(',')}` : `l|${approverChainFingerprint(chain)}`; const existingGroup = groupedByFingerprint.get(fingerprint); From 9973de0a2afa8b4f262306a337215709e46e9870 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 15:02:57 +0200 Subject: [PATCH 036/466] some more cleanup --- src/ROUTES.ts | 2 -- .../workflows/WorkspaceWorkflowsPage.tsx | 6 +----- ...orkspaceWorkflowsApprovalsExpensesFromPage.tsx | 3 +-- src/types/onyx/ApprovalWorkflowRules.ts | 15 ++++----------- src/types/onyx/Policy.ts | 3 +-- 5 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 03cfd172eb85..cded97d36eaa 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2351,8 +2351,6 @@ const ROUTES = { }, WORKSPACE_WORKFLOWS_APPROVALS_EDIT: { route: 'workspaces/:policyID/workflows/approvals/:firstApproverEmail/edit', - // `memberEmail` disambiguates workflows that share a first approver (possible once rule-based - // chains diverge) — a member belongs to exactly one workflow, so it uniquely identifies it. getRoute: (policyID: string, firstApproverEmail: string, memberEmail?: string) => `workspaces/${policyID}/workflows/approvals/${encodeURIComponent(firstApproverEmail)}/edit${memberEmail ? `?memberEmail=${encodeURIComponent(memberEmail)}` : ''}` as const, }, diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 86652b5af967..daf02f9da7a2 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -294,10 +294,6 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const isHRAdvancedModeEnabled = isHRAdvancedMode(policy); const hrFinalApproverEmail = getHRFinalApprover(policy) ?? undefined; - // With the multipleApprovers beta on, `approvalWorkflows` is reconstructed from - // `rules.approvalWorkflows` and already contains exactly the workflows that exist, so we don't - // collapse to the default workflow based on `approvalMode` (which the rules-create path doesn't - // bump to ADVANCED). const filteredApprovalWorkflows = isMultipleApproversBetaEnabled || policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.ADVANCED || @@ -532,7 +528,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { {displayedWorkflows.map((workflow) => { const firstApproverEmail = workflow.approvers.at(0)?.email ?? ''; // A workflow's first approver isn't unique once rule-based chains diverge, but a member - // belongs to exactly one workflow — use it to disambiguate both the React key and the edit route. + // belongs to exactly one workflow const firstMemberEmail = workflow.members.at(0)?.email ?? ''; return ( diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index 9517fe5cbfd3..acd0328a3761 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -475,8 +475,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat // Warn when adding a member who already belongs to another workflow. With the beta on this // applies to both create and edit (a submitter can only be in one workflow, so confirming - // moves them); legacy keeps the create-only behavior. The `!== firstApprover` check skips - // members who are already in the workflow being edited. + // moves them); legacy keeps the create-only behavior. const shouldCheckCrossWorkflow = isCreateAction || isMultipleApproversBetaEnabled; if (shouldCheckCrossWorkflow && members.length > selectedMembers.length) { const newMember = members.find((m) => !selectedMembers.some((s) => s.login === m.login)); diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index 92b3ecc78c78..0cb640341e88 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -30,21 +30,16 @@ type ApprovalWorkflowFilterComparison = { /** * Top-level filter for an approval-workflow rule. Combines two child nodes with a boolean - * operator. `left` / `right` may be either leaf comparisons or nested filters, so the tree - * can express arbitrarily deep `from AND (previousApprover AND amount < limit)` shapes. - * - * For this project the operator is constrained to `AND` at runtime, but the type uses - * `ValueOf` so future operators (e.g. `OR`) can slot in without changing - * the contract. + * operator. `left` / `right` may be either leaf comparisons or nested filters */ type ApprovalWorkflowFilter = { - /** Boolean combinator — currently always `AND` in production. */ + /** Boolean combinator */ operator: ValueOf; /** Left-hand child: leaf comparison or nested boolean filter. */ left: ApprovalWorkflowFilterComparison | ApprovalWorkflowFilter; - /** Right-hand child. Optional so a degenerate single-comparison filter can be represented. */ + /** Right-hand child */ right?: ApprovalWorkflowFilterComparison | ApprovalWorkflowFilter; }; @@ -54,9 +49,7 @@ type ApprovalWorkflowFilter = { */ type ApprovalWorkflowRule = { /** - * Conditions that must match the report for the rule to fire. Usually an `AND` filter, but a - * rule with a single condition (e.g. the initial-submission rule that only checks `from`) is - * stored as a bare comparison rather than a degenerate `AND` with no `right`. + * Conditions that must match the report for the rule to fire. */ filters: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 6e9d6d6b6903..eace50dd2f88 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2297,8 +2297,7 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** * Approval-workflow rules keyed by ruleID. Each rule routes a report to a `nextReceiver` - * when its `filters` match. Multiple workflows can share the same rule by listing more - * than one submitter in the `from` filter. + * when its `filters` match. */ approvalWorkflows?: Record; }; From 9a1199b38243de524c7bcba5d14ee18dd6fd289b Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 15:11:34 +0200 Subject: [PATCH 037/466] prettier --- .../approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index acd0328a3761..758325704c3c 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -23,8 +23,8 @@ import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {canEditWorkspaceSettings, getDefaultApprover, getExcludedUsers, getMemberAccountIDsForWorkspace, isPendingDeletePolicy} from '@libs/PolicyUtils'; -import {getRulesSubmitterToFirstApprover} from '@libs/WorkflowUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {getRulesSubmitterToFirstApprover} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; From 4b5c3e215714dcc36c3a0ff4c36ceaa850d29d28 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 15:15:30 +0200 Subject: [PATCH 038/466] knip --- src/libs/actions/Workflow.ts | 1 - src/types/onyx/ApprovalWorkflowRules.ts | 2 +- src/types/onyx/Policy.ts | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 747b8680d563..d1b383d54af9 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -601,7 +601,6 @@ export { setApprovalWorkflowMembers, setApprovalWorkflowApprover, setApprovalWorkflow, - setApprovalWorkflowRules, selectApprovalWorkflowForEdit, clearApprovalWorkflowApprover, clearApprovalWorkflowApprovers, diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index 0cb640341e88..f6519338d850 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -66,4 +66,4 @@ type ApprovalWorkflowRule = { */ type ApprovalWorkflowRules = Record; -export type {ApprovalWorkflowFilter, ApprovalWorkflowFilterComparison, ApprovalWorkflowFilterOperand, ApprovalWorkflowRule, ApprovalWorkflowRules}; +export type {ApprovalWorkflowFilter, ApprovalWorkflowFilterComparison, ApprovalWorkflowRule, ApprovalWorkflowRules}; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index eace50dd2f88..a6b6995fa5fe 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -5,7 +5,7 @@ import type CONST from '@src/CONST'; import type {Country} from '@src/CONST'; import type {MergeHRProviderSlug} from '@src/CONST/MERGE_HR_PROVIDERS'; import type * as OnyxTypes from '.'; -import type {ApprovalWorkflowRule} from './ApprovalWorkflowRules'; +import type {ApprovalWorkflowRules} from './ApprovalWorkflowRules'; import type * as OnyxCommon from './OnyxCommon'; import type {WorkspaceTravelSettings} from './TravelSettings'; @@ -2299,7 +2299,7 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< * Approval-workflow rules keyed by ruleID. Each rule routes a report to a `nextReceiver` * when its `filters` match. */ - approvalWorkflows?: Record; + approvalWorkflows?: ApprovalWorkflowRules; }; /** The "RuleBot" agent account ID */ From dcfe92cdf9fad0ad91ecfdc7f71bbf6e34e691e3 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 15:56:21 +0200 Subject: [PATCH 039/466] lint --- .../API/parameters/ApprovalWorkflowParams.ts | 2 +- src/libs/API/parameters/index.ts | 2 +- src/libs/WorkflowUtils.ts | 99 +++++++++---------- 3 files changed, 47 insertions(+), 56 deletions(-) diff --git a/src/libs/API/parameters/ApprovalWorkflowParams.ts b/src/libs/API/parameters/ApprovalWorkflowParams.ts index bab03b2bf134..838dd39843c3 100644 --- a/src/libs/API/parameters/ApprovalWorkflowParams.ts +++ b/src/libs/API/parameters/ApprovalWorkflowParams.ts @@ -8,4 +8,4 @@ type SetApprovalWorkflowParams = { rules: string; }; -export type {SetApprovalWorkflowParams}; +export default SetApprovalWorkflowParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 926f9c2d76dc..44cf21f15d2d 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -526,7 +526,7 @@ export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; export type {default as SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams} from './SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams'; export type {default as SetPolicyCodingRuleParams} from './SetPolicyCodingRuleParams'; -export type {SetApprovalWorkflowParams} from './ApprovalWorkflowParams'; +export type {default as SetApprovalWorkflowParams} from './ApprovalWorkflowParams'; export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; export type {default as RevokeMultifactorAuthenticationCredentialsParams} from './RevokeMultifactorAuthenticationCredentialsParams'; export type {default as TroubleshootMultifactorAuthenticationParams} from './TroubleshootMultifactorAuthenticationParams'; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index c552221dbcd4..cab4237121bd 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -792,7 +792,7 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva } const hasLimitSplit = !!approver.approvalLimit && approver.approvalLimit > 0 && !!approver.overLimitForwardsTo; - const limit = hasLimitSplit ? (approver.approvalLimit as number) : undefined; + const limit = hasLimitSplit ? approver.approvalLimit! : undefined; const underAmount = limit !== undefined ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit) : undefined; const overAmount = limit !== undefined ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit) : undefined; @@ -809,7 +809,7 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva rules.push({ filters: buildLevelFilters(fromComparison, gate, overAmount), action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: approver.overLimitForwardsTo as string, + nextReceiver: approver.overLimitForwardsTo!, }); } else { rules.push({ @@ -820,15 +820,27 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva } } - previousApproverEmails = hasLimitSplit ? [approver.email, approver.overLimitForwardsTo as string] : [approver.email]; + previousApproverEmails = hasLimitSplit ? [approver.email, approver.overLimitForwardsTo!] : [approver.email]; } return rules; } +/** + * A leaf comparison node — its `left` is a primitive field name (or an array of values), unlike a + * boolean filter whose `left` is another node. + */ +function isComparisonLeaf(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): node is ApprovalWorkflowFilterComparison { + if (!node) { + return false; + } + const nodeLeft = node.left; + return typeof nodeLeft !== 'object' || nodeLeft === null || Array.isArray(nodeLeft); +} + /** True when a comparison node targets the `from` field with an equality operator. */ function isSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): boolean { - return node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && (node as ApprovalWorkflowFilterComparison).left === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM; + return isComparisonLeaf(node) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && node.left === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM; } /** Walk a filter tree and call `callback` on every submitter filter (the `from` leaf) in it. */ @@ -836,19 +848,15 @@ function forEachSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowF if (!node) { return; } - if (isSubmitterFilter(node)) { - callback(node as ApprovalWorkflowFilterComparison); + if (isComparisonLeaf(node)) { + // A leaf has no children to recurse into; only `from` leaves are reported. + if (isSubmitterFilter(node)) { + callback(node); + } return; } - // Only filter nodes (which always carry a left filter) and AND/OR nodes have children to recurse into. - const leftIsNode = typeof (node as ApprovalWorkflowFilter).left === 'object' && (node as ApprovalWorkflowFilter).left !== null; - if (leftIsNode) { - forEachSubmitterFilter((node as ApprovalWorkflowFilter).left, callback); - } - const right = (node as ApprovalWorkflowFilter).right as ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined; - if (right && typeof right === 'object') { - forEachSubmitterFilter(right, callback); - } + forEachSubmitterFilter(node.left, callback); + forEachSubmitterFilter(node.right, callback); } /** Extract the union of email values across every `from` leaf in a rule. */ @@ -857,11 +865,11 @@ function extractSubmitterEmails(rule: ApprovalWorkflowRule): string[] { forEachSubmitterFilter(rule.filters, (filter) => { const right = filter.right; if (Array.isArray(right)) { - right.forEach((email) => { + for (const email of right) { if (typeof email === 'string') { emails.add(email); } - }); + } } else if (typeof right === 'string') { emails.add(right); } @@ -879,19 +887,14 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { if (!node) { return node; } - if (isSubmitterFilter(node)) { - return {operator: node.operator, left: (node as ApprovalWorkflowFilterComparison).left}; - } - const out: Record = {operator: node.operator}; - const left = (node as ApprovalWorkflowFilter).left; - if (left !== undefined) { - out.left = typeof left === 'object' && left !== null ? stripFromValues(left) : left; - } - const right = (node as ApprovalWorkflowFilter).right as unknown; - if (right !== undefined) { - out.right = typeof right === 'object' && right !== null ? stripFromValues(right as ApprovalWorkflowFilter) : right; + if (isComparisonLeaf(node)) { + // Drop the `right` (submitter list) from `from` leaves so only the structure remains. + if (isSubmitterFilter(node)) { + return {operator: node.operator, left: node.left}; + } + return {operator: node.operator, left: node.left, right: node.right}; } - return out; + return {operator: node.operator, left: stripFromValues(node.left), right: stripFromValues(node.right)}; }; return JSON.stringify({ @@ -904,31 +907,29 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { /** Replace the `right` value on every `from` leaf with `newEmails`. */ function replaceSubmitterEmails(rule: ApprovalWorkflowRule, newEmails: string[]): ApprovalWorkflowRule { const rewrite = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison => { - if (isSubmitterFilter(node)) { - return {...node, right: [...newEmails]} as ApprovalWorkflowFilterComparison; + if (isComparisonLeaf(node)) { + return isSubmitterFilter(node) ? {...node, right: [...newEmails]} : node; } - const left = (node as ApprovalWorkflowFilter).left; - const right = (node as ApprovalWorkflowFilter).right as ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined; return { ...node, - ...(typeof left === 'object' && left !== null ? {left: rewrite(left)} : {}), - ...(right && typeof right === 'object' ? {right: rewrite(right)} : {}), - } as ApprovalWorkflowFilter; + left: rewrite(node.left), + ...(node.right ? {right: rewrite(node.right)} : {}), + }; }; - return {...rule, filters: rewrite(rule.filters) as ApprovalWorkflowFilter}; + return {...rule, filters: rewrite(rule.filters)}; } /** Merge `emailsToAdd` into `existingEmails`, preserving the order of `existingEmails` and dropping duplicates. */ function mergeEmails(existingEmails: string[], emailsToAdd: string[]): string[] { const seen = new Set(existingEmails); const result = [...existingEmails]; - emailsToAdd.forEach((email) => { + for (const email of emailsToAdd) { if (seen.has(email)) { - return; + continue; } seen.add(email); result.push(email); - }); + } return result; } @@ -1097,7 +1098,7 @@ function reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails: st const updatedEmails = mergeEmails(afterRemoval, newMemberEmails); // No effective change to this rule's `from` — skip the round-trip. - if (updatedEmails.length === ruleEmails.length && updatedEmails.every((email, idx) => email === ruleEmails[idx])) { + if (updatedEmails.length === ruleEmails.length && updatedEmails.every((email, idx) => email === ruleEmails.at(idx))) { continue; } @@ -1127,15 +1128,6 @@ function applyApprovalWorkflowRulesDiff(existingRules: Record Date: Mon, 29 Jun 2026 16:04:58 +0200 Subject: [PATCH 040/466] more lint --- src/types/onyx/ApprovalWorkflowRules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index f6519338d850..339d37e5d82c 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -9,7 +9,7 @@ import type CONST from '@src/CONST'; * - The right operand is the literal value the field is compared against. For email-typed * fields like `from`, the value may be a list of emails to match against. */ -type ApprovalWorkflowFilterOperand = ValueOf | string | number | string[]; +type ApprovalWorkflowFilterOperand = string | number | string[]; /** * A single comparison node: ` `. From 437c9e4a006f86e7038706917fbae007dff759c7 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 16:47:43 +0200 Subject: [PATCH 041/466] more lint --- src/libs/WorkflowUtils.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index cab4237121bd..2ee5b43fd42e 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -791,16 +791,20 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva continue; } - const hasLimitSplit = !!approver.approvalLimit && approver.approvalLimit > 0 && !!approver.overLimitForwardsTo; - const limit = hasLimitSplit ? approver.approvalLimit! : undefined; - const underAmount = limit !== undefined ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit) : undefined; - const overAmount = limit !== undefined ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limit) : undefined; + // Narrow once: when the approver has a positive limit and an over-limit target, capture both + // (already non-null inside this branch) so the level can route under/over amounts separately. + const limitSplit = + approver.approvalLimit && approver.approvalLimit > 0 && approver.overLimitForwardsTo + ? {limit: approver.approvalLimit, overLimitForwardsTo: approver.overLimitForwardsTo} + : undefined; + const underAmount = limitSplit ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limitSplit.limit) : undefined; + const overAmount = limitSplit ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limitSplit.limit) : undefined; // One per possible previous approver const gates = previousApproverEmails.length === 0 ? [undefined] : previousApproverEmails.map((email) => buildPreviousApproverComparison(email)); for (const gate of gates) { - if (hasLimitSplit) { + if (limitSplit) { rules.push({ filters: buildLevelFilters(fromComparison, gate, underAmount), action: APPROVAL_WORKFLOW_FORWARD_ACTION, @@ -809,7 +813,7 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva rules.push({ filters: buildLevelFilters(fromComparison, gate, overAmount), action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: approver.overLimitForwardsTo!, + nextReceiver: limitSplit.overLimitForwardsTo, }); } else { rules.push({ @@ -820,7 +824,7 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva } } - previousApproverEmails = hasLimitSplit ? [approver.email, approver.overLimitForwardsTo!] : [approver.email]; + previousApproverEmails = limitSplit ? [approver.email, limitSplit.overLimitForwardsTo] : [approver.email]; } return rules; From 3cce08e48deb60286cab79170ee0050c0dabedde Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 18:01:04 +0200 Subject: [PATCH 042/466] lint again --- src/libs/actions/Workflow.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index d1b383d54af9..112929133fb8 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -1,10 +1,10 @@ import lodashDropRightWhile from 'lodash/dropRightWhile'; import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import * as API from '@libs/API'; +import {write} from '@libs/API'; import type {CreateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams, SetApprovalWorkflowParams, UpdateWorkspaceApprovalParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; -import * as ErrorUtils from '@libs/ErrorUtils'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {getDefaultApprover} from '@libs/PolicyUtils'; import type {ApprovalWorkflowRulesDiff} from '@libs/WorkflowUtils'; import { @@ -100,7 +100,7 @@ function createApprovalWorkflow({approvalWorkflow, policy, addExpenseApprovalsTa ]; const parameters: CreateWorkspaceApprovalParams = {policyID: policy.id, employees: JSON.stringify(Object.values(updatedEmployees))}; - API.write(WRITE_COMMANDS.CREATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.CREATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); if ( addExpenseApprovalsTaskReport && @@ -214,7 +214,7 @@ function updateApprovalWorkflow(approvalWorkflow: ApprovalWorkflow, membersToRem employees: JSON.stringify(Object.values(updatedEmployees)), defaultApprover: newDefaultApprover, }; - API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); } function removeApprovalWorkflow(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry) { @@ -273,7 +273,7 @@ function removeApprovalWorkflow(approvalWorkflow: ApprovalWorkflow, policy: Onyx ]; const parameters: RemoveWorkspaceApprovalParams = {policyID: policy.id, employees: JSON.stringify(Object.values(updatedEmployees))}; - API.write(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); } type SetApprovalWorkflowRulesParams = { @@ -336,7 +336,7 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousApprovalWorkflow value: { rules: {approvalWorkflows: failureApprovalWorkflows}, pendingFields: {approvalWorkflows: null}, - errorFields: {approvalWorkflows: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + errorFields: {approvalWorkflows: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, }, }, ]; @@ -346,7 +346,7 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousApprovalWorkflow rules: JSON.stringify(rulesDiff), }; - API.write(WRITE_COMMANDS.SET_APPROVAL_WORKFLOW, parameters, {optimisticData, successData, failureData}); + write(WRITE_COMMANDS.SET_APPROVAL_WORKFLOW, parameters, {optimisticData, successData, failureData}); } /** From 3fa8579e736e003ab46d6f880cd908827d67a783 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 18:17:32 +0200 Subject: [PATCH 043/466] Add tests --- tests/actions/WorkflowTest.ts | 155 ++++++++++++++++- tests/unit/WorkflowUtilsTest.ts | 294 ++++++++++++++++++++++++++++++++ 2 files changed, 448 insertions(+), 1 deletion(-) diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index ea960b0aee8c..3e8f93edf4df 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -4,7 +4,16 @@ import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import {generatePolicyID} from '@src/libs/actions/Policy/Policy'; import * as Task from '@src/libs/actions/Task'; -import {clearApprovalWorkflowApprover, createApprovalWorkflow, removeApprovalWorkflow, setApprovalWorkflowApprover, updateApprovalWorkflow} from '@src/libs/actions/Workflow'; +import { + clearApprovalWorkflowApprover, + createApprovalWorkflow, + createApprovalWorkflowRules, + removeApprovalWorkflow, + removeApprovalWorkflowRules, + setApprovalWorkflowApprover, + updateApprovalWorkflow, + updateApprovalWorkflowRules, +} from '@src/libs/actions/Workflow'; import {calculateApprovers} from '@src/libs/WorkflowUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ApprovalWorkflowOnyx, PersonalDetailsList, Policy, Policy as PolicyType, Report} from '@src/types/onyx'; @@ -837,4 +846,148 @@ describe('actions/Workflow', () => { await waitForBatchedUpdates(); }); }); + + describe('createApprovalWorkflowRules', () => { + it('writes a new rule-based approval workflow to the policy rules', async () => { + mockFetch.pause(); + + const policyID = '123456789'; + const policy = { + id: policyID, + name: 'Test Workspace', + role: 'admin', + type: 'corporate', + owner: ownerEmail, + rules: {}, + } as unknown as Policy; + + const approvalWorkflow = { + members: [{email: employee1Email, displayName: employee1Email}], + approvers: [{email: ownerEmail, displayName: ownerEmail, isCircularReference: false}], + availableMembers: [], + usedApproverEmails: [], + isDefault: false, + action: 'create', + originalApprovers: [], + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); + await waitForBatchedUpdates(); + + createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport: undefined}); + await waitForBatchedUpdates(); + + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const rules = Object.values(updatedPolicy?.rules?.approvalWorkflows ?? {}); + expect(rules).toHaveLength(1); + expect(rules.at(0)?.nextReceiver).toBe(ownerEmail); + expect(rules.at(0)?.filters).toEqual({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}); + + await mockFetch.resume(); + await waitForBatchedUpdates(); + }); + }); + + describe('updateApprovalWorkflowRules', () => { + it('replaces the approver chain when the workflow approver changes', async () => { + mockFetch.pause(); + + const policyID = '123456789'; + const policy = { + id: policyID, + name: 'Test Workspace', + role: 'admin', + type: 'corporate', + owner: ownerEmail, + rules: { + approvalWorkflows: { + rule1: { + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, + action: 'forward', + nextReceiver: ownerEmail, + }, + }, + }, + } as unknown as Policy; + + const initialApprovalWorkflow = { + members: [{email: employee1Email, displayName: employee1Email}], + approvers: [{email: ownerEmail, displayName: ownerEmail, isCircularReference: false}], + availableMembers: [], + usedApproverEmails: [], + isDefault: false, + action: 'update', + originalApprovers: [], + }; + const approvalWorkflow = { + ...initialApprovalWorkflow, + approvers: [{email: employee2Email, displayName: employee2Email, isCircularReference: false}], + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); + await waitForBatchedUpdates(); + + updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy}); + await waitForBatchedUpdates(); + + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const rules = Object.values(updatedPolicy?.rules?.approvalWorkflows ?? {}); + // The old rule (forwarding to the owner) is removed and a new rule forwarding to employee2 is created. + expect(updatedPolicy?.rules?.approvalWorkflows?.rule1).toBeUndefined(); + expect(rules).toHaveLength(1); + expect(rules.at(0)?.nextReceiver).toBe(employee2Email); + + await mockFetch.resume(); + await waitForBatchedUpdates(); + }); + }); + + describe('removeApprovalWorkflowRules', () => { + it('removes the rules that belong only to the workflow members', async () => { + mockFetch.pause(); + + const policyID = '123456789'; + const policy = { + id: policyID, + name: 'Test Workspace', + role: 'admin', + type: 'corporate', + owner: ownerEmail, + rules: { + approvalWorkflows: { + rule1: { + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, + action: 'forward', + nextReceiver: ownerEmail, + }, + }, + }, + } as unknown as Policy; + + const approvalWorkflow = { + members: [{email: employee1Email, displayName: employee1Email}], + approvers: [{email: ownerEmail, displayName: ownerEmail, isCircularReference: false}], + availableMembers: [], + usedApproverEmails: [], + isDefault: false, + action: 'remove', + originalApprovers: [], + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); + await waitForBatchedUpdates(); + + removeApprovalWorkflowRules(approvalWorkflow, policy); + await waitForBatchedUpdates(); + + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + expect(updatedPolicy?.rules?.approvalWorkflows?.rule1).toBeUndefined(); + + await mockFetch.resume(); + await waitForBatchedUpdates(); + }); + }); }); diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 132ac40c4683..135a7a7ce61e 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -3,18 +3,28 @@ import {convertToDisplayString} from '@libs/CurrencyUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import { + applyApprovalWorkflowRulesDiff, + buildApprovalWorkflowRules, calculateApprovers, + convertApprovalWorkflowRulesToWorkflows, convertApprovalWorkflowToPolicyEmployees, convertPolicyEmployeesToApprovalWorkflows, getApprovalLimitDescription, + getEligibleExistingBusinessBankAccounts, getOpenConnectedToPolicyBusinessBankAccounts, getOverLimitForwardsToDisplayName, + getRulesSubmitterToFirstApprover, mergeWorkflowMembersWithAvailableMembers, + reconcileApprovalWorkflowRulesForCreate, + reconcileApprovalWorkflowRulesForEdit, + reconcileApprovalWorkflowRulesForMembersChange, + reconcileApprovalWorkflowRulesForRemove, updateWorkflowDataOnApproverRemoval, } from '@src/libs/WorkflowUtils'; import type {Policy} from '@src/types/onyx'; import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; import type {BankAccountList} from '@src/types/onyx/BankAccount'; import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; import type {Connections} from '@src/types/onyx/Policy'; @@ -1627,4 +1637,288 @@ describe('WorkflowUtils', () => { expect(result).toEqual([openBusinessBankAccount, secondMatchingAccount]); }); }); + + describe('getEligibleExistingBusinessBankAccounts', () => { + const openBusinessBankAccount = { + bankCurrency: 'USD', + accountData: { + state: CONST.BANK_ACCOUNT.STATE.OPEN, + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + }, + }; + + it('should return an empty array when the bank account list is undefined', () => { + expect(getEligibleExistingBusinessBankAccounts(undefined, 'USD')).toEqual([]); + }); + + it('should return an empty array when the policy currency is undefined', () => { + expect(getEligibleExistingBusinessBankAccounts({'1': openBusinessBankAccount}, undefined)).toEqual([]); + }); + + it('should return open business accounts matching the policy currency', () => { + expect(getEligibleExistingBusinessBankAccounts({'1': openBusinessBankAccount}, 'USD')).toEqual([openBusinessBankAccount]); + }); + + it('should filter out accounts with a non-matching currency', () => { + const eurAccount = {...openBusinessBankAccount, bankCurrency: 'EUR'}; + expect(getEligibleExistingBusinessBankAccounts({'1': eurAccount}, 'USD')).toEqual([]); + }); + + it('should filter out non-business accounts', () => { + const personalAccount = {...openBusinessBankAccount, accountData: {...openBusinessBankAccount.accountData, type: CONST.BANK_ACCOUNT.TYPE.PERSONAL}}; + expect(getEligibleExistingBusinessBankAccounts({'1': personalAccount}, 'USD')).toEqual([]); + }); + + it('should exclude partially set up accounts by default but include them when requested', () => { + const setupAccount = {...openBusinessBankAccount, accountData: {...openBusinessBankAccount.accountData, state: CONST.BANK_ACCOUNT.STATE.SETUP}}; + + expect(getEligibleExistingBusinessBankAccounts({'1': setupAccount}, 'USD')).toEqual([]); + expect(getEligibleExistingBusinessBankAccounts({'1': setupAccount}, 'USD', true)).toEqual([setupAccount]); + }); + }); + + describe('rule-based approval workflows', () => { + const FORWARD_ACTION = 'forward' as const; + const buildFromFilter = (emails: string[]) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: emails}); + const buildPreviousApproverFilter = (email: string) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER, right: email}); + const keyRules = (rules: ApprovalWorkflowRule[]): Record => Object.fromEntries(rules.map((rule, index) => [`rule${index}`, rule])); + + describe('buildApprovalWorkflowRules', () => { + it('Should return an empty list when there are no members', () => { + expect(buildApprovalWorkflowRules(buildWorkflow([], [1]))).toEqual([]); + }); + + it('Should return an empty list when there are no approvers', () => { + expect(buildApprovalWorkflowRules(buildWorkflow([2], []))).toEqual([]); + }); + + it('Should build a single `from` rule for a one-approver workflow', () => { + expect(buildApprovalWorkflowRules(buildWorkflow([2], [1]))).toEqual([{filters: buildFromFilter(['2@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}]); + }); + + it('Should chain approvers with `previousApprover` gates', () => { + expect(buildApprovalWorkflowRules(buildWorkflow([3], [1, 2]))).toEqual([ + {filters: buildFromFilter(['3@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + { + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['3@example.com']), right: buildPreviousApproverFilter('1@example.com')}, + action: FORWARD_ACTION, + nextReceiver: '2@example.com', + }, + ]); + }); + + it('Should split a level into under/over amount rules when the approver has a limit split', () => { + const workflow: ApprovalWorkflow = { + members: [buildMember(2)], + approvers: [buildApprover(1, {approvalLimit: 50000, overLimitForwardsTo: '3@example.com'})], + isDefault: false, + }; + const underAmountFilter = {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 50000}; + const overAmountFilter = {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 50000}; + + expect(buildApprovalWorkflowRules(workflow)).toEqual([ + {filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['2@example.com']), right: underAmountFilter}, action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + {filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['2@example.com']), right: overAmountFilter}, action: FORWARD_ACTION, nextReceiver: '3@example.com'}, + ]); + }); + + it('Should ignore a limit split when the limit is not positive', () => { + const workflow: ApprovalWorkflow = { + members: [buildMember(2)], + approvers: [buildApprover(1, {approvalLimit: 0, overLimitForwardsTo: '3@example.com'})], + isDefault: false, + }; + + expect(buildApprovalWorkflowRules(workflow)).toEqual([{filters: buildFromFilter(['2@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}]); + }); + }); + + describe('applyApprovalWorkflowRulesDiff', () => { + const ruleA: ApprovalWorkflowRule = {filters: buildFromFilter(['2@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}; + const ruleB: ApprovalWorkflowRule = {filters: buildFromFilter(['3@example.com']), action: FORWARD_ACTION, nextReceiver: '4@example.com'}; + + it('Should upsert non-null entries and delete null entries', () => { + expect(applyApprovalWorkflowRulesDiff({a: ruleA}, {a: null, b: ruleB})).toEqual({b: ruleB}); + }); + + it('Should not mutate the input map', () => { + const existingRules = {a: ruleA}; + applyApprovalWorkflowRulesDiff(existingRules, {a: null}); + expect(existingRules).toEqual({a: ruleA}); + }); + }); + + describe('reconcileApprovalWorkflowRulesForCreate', () => { + it('Should create fresh rules when nothing matches', () => { + const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); + const diff = reconcileApprovalWorkflowRulesForCreate(newRules, ['10@example.com'], {existingRules: {}}); + + expect(Object.values(diff)).toEqual(newRules); + }); + + it('Should merge members into a structurally identical existing rule', () => { + const existingRules: Record = { + existing1: {filters: buildFromFilter(['20@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }; + const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); + const diff = reconcileApprovalWorkflowRulesForCreate(newRules, ['10@example.com'], {existingRules}); + + expect(diff).toEqual({existing1: {filters: buildFromFilter(['20@example.com', '10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}}); + }); + }); + + describe('reconcileApprovalWorkflowRulesForEdit', () => { + it('Should drop the old rule and create the new chain when the approver changes', () => { + const existingRules: Record = { + r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }; + const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [2])); + const diff = reconcileApprovalWorkflowRulesForEdit(newRules, ['10@example.com'], {existingRules}); + + expect(diff.r1).toBeNull(); + const createdRules = Object.entries(diff) + .filter(([ruleID]) => ruleID !== 'r1') + .map(([, rule]) => rule); + expect(createdRules).toEqual(newRules); + }); + + it('Should leave a structurally identical rule untouched', () => { + const existingRules: Record = { + r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }; + const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); + const diff = reconcileApprovalWorkflowRulesForEdit(newRules, ['10@example.com'], {existingRules}); + + expect(diff).toEqual({}); + }); + }); + + describe('reconcileApprovalWorkflowRulesForRemove', () => { + it('Should remove a rule that only contains the workflow members', () => { + const existingRules: Record = { + r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }; + + expect(reconcileApprovalWorkflowRulesForRemove(['10@example.com'], {existingRules})).toEqual({r1: null}); + }); + + it('Should strip only the workflow members from a shared rule', () => { + const existingRules: Record = { + r1: {filters: buildFromFilter(['10@example.com', '20@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }; + + expect(reconcileApprovalWorkflowRulesForRemove(['10@example.com'], {existingRules})).toEqual({ + r1: {filters: buildFromFilter(['20@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }); + }); + + it('Should ignore rules that do not contain the members', () => { + const existingRules: Record = { + r1: {filters: buildFromFilter(['30@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }; + + expect(reconcileApprovalWorkflowRulesForRemove(['10@example.com'], {existingRules})).toEqual({}); + }); + }); + + describe('reconcileApprovalWorkflowRulesForMembersChange', () => { + it('Should add new members and drop removed ones from matching rules', () => { + const existingRules: Record = { + r1: {filters: buildFromFilter(['10@example.com', '20@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }; + const diff = reconcileApprovalWorkflowRulesForMembersChange(['10@example.com', '20@example.com'], ['20@example.com', '30@example.com'], {existingRules}); + + expect(diff).toEqual({r1: {filters: buildFromFilter(['20@example.com', '30@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}}); + }); + + it('Should skip rules with no net change to the submitter list', () => { + const existingRules: Record = { + r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }; + + expect(reconcileApprovalWorkflowRulesForMembersChange(['10@example.com'], ['10@example.com'], {existingRules})).toEqual({}); + }); + + it('Should remove a rule when all of its members are dropped', () => { + const existingRules: Record = { + r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, + }; + + expect(reconcileApprovalWorkflowRulesForMembersChange(['10@example.com'], [], {existingRules})).toEqual({r1: null}); + }); + }); + + describe('getRulesSubmitterToFirstApprover', () => { + it('Should map each submitter to the first approver in their chain', () => { + const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); + + expect(getRulesSubmitterToFirstApprover(rules)).toEqual({'5@example.com': '1@example.com'}); + }); + }); + + describe('convertApprovalWorkflowRulesToWorkflows', () => { + const createPolicyWithRules = (employees: PolicyEmployeeList, rules: Record, defaultApprover: string) => + ({ + id: 'test-policy', + name: 'Test Policy', + role: 'admin' as const, + type: 'team' as const, + owner: 'owner@example.com', + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + employeeList: employees, + approver: defaultApprover, + rules: {approvalWorkflows: rules}, + }) as Policy; + + it('Should reconstruct a multi-approver chain from rules', () => { + const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); + const employees: PolicyEmployeeList = {'5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}}; + const policy = createPolicyWithRules(employees, rules, '1@example.com'); + + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare}); + + expect(approvalWorkflows).toHaveLength(1); + const workflow = approvalWorkflows.at(0); + expect(workflow?.isDefault).toBe(true); + expect(workflow?.members.map((member) => member.email)).toEqual(['5@example.com']); + expect(workflow?.approvers.map((approver) => approver.email)).toEqual(['1@example.com', '2@example.com']); + expect(workflow?.approvers.at(0)?.forwardsTo).toBe('2@example.com'); + expect(workflow?.approvers.at(1)?.forwardsTo).toBeUndefined(); + }); + + it('Should reconstruct an approver limit split from rules', () => { + const workflow: ApprovalWorkflow = { + members: [buildMember(5)], + approvers: [buildApprover(1, {approvalLimit: 50000, overLimitForwardsTo: '3@example.com'})], + isDefault: false, + }; + const rules = keyRules(buildApprovalWorkflowRules(workflow)); + const employees: PolicyEmployeeList = {'5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}}; + const policy = createPolicyWithRules(employees, rules, '1@example.com'); + + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare}); + + const firstApprover = approvalWorkflows.at(0)?.approvers.at(0); + expect(firstApprover?.email).toBe('1@example.com'); + expect(firstApprover?.approvalLimit).toBe(50000); + expect(firstApprover?.overLimitForwardsTo).toBe('3@example.com'); + }); + + it('Should round-trip a workflow through rules and back', () => { + const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); + const employees: PolicyEmployeeList = {'5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}}; + const policy = createPolicyWithRules(employees, rules, '1@example.com'); + + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare}); + + // Rebuilding rules from the reconstructed workflow should reproduce the original rule set. + const workflow = approvalWorkflows.at(0); + expect(workflow).toBeDefined(); + if (workflow) { + expect(buildApprovalWorkflowRules(workflow)).toEqual(Object.values(rules)); + } + }); + }); + }); }); From 53cbedf2759e4be48c4ba141f607c33fa54bf79b Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 21:21:05 +0200 Subject: [PATCH 044/466] prettier --- tests/unit/WorkflowUtilsTest.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 135a7a7ce61e..0b3c3912e55a 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1717,8 +1717,16 @@ describe('WorkflowUtils', () => { const overAmountFilter = {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 50000}; expect(buildApprovalWorkflowRules(workflow)).toEqual([ - {filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['2@example.com']), right: underAmountFilter}, action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - {filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['2@example.com']), right: overAmountFilter}, action: FORWARD_ACTION, nextReceiver: '3@example.com'}, + { + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['2@example.com']), right: underAmountFilter}, + action: FORWARD_ACTION, + nextReceiver: '1@example.com', + }, + { + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['2@example.com']), right: overAmountFilter}, + action: FORWARD_ACTION, + nextReceiver: '3@example.com', + }, ]); }); From a05bc37eca1405bf0598a3042e02ac7d6393322d Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 29 Jun 2026 21:30:49 +0200 Subject: [PATCH 045/466] type --- tests/unit/WorkflowUtilsTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 0b3c3912e55a..d43e4b37e3e0 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1641,6 +1641,7 @@ describe('WorkflowUtils', () => { describe('getEligibleExistingBusinessBankAccounts', () => { const openBusinessBankAccount = { bankCurrency: 'USD', + bankCountry: 'US', accountData: { state: CONST.BANK_ACCOUNT.STATE.OPEN, type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, From 72cdcaebe22acc2520e31b6cb67e01cb4c6402e5 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 30 Jun 2026 00:01:45 +0200 Subject: [PATCH 046/466] types --- tests/actions/WorkflowTest.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 3e8f93edf4df..3154a3903e9c 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -852,14 +852,12 @@ describe('actions/Workflow', () => { mockFetch.pause(); const policyID = '123456789'; - const policy = { + const policy: Policy = { + ...createRandomPolicy(1), id: policyID, - name: 'Test Workspace', - role: 'admin', - type: 'corporate', owner: ownerEmail, rules: {}, - } as unknown as Policy; + }; const approvalWorkflow = { members: [{email: employee1Email, displayName: employee1Email}], @@ -894,11 +892,9 @@ describe('actions/Workflow', () => { mockFetch.pause(); const policyID = '123456789'; - const policy = { + const policy: Policy = { + ...createRandomPolicy(1), id: policyID, - name: 'Test Workspace', - role: 'admin', - type: 'corporate', owner: ownerEmail, rules: { approvalWorkflows: { @@ -909,7 +905,7 @@ describe('actions/Workflow', () => { }, }, }, - } as unknown as Policy; + }; const initialApprovalWorkflow = { members: [{email: employee1Email, displayName: employee1Email}], @@ -949,11 +945,9 @@ describe('actions/Workflow', () => { mockFetch.pause(); const policyID = '123456789'; - const policy = { + const policy: Policy = { + ...createRandomPolicy(1), id: policyID, - name: 'Test Workspace', - role: 'admin', - type: 'corporate', owner: ownerEmail, rules: { approvalWorkflows: { @@ -964,7 +958,7 @@ describe('actions/Workflow', () => { }, }, }, - } as unknown as Policy; + }; const approvalWorkflow = { members: [{email: employee1Email, displayName: employee1Email}], From c9c2329ae78415a4a45d5c0032797f0866272839 Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Sun, 5 Jul 2026 03:54:57 +0500 Subject: [PATCH 047/466] fixed ci/cd --- tests/perf-test/OptionsListUtils.perf-test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 51844d68ae50..4d5cc1997b49 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -304,8 +304,8 @@ describe('OptionsListUtils', () => { // on the PR branch RegExps are compiled once per call, so duration drops significantly. test('[OptionsListUtils] filterAndOrderOptions with multi-word search on large dataset', async () => { const largePersonalDetails = getMockedPersonalDetails(LARGE_PERSONAL_DETAILS_COUNT); - const largeReports = getMockedReports(LARGE_REPORTS_COUNT); - const largeOptionList = createOptionList(largePersonalDetails, EMPTY_PRIVATE_IS_ARCHIVED_MAP, largeReports, undefined); + const largeReports = getMockedReports(LARGE_REPORTS_COUNT) as Record<`${typeof ONYXKEYS.COLLECTION.REPORT}`, Report>; + const largeOptionList = createFilteredOptionList(largePersonalDetails, largeReports, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined); const {options: formattedOptions} = getValidOptions( {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, From 5ee795203e00e65cf6e28b862676516a357fc072 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 8 Jul 2026 17:49:39 +0200 Subject: [PATCH 048/466] rename --- src/components/AddressForm.tsx | 5 ++-- ...ance.ts => useDistanceHomeAddressCheck.ts} | 27 +++++++++---------- 2 files changed, 14 insertions(+), 18 deletions(-) rename src/hooks/{useHomeAddressGateForDistance.ts => useDistanceHomeAddressCheck.ts} (70%) diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 601db929f6a1..03a17f8f435a 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -69,9 +69,8 @@ type AddressFormProps = { enabledWhenOffline?: boolean; /** - * Whether to force the zip/postal code to be present. Defaults to off because most consumers - * treat it as optional, but workspace addresses backing the homeAndOffice commuter exclusions - * method require a complete address so the distance can be computed reliably. + * Whether to force the zip/postal code to be present. Workspace addresses for homeAndOffice commuter exclusions + * require a complete address */ shouldRequireZip?: boolean; diff --git a/src/hooks/useHomeAddressGateForDistance.ts b/src/hooks/useDistanceHomeAddressCheck.ts similarity index 70% rename from src/hooks/useHomeAddressGateForDistance.ts rename to src/hooks/useDistanceHomeAddressCheck.ts index 90924b9d660f..f8990c7387fa 100644 --- a/src/hooks/useHomeAddressGateForDistance.ts +++ b/src/hooks/useDistanceHomeAddressCheck.ts @@ -1,39 +1,36 @@ -import {useCallback} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; import {ModalActions} from '@components/Modal/Global/ModalContext'; + import Navigation from '@libs/Navigation/Navigation'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import type Policy from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useCallback} from 'react'; + import useConfirmModal from './useConfirmModal'; import {useMemoizedLazyIllustrations} from './useLazyAsset'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; -type UseHomeAddressGateForDistanceResult = { +type UseDistanceHomeAddressCheckResult = { /** * True when the destination workspace uses homeAndOffice commuter exclusions but the current - * user has no saved home address. Callers should bail (and call `promptForHomeAddress`) before - * starting any distance request submission while this is true. + * user has no saved home address. */ needsHomeAddressPrompt: boolean; /** - * Show the blocking "Home address is required" modal. Confirming sends the user to the address - * section of their private personal details; the X dismiss just closes the modal. + * Show the blocking "Home address is required" modal. */ promptForHomeAddress: () => void; }; -/** - * Shared gate for distance-request flows: when the destination workspace uses the homeAndOffice - * commuter-exclusion method the per-member commute can't be computed without a saved home address. - * Used by every entry point that submits a distance request (start screen, confirmation step, - * quick actions) so dismissing the proactive modal can't slip through to an actual API call. - */ -function useHomeAddressGateForDistance(policy: OnyxEntry): UseHomeAddressGateForDistanceResult { +function useDistanceHomeAddressCheck(policy: OnyxEntry): UseDistanceHomeAddressCheckResult { const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const illustrations = useMemoizedLazyIllustrations(['House']); @@ -62,4 +59,4 @@ function useHomeAddressGateForDistance(policy: OnyxEntry): UseHomeAddres return {needsHomeAddressPrompt, promptForHomeAddress}; } -export default useHomeAddressGateForDistance; +export default useDistanceHomeAddressCheck; From 010f7fd2f7774715013960d81631c2a99ca58dc6 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 8 Jul 2026 17:49:54 +0200 Subject: [PATCH 049/466] cleanup comments --- .../hooks/useTimeSensitiveHomeAddress.ts | 4 ---- src/pages/iou/request/step/IOURequestStepDistance.tsx | 9 +-------- .../request/step/confirmation/useExpenseSubmission.ts | 10 ++-------- src/pages/settings/Profile/ProfilePage.tsx | 4 ---- src/pages/workspace/WorkspaceOverviewAddressPage.tsx | 5 +---- .../distanceRates/PolicyCommuterExclusionsPage.tsx | 8 -------- src/types/onyx/OriginalMessage.ts | 5 +---- 7 files changed, 5 insertions(+), 40 deletions(-) diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts index c1b0a6cc5538..3288492163ce 100644 --- a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts @@ -6,10 +6,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; * Surfaces the "Add a home address" item under Home > Time sensitive when the user belongs to at * least one active workspace that has the homeAndOffice commuter-exclusion method enabled but has * no home address recorded in their private personal details. - * - * Subscribes to the raw COLLECTION.POLICY (no selector) so every nested change to any policy - * - including switching method to fixedDistance or removing commuterExclusions entirely - is - * picked up reliably. Matches the pattern of the other useTimeSensitive* hooks in this folder. */ function useTimeSensitiveHomeAddress() { const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 4b84e0cd7105..82ed33dff4e9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -289,9 +289,7 @@ function IOURequestStepDistance({ }, []); // When the destination workspace requires home/office commuter exclusions, the per-member - // commute deduction can't be computed unless the member has a home address. Surface a - // proactive modal as soon as the policy is known; submission-time gating lives in - // useExpenseSubmission so it covers every distance entry point. + // commute deduction can't be computed unless the member has a home address. const {needsHomeAddressPrompt, promptForHomeAddress} = useHomeAddressGateForDistance(policy); const hasShownHomeAddressModalRef = useRef(false); useEffect(() => { @@ -466,9 +464,6 @@ function IOURequestStepDistance({ ); const submitWaypoints = useCallback(() => { - // Hard block: if the destination workspace requires home/office commuter exclusions and the - // member has no home address, the per-member commute can't be computed - re-show the modal - // instead of letting them save. if (needsHomeAddressPrompt) { promptForHomeAddress(); return; @@ -575,8 +570,6 @@ function IOURequestStepDistance({ ]); const submitManualDistance = useCallback(() => { - // Same home-address guard as submitWaypoints - block manual saves too in case the user - // dismissed the proactive modal and switched tabs. if (needsHomeAddressPrompt) { promptForHomeAddress(); return; diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 718657f756b9..5ef92b365074 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -297,11 +297,8 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const transactionIDs = transactions?.map((tx) => tx.transactionID); const [storedTransactions] = useTransactionsByID(transactionIDs); - // Centralised gate: any distance flow that submits through this hook is blocked while the - // user is missing a home address on a homeAndOffice workspace. The IOURequestStepDistance - // screen surfaces the same modal proactively when it mounts, but this catches every other - // entry point (e.g. quick actions, deep-links that skip the step) as well as the case where - // the user dismissed the proactive modal and went straight to confirmation. + // Any distance flow that submits through this hook is blocked while the user is missing a home address on a + // homeAndOffice workspace. const {needsHomeAddressPrompt: distanceNeedsHomeAddress, promptForHomeAddress: promptForDistanceHomeAddress} = useHomeAddressGateForDistance(policy); function performPostBatchCleanup({ @@ -794,9 +791,6 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } function createTransaction(locationPermissionGranted = false, shouldHandleNavigation = true) { - // Distance-request home-address gate: bail before flipping any submission state so a - // missing home address never produces a half-submitted distance expense. We re-show the - // blocking modal here even if the user dismissed the proactive one on the step screen. if (isDistanceRequest && distanceNeedsHomeAddress) { promptForDistanceHomeAddress(); return; diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index 44d160d12de5..a2dce06145bc 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -90,10 +90,6 @@ function ProfilePage() { const emojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; const privateDetails = privatePersonalDetails ?? {}; const legalName = `${privateDetails.legalFirstName ?? ''} ${privateDetails.legalLastName ?? ''}`.trim(); - - // Surface an info GBR on the Home address row when a workspace needs it for commuter exclusions - // and the member hasn't set one yet. The hook tracks both conditions and auto-clears when either - // becomes false (workspace turns off homeAndOffice, or the user saves an address with a street). const {shouldShowAddHomeAddress: shouldShowAddHomeAddressGBR} = useTimeSensitiveHomeAddress(); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); diff --git a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx index 3d7d2dabdb3e..2d04432ef4d9 100644 --- a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx @@ -60,10 +60,7 @@ function WorkspaceOverviewAddressPage({policy}: WorkspaceOverviewAddressPageProp }); Navigation.goBack(backPath); }; - - // When the policy uses homeAndOffice commuter exclusions, the workspace address backs the - // per-member commute calculation. A partial/invalid address would silently break that, so - // promote the zip code to a required field on top of the form's standard required set. + const requiresFullAddressForExclusions = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; return ( diff --git a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx index d34a47bb4eaf..4854deee4f70 100644 --- a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx @@ -89,8 +89,6 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps }; const goToWorkspaceOverview = () => { - // Dismiss the modal first so the user lands on the overview page with a clean stack instead - // of having the confirm modal animate away on top of the navigation. closeModal(); Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW.getRoute(policyID)); }; @@ -99,10 +97,6 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps if (item.keyForList === selectedKey) { return; } - - // homeAndOffice depends on the workspace's own address - if it's missing, block the change - // and point the admin to the Overview page via an inline link. The chosen option is not - // stored locally until they actually have a workspace address. if (item.keyForList === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !policyData?.hasWorkspaceAddress) { showConfirmModal({ title: translate('workspace.distanceRates.commuterExclusions.workspaceAddressRequired.title'), @@ -133,8 +127,6 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps } if (selectedKey === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE) { - // Defensive guard: the row selector already blocks this path when the workspace has no - // address, but a manual save (e.g. via keyboard) shouldn't bypass the requirement. if (!policyData?.hasWorkspaceAddress) { return; } diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index e02f606e1254..697ad669821d 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1627,10 +1627,7 @@ type IssueNewCardOriginalMessage = OriginalMessage< >; /** - * Model of a HOME_ADDRESS_REQUIRED Concierge report action. Posted by Auth's - * SetPolicyCommuterExclusions command for members missing a home address when their workspace - * switches to the homeAndOffice commuter-exclusion method. The App renders a custom "Add address" - * CTA for this action and uses `policyID` to deep-link the user to the workspace that triggered it. + * Model of a HOME_ADDRESS_REQUIRED Concierge report action. */ type OriginalMessageHomeAddressRequired = { /** ID of the policy whose commuter-exclusion change triggered the prompt */ From bdd12bb2a5b94b9df36c691929862739c9618f29 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 8 Jul 2026 17:56:45 +0200 Subject: [PATCH 050/466] parrot --- src/languages/de.ts | 48 +++++++++++++++++-------------------- src/languages/es.ts | 36 +++++++++++++--------------- src/languages/fr.ts | 46 ++++++++++++++++------------------- src/languages/it.ts | 46 ++++++++++++++++------------------- src/languages/ja.ts | 52 +++++++++++++++++----------------------- src/languages/nl.ts | 44 ++++++++++++++++------------------ src/languages/pl.ts | 46 ++++++++++++++++------------------- src/languages/pt-BR.ts | 46 ++++++++++++++++------------------- src/languages/zh-hans.ts | 50 ++++++++++++++++---------------------- 9 files changed, 185 insertions(+), 229 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 778bfd1e44fb..a63abb145f99 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1,13 +1,3 @@ -import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; -import StringUtils from '@libs/StringUtils'; - -import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; -import type OriginalMessage from '@src/types/onyx/OriginalMessage'; -import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; - -import type {ValueOf} from 'type-fest'; - /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / @@ -19,6 +9,16 @@ import type {ValueOf} from 'type-fest'; * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ +import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; +import StringUtils from '@libs/StringUtils'; + +import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; + +import type {ValueOf} from 'type-fest'; + import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; @@ -63,7 +63,6 @@ import type { YourPlanPriceParams, } from './params'; import type {TranslationDeepObject} from './types'; - type StateValue = { stateISO: string; stateName: string; @@ -983,8 +982,8 @@ const translations: TranslationDeepObject = { }, validateAccount: {title: 'Bestätigen Sie Ihr Konto', subtitle: 'Konto', cta: 'Bestätigen'}, addHomeAddress: { - title: 'Heimadresse hinzufügen', - subtitle: 'Dein Administrator hat Pendlerausschlüsse für Distanzausgaben aktiviert. Füge eine Heimadresse hinzu, damit wir sie auf deine Abrechnungen anwenden können.', + title: 'Fügen Sie eine Privatadresse hinzu', + subtitle: 'Ihre Verwaltung hat Pendlerausnahmen für Entfernungskosten aktiviert. Fügen Sie eine Privatadresse hinzu, damit wir diese auf Ihre Abrechnungen anwenden können.', cta: 'Adresse hinzufügen', }, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, @@ -1228,12 +1227,12 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Heimadresse erforderlich', + title: 'Privatadresse ist erforderlich', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Bevor du Distanz erfassen kannst, musst du deine Heimadresse zu deinem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` - : 'Bevor du Distanz erfassen kannst, musst du deine Heimadresse zu deinem privaten Profil hinzufügen. Dieser Arbeitsbereich verwendet diese Adresse für Pendlerabzüge.', - cta: 'Heimadresse hinzufügen', + ? `Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` + : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. Dieser Workspace verwendet diese Adresse für Pendlerabzüge.', + cta: 'Privatadresse hinzufügen', }, amount: 'Betrag', percent: 'Prozent', @@ -6867,24 +6866,21 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU summaryDisabled: 'Kein Ausschluss von Pendelstrecken', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `${distance} ${unit} pro Abrechnung ausschließen`, optionDisabledTitle: 'Pendelfahrten nicht ausschließen', - optionDisabledHelp: 'Keine Pendelstrecke wird aus Abrechnungen entfernt.', + optionDisabledHelp: 'Keine Pendelfahrt wird von den Abrechnungen entfernt.', optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', distanceLabel: 'Entfernung', - summaryHomeAndOffice: 'Heim- und Bürostandorte verwenden', + summaryHomeAndOffice: 'Standort für Zuhause und Büro verwenden', optionHomeAndOfficeTitle: 'Nach Zuhause und Büro berechnen', - optionHomeAndOfficeHelp: 'Verwende die Heimadresse, das Arbeitsmodell und die Bürozuweisung des Mitglieds, um Pendelausschlüsse zu berechnen.', + optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuordnung des Mitglieds, um Fahrtkostenausschlüsse zu berechnen.', workspaceAddressRequired: { - title: 'Nicht so schnell...', - promptStart: 'Du kannst die Berechnung nach Zuhause und Büro erst aktivieren, wenn du in ', + title: 'Nur langsam …', + promptStart: 'Sie können die Einstellung „Nach Zuhause und Büro berechnen“ nicht aktivieren, bevor Sie nicht zuerst einen Bürostandort in hinzugefügt haben', linkText: 'Übersicht', promptEnd: ' eine Büroadresse hinzugefügt hast.', cta: 'Verstanden', }, - errors: { - distanceMustBePositive: 'Die Entfernung muss eine positive ganze Zahl sein.', - invalidAddress: 'Bitte gib eine gültige Adresse ein', - }, + errors: {distanceMustBePositive: 'Die Entfernung muss eine positive ganze Zahl sein.', invalidAddress: 'Bitte geben Sie eine gültige Adresse ein'}, }, distance: 'Entfernung', centrallyManage: 'Tarife zentral verwalten, in Meilen oder Kilometern nachverfolgen und eine Standardkategorie festlegen.', diff --git a/src/languages/es.ts b/src/languages/es.ts index b0aa84b58b23..e8eea70c1fb9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1,6 +1,3 @@ -import CONST from '@src/CONST'; -import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; - /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / @@ -12,12 +9,14 @@ import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ +import CONST from '@src/CONST'; +import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; + import {CONST as COMMON_CONST, Str} from 'expensify-common'; import type en from './en'; import type {ConciergeBrokenCardConnectionParams, PaidElsewhereParams, RemoveCopilotAccessConfirmationParams, UnsupportedFormulaValueErrorParams} from './params'; import type {TranslationDeepObject} from './types'; - const translations: TranslationDeepObject = { common: { count: 'Contar', @@ -942,10 +941,10 @@ const translations: TranslationDeepObject = { cta: 'Validar', }, addHomeAddress: { - title: 'Añade una dirección de casa', + title: 'Añade una dirección de domicilio', subtitle: - 'Tu administrador habilitó exclusiones de desplazamientos para los gastos por distancia. Añade una dirección de casa para que podamos aplicarlas a tus reclamaciones.', - cta: 'Añadir dirección', + 'Tu administrador habilitó exclusiones de desplazamientos para los gastos de distancia. Añade una dirección de casa para que podamos aplicarlas a tus reclamaciones.', + cta: 'Añade dirección', }, fixFailedBilling: { title: 'No pudimos cobrar a la tarjeta registrada.', @@ -1188,11 +1187,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Se requiere la dirección de casa', + title: 'La dirección particular es obligatoria', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Antes de registrar distancia, debes añadir tu dirección de casa a tu perfil privado. ${workspaceName} usa esta dirección para las deducciones de desplazamientos.` - : 'Antes de registrar distancia, debes añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo usa esta dirección para las deducciones de desplazamientos.', + ? `Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. ${workspaceName} utiliza esta dirección para las deducciones por desplazamiento al trabajo.` + : 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo utiliza esta dirección para las deducciones por desplazamiento.', cta: 'Añadir dirección de casa', }, amount: 'Importe', @@ -6781,25 +6780,22 @@ ${amount} para ${merchant} - ${date}`, title: 'Excluir desplazamientos al trabajo', summaryDisabled: 'Sin exclusión por desplazamiento al trabajo', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, - summaryHomeAndOffice: 'Usar las ubicaciones de casa y oficina', + summaryHomeAndOffice: 'Usar ubicaciones de casa y oficina', optionDisabledTitle: 'No excluir los desplazamientos al trabajo', - optionDisabledHelp: 'No se elimina ningún desplazamiento de las reclamaciones.', + optionDisabledHelp: 'Ningún desplazamiento se elimina de las solicitudes.', optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', optionFixedDistanceHelp: 'Resta la misma distancia de desplazamiento de cada solicitud. Ideal para personas que envían una solicitud por día laborable.', optionHomeAndOfficeTitle: 'Calcular por casa y oficina', - optionHomeAndOfficeHelp: 'Usa la dirección de casa, el arreglo de trabajo y la asignación de oficina del miembro para calcular las exclusiones de desplazamientos.', + optionHomeAndOfficeHelp: 'Utiliza la dirección de casa del miembro, su modalidad de trabajo y la asignación de oficina para calcular las exclusiones de desplazamiento.', distanceLabel: 'Distancia', workspaceAddressRequired: { - title: 'Espera un momento...', - promptStart: 'No puedes activar el cálculo por casa y oficina hasta que añadas una dirección de oficina en ', - linkText: 'Vista general', + title: 'No tan rápido...', + promptStart: 'No puedes habilitar el ajuste de calcular por casa y oficina hasta que primero añadas una ubicación de oficina en', + linkText: 'Resumen', promptEnd: '.', cta: 'Entendido', }, - errors: { - distanceMustBePositive: 'La distancia debe ser un número entero positivo.', - invalidAddress: 'Introduce una dirección válida', - }, + errors: {distanceMustBePositive: 'La distancia debe ser un número entero positivo.', invalidAddress: 'Por favor, introduce una dirección válida'}, }, distance: 'Distancia', centrallyManage: 'Gestiona centralizadamente las tasas, elige si contabilizar en millas o kilómetros, y define una categoría por defecto', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 45f616fa60a9..808aeb51a762 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1,13 +1,3 @@ -import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; -import StringUtils from '@libs/StringUtils'; - -import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; -import type OriginalMessage from '@src/types/onyx/OriginalMessage'; -import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; - -import type {ValueOf} from 'type-fest'; - /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / @@ -19,6 +9,16 @@ import type {ValueOf} from 'type-fest'; * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ +import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; +import StringUtils from '@libs/StringUtils'; + +import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; + +import type {ValueOf} from 'type-fest'; + import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; @@ -63,7 +63,6 @@ import type { YourPlanPriceParams, } from './params'; import type {TranslationDeepObject} from './types'; - type StateValue = { stateISO: string; stateName: string; @@ -988,7 +987,7 @@ const translations: TranslationDeepObject = { addHomeAddress: { title: 'Ajouter une adresse personnelle', subtitle: - 'Votre administrateur a activé les exclusions de trajet pour les dépenses de distance. Ajoutez une adresse personnelle pour que nous puissions les appliquer à vos réclamations.', + 'Votre administrateur a activé des exclusions de trajet domicile-travail pour les dépenses de distance. Ajoutez une adresse de domicile afin que nous puissions les appliquer à vos demandes.', cta: 'Ajouter une adresse', }, fixFailedBilling: {title: 'Nous n’avons pas pu débiter votre carte enregistrée', subtitle: 'Abonnement'}, @@ -1233,11 +1232,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Adresse personnelle requise', + title: 'L’adresse du domicile est obligatoire', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Avant de pouvoir suivre la distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions de trajet.` - : 'Avant de pouvoir suivre la distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions de trajet.', + ? `Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions de frais de déplacement domicile-travail.` + : 'Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions liées aux trajets domicile-travail.', cta: 'Ajouter une adresse personnelle', }, amount: 'Montant', @@ -6894,24 +6893,21 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. summaryDisabled: 'Pas d’exclusion du trajet domicile-travail', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclure ${distance} ${unit} par demande`, optionDisabledTitle: 'Ne pas exclure les trajets domicile-travail', - optionDisabledHelp: 'Aucun trajet n’est retiré des réclamations.', + optionDisabledHelp: 'Aucun trajet domicile-travail n’est supprimé des déclarations.', optionFixedDistanceTitle: 'Exclure une distance fixe par demande', optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', distanceLabel: 'Distance', - summaryHomeAndOffice: 'Utiliser les emplacements du domicile et du bureau', + summaryHomeAndOffice: 'Utiliser les emplacements domicile et bureau', optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', - optionHomeAndOfficeHelp: 'Utilise l’adresse personnelle, le mode de travail et l’affectation au bureau du membre pour calculer les exclusions de trajet.', + optionHomeAndOfficeHelp: 'Utilise l’adresse du domicile du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet domicile-travail.', workspaceAddressRequired: { - title: 'Pas si vite...', - promptStart: 'Vous ne pouvez pas activer le calcul par domicile et bureau tant que vous n’avez pas ajouté une adresse de bureau dans ', - linkText: 'Vue d’ensemble', + title: 'Pas si vite…', + promptStart: 'Vous ne pouvez pas activer le paramètre de calcul domicile-bureau avant d’ajouter d’abord un lieu de bureau dans', + linkText: 'Aperçu', promptEnd: '.', cta: 'Compris', }, - errors: { - distanceMustBePositive: 'La distance doit être un nombre entier positif.', - invalidAddress: 'Veuillez saisir une adresse valide', - }, + errors: {distanceMustBePositive: 'La distance doit être un nombre entier positif.', invalidAddress: 'Veuillez saisir une adresse valide'}, }, distance: 'Distance', centrallyManage: 'Gérez les taux de manière centralisée, suivez en miles ou en kilomètres et définissez une catégorie par défaut.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 5297cb2cf776..c47e3811d1d4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1,13 +1,3 @@ -import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; -import StringUtils from '@libs/StringUtils'; - -import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; -import type OriginalMessage from '@src/types/onyx/OriginalMessage'; -import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; - -import type {ValueOf} from 'type-fest'; - /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / @@ -19,6 +9,16 @@ import type {ValueOf} from 'type-fest'; * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ +import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; +import StringUtils from '@libs/StringUtils'; + +import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; + +import type {ValueOf} from 'type-fest'; + import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; @@ -63,7 +63,6 @@ import type { YourPlanPriceParams, } from './params'; import type {TranslationDeepObject} from './types'; - type StateValue = { stateISO: string; stateName: string; @@ -986,7 +985,7 @@ const translations: TranslationDeepObject = { addHomeAddress: { title: 'Aggiungi un indirizzo di casa', subtitle: - 'Il tuo amministratore ha abilitato le esclusioni dei tragitti per le spese di distanza. Aggiungi un indirizzo di casa così possiamo applicarle alle tue richieste.', + 'Il tuo amministratore ha abilitato le esclusioni per i tragitti casa-lavoro nelle spese chilometriche. Aggiungi un indirizzo di casa così potremo applicarle alle tue richieste di rimborso.', cta: 'Aggiungi indirizzo', }, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, @@ -1229,11 +1228,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'È necessario un indirizzo di casa', + title: "L'indirizzo di casa è obbligatorio", prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Prima di registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le deduzioni dei tragitti.` - : 'Prima di registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Quest’area di lavoro usa questo indirizzo per le deduzioni dei tragitti.', + ? `Prima di tracciare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le detrazioni per i pendolari.` + : 'Prima di tracciare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro usa questo indirizzo per le detrazioni per pendolari.', cta: 'Aggiungi indirizzo di casa', }, amount: 'Importo', @@ -6853,24 +6852,21 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. summaryDisabled: 'Nessuna esclusione per il tragitto', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Escludi ${distance} ${unit} per rimborso`, optionDisabledTitle: 'Non escludere gli spostamenti casa-lavoro', - optionDisabledHelp: 'Nessun tragitto viene rimosso dalle richieste.', + optionDisabledHelp: 'Nessuno spostamento casa-lavoro viene rimosso dalle richieste.', optionFixedDistanceTitle: 'Escludi una distanza fissa per richiesta', optionFixedDistanceHelp: 'Detrai la stessa distanza del tragitto casa-lavoro da ogni richiesta. Ideale per chi invia una richiesta per ogni giorno lavorativo.', distanceLabel: 'Distanza', - summaryHomeAndOffice: 'Usa le località di casa e ufficio', - optionHomeAndOfficeTitle: 'Calcola in base a casa e ufficio', - optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa, la modalità di lavoro e l’assegnazione dell’ufficio del membro per calcolare le esclusioni dei tragitti.', + summaryHomeAndOffice: 'Usa le posizioni di casa e ufficio', + optionHomeAndOfficeTitle: 'Calcola per casa e ufficio', + optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, la modalità di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni sul tragitto casa-lavoro.', workspaceAddressRequired: { title: 'Non così in fretta...', - promptStart: 'Non puoi attivare il calcolo in base a casa e ufficio finché non aggiungi un indirizzo dell’ufficio in ', + promptStart: "Non puoi abilitare l'impostazione per calcolare in base a casa e ufficio finché non aggiungi prima una sede dell'ufficio in", linkText: 'Panoramica', promptEnd: '.', - cta: 'Ho capito', - }, - errors: { - distanceMustBePositive: 'La distanza deve essere un numero intero positivo.', - invalidAddress: 'Inserisci un indirizzo valido', + cta: 'Capito', }, + errors: {distanceMustBePositive: 'La distanza deve essere un numero intero positivo.', invalidAddress: 'Inserisci un indirizzo valido'}, }, distance: 'Distanza', centrallyManage: 'Gestisci centralmente le tariffe, monitora in miglia o chilometri e imposta una categoria predefinita.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 92aeea1a4750..dc3a6f1e002a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1,13 +1,3 @@ -import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; -import StringUtils from '@libs/StringUtils'; - -import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; -import type OriginalMessage from '@src/types/onyx/OriginalMessage'; -import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; - -import type {ValueOf} from 'type-fest'; - /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / @@ -19,6 +9,16 @@ import type {ValueOf} from 'type-fest'; * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ +import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; +import StringUtils from '@libs/StringUtils'; + +import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; + +import type {ValueOf} from 'type-fest'; + import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; @@ -63,7 +63,6 @@ import type { YourPlanPriceParams, } from './params'; import type {TranslationDeepObject} from './types'; - type StateValue = { stateISO: string; stateName: string; @@ -969,11 +968,7 @@ const translations: TranslationDeepObject = { }, fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, validateAccount: {title: 'アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, - addHomeAddress: { - title: '自宅住所を追加', - subtitle: '管理者が距離経費の通勤控除を有効にしました。請求に適用できるよう自宅住所を追加してください。', - cta: '住所を追加', - }, + addHomeAddress: {title: '自宅住所を追加', subtitle: '管理者が距離経費に通勤分の除外を有効にしました。精算に適用できるよう、自宅住所を追加してください。', cta: '住所を追加'}, fixFailedBilling: {title: '登録されているカードから請求できませんでした', subtitle: 'サブスクリプション'}, unlockBankAccount: { workspaceTitle: 'ビジネス用銀行口座がロックされました', @@ -1210,11 +1205,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: '自宅住所が必要です', + title: '自宅住所は必須です', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `距離を記録するには、まずプライベートプロフィールに自宅住所を追加してください。${workspaceName} はこの住所を通勤控除に使用します。` - : '距離を記録するには、まずプライベートプロフィールに自宅住所を追加してください。このワークスペースはこの住所を通勤控除に使用します。', + ? `距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除のためにこの住所を使用します。` + : '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースは通勤控除のためにこの住所を使用します。', cta: '自宅住所を追加', }, amount: '金額', @@ -6772,24 +6767,21 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO summaryDisabled: '通勤除外なし', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `申請ごとに ${distance} ${unit} を除外します`, optionDisabledTitle: '通勤を除外しない', - optionDisabledHelp: '請求から通勤距離は差し引かれません。', + optionDisabledHelp: '通勤分は申請から差し引かれていません。', optionFixedDistanceTitle: '申請ごとに一定距離を除外します', optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', distanceLabel: '距離', - summaryHomeAndOffice: '自宅とオフィスの位置を使用', - optionHomeAndOfficeTitle: '自宅とオフィスで計算', - optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィスの割り当てを使用して通勤控除を計算します。', + summaryHomeAndOffice: '自宅と勤務先の所在地を使用する', + optionHomeAndOfficeTitle: '自宅とオフィス別に計算', + optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、およびオフィスの配属情報を使用して、通勤の非課税対象額を算出します。', workspaceAddressRequired: { - title: 'ちょっと待って...', - promptStart: '自宅とオフィスで計算する設定を有効にするには、まず ', + title: 'ちょっとお待ちください…', + promptStart: '自宅とオフィスで計算する設定は、先にオフィス所在地を追加しないと有効にできません', linkText: '概要', promptEnd: ' でオフィスの住所を追加してください。', - cta: 'OK', - }, - errors: { - distanceMustBePositive: '距離は正の整数で入力してください。', - invalidAddress: '有効な住所を入力してください', + cta: '了解しました', }, + errors: {distanceMustBePositive: '距離は正の整数で入力してください。', invalidAddress: '有効な住所を入力してください'}, }, distance: '距離', centrallyManage: '料金を一元管理し、マイルまたはキロメートルで追跡し、デフォルトのカテゴリを設定できます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e291b0f20fae..0d9af784dee7 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1,13 +1,3 @@ -import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; -import StringUtils from '@libs/StringUtils'; - -import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; -import type OriginalMessage from '@src/types/onyx/OriginalMessage'; -import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; - -import type {ValueOf} from 'type-fest'; - /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / @@ -19,6 +9,16 @@ import type {ValueOf} from 'type-fest'; * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ +import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; +import StringUtils from '@libs/StringUtils'; + +import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; + +import type {ValueOf} from 'type-fest'; + import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; @@ -63,7 +63,6 @@ import type { YourPlanPriceParams, } from './params'; import type {TranslationDeepObject} from './types'; - type StateValue = { stateISO: string; stateName: string; @@ -983,7 +982,7 @@ const translations: TranslationDeepObject = { validateAccount: {title: 'Valideer je account', subtitle: 'Account', cta: 'Valideren'}, addHomeAddress: { title: 'Voeg een huisadres toe', - subtitle: 'Je beheerder heeft woon-werkuitsluitingen voor afstandsuitgaven ingeschakeld. Voeg een huisadres toe zodat we deze op je claims kunnen toepassen.', + subtitle: 'Je beheerder heeft woon-werkuitzonderingen ingeschakeld voor afstandskosten. Voeg een huisadres toe zodat we die op je declaraties kunnen toepassen.', cta: 'Adres toevoegen', }, fixFailedBilling: {title: 'We konden je kaart in ons bestand niet belasten', subtitle: 'Abonnement'}, @@ -1229,9 +1228,9 @@ const translations: TranslationDeepObject = { title: 'Huisadres is vereist', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Voordat je afstand kunt registreren, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werkaftrekken.` - : 'Voordat je afstand kunt registreren, moet je je huisadres toevoegen aan je privéprofiel. Deze werkruimte gebruikt dit adres voor woon-werkaftrekken.', - cta: 'Huisadres toevoegen', + ? `Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werk­aftrekken.` + : 'Voordat je een afstand bijhoudt, moet je je huisadres toevoegen aan je privéprofiel. Deze workspace gebruikt dit adres voor woon-werkverkeer­aftrek.', + cta: 'Thuisadres toevoegen', }, amount: 'Bedrag', percent: 'Procent', @@ -6835,24 +6834,21 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ summaryDisabled: 'Geen woon-werkuitzondering', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Sluit ${distance} ${unit} per declaratie uit`, optionDisabledTitle: 'Woon-werkverkeer niet uitsluiten', - optionDisabledHelp: 'Geen woon-werkverkeer wordt uit claims verwijderd.', + optionDisabledHelp: 'Geen woon-werkverkeer is uit declaraties verwijderd.', optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', distanceLabel: 'Afstand', - summaryHomeAndOffice: 'Gebruik thuis- en kantoorlocaties', - optionHomeAndOfficeTitle: 'Berekenen via huis en kantoor', - optionHomeAndOfficeHelp: 'Gebruik het huisadres, de werkregeling en de kantoorindeling van het lid om woon-werkuitsluitingen te berekenen.', + summaryHomeAndOffice: 'Gebruik thuis- en kantoorlokaties', + optionHomeAndOfficeTitle: 'Bereken per huis en kantoor', + optionHomeAndOfficeHelp: 'Gebruik het thuisadres, de werkafspraak en de kantoorindeling van het lid om woon-werkuitzonderingen te berekenen.', workspaceAddressRequired: { title: 'Niet zo snel...', - promptStart: 'Je kunt de instelling Berekenen via huis en kantoor pas inschakelen nadat je een kantooradres hebt toegevoegd in ', + promptStart: 'Je kunt de instelling ‘berekenen op basis van thuis en kantoor’ pas inschakelen nadat je eerst een kantoorlocatie hebt toegevoegd in', linkText: 'Overzicht', promptEnd: '.', cta: 'Begrepen', }, - errors: { - distanceMustBePositive: 'Afstand moet een positief geheel getal zijn.', - invalidAddress: 'Voer een geldig adres in', - }, + errors: {distanceMustBePositive: 'Afstand moet een positief geheel getal zijn.', invalidAddress: 'Voer een geldig adres in'}, }, distance: 'Afstand', centrallyManage: 'Beheer tarieven centraal, volg in mijlen of kilometers en stel een standaardcategorie in.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 4fbf040dec65..7885e974f2ac 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1,13 +1,3 @@ -import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; -import StringUtils from '@libs/StringUtils'; - -import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; -import type OriginalMessage from '@src/types/onyx/OriginalMessage'; -import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; - -import type {ValueOf} from 'type-fest'; - /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / @@ -19,6 +9,16 @@ import type {ValueOf} from 'type-fest'; * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ +import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; +import StringUtils from '@libs/StringUtils'; + +import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; + +import type {ValueOf} from 'type-fest'; + import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; @@ -63,7 +63,6 @@ import type { YourPlanPriceParams, } from './params'; import type {TranslationDeepObject} from './types'; - type StateValue = { stateISO: string; stateName: string; @@ -985,7 +984,7 @@ const translations: TranslationDeepObject = { validateAccount: {title: 'Zweryfikuj swoje konto', subtitle: 'Konto', cta: 'Zatwierdź'}, addHomeAddress: { title: 'Dodaj adres domowy', - subtitle: 'Twój administrator włączył wykluczenia dojazdów dla wydatków za dystans. Dodaj adres domowy, abyśmy mogli je zastosować do twoich roszczeń.', + subtitle: 'Twój administrator włączył wyłączenia dojazdów dla wydatków odległościowych. Dodaj adres domowy, żebyśmy mogli zastosować je do twoich rozliczeń.', cta: 'Dodaj adres', }, fixFailedBilling: {title: 'Nie mogliśmy obciążyć zapisanej karty', subtitle: 'Subskrypcja'}, @@ -1222,11 +1221,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Wymagany adres domowy', + title: 'Adres domowy jest wymagany', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Zanim zaczniesz rejestrować dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń dojazdów.` - : 'Zanim zaczniesz rejestrować dystans, musisz dodać swój adres domowy do prywatnego profilu. Ta przestrzeń robocza używa tego adresu do odliczeń dojazdów.', + ? `Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy do pracy.` + : 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. To miejsce pracy wykorzystuje ten adres do odliczeń za dojazdy.', cta: 'Dodaj adres domowy', }, amount: 'Kwota', @@ -6818,24 +6817,21 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy summaryDisabled: 'Bez wykluczenia dojazdów', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Wyklucz ${distance} ${unit} na zgłoszenie`, optionDisabledTitle: 'Nie wykluczaj dojazdów', - optionDisabledHelp: 'Z roszczeń nie jest usuwany żaden dojazd.', + optionDisabledHelp: 'Nie usunięto żadnego dojazdu z rozliczeń.', optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', distanceLabel: 'Dystans', summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', - optionHomeAndOfficeTitle: 'Oblicz na podstawie domu i biura', - optionHomeAndOfficeHelp: 'Użyj adresu domowego, modelu pracy i przypisania biura członka do obliczenia wykluczeń dojazdów.', + optionHomeAndOfficeTitle: 'Oblicz według domu i biura', + optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy i przypisania do biura, aby obliczyć wyłączenia z dojazdów.', workspaceAddressRequired: { - title: 'Nie tak szybko...', - promptStart: 'Nie możesz włączyć ustawienia oblicz na podstawie domu i biura, dopóki nie dodasz adresu biura w sekcji ', + title: 'Nie tak szybko…', + promptStart: 'Nie możesz włączyć ustawienia obliczania według domu i biura, dopóki najpierw nie dodasz lokalizacji biura w', linkText: 'Przegląd', promptEnd: '.', - cta: 'Rozumiem', - }, - errors: { - distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', - invalidAddress: 'Wprowadź prawidłowy adres', + cta: 'Jasne', }, + errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', invalidAddress: 'Wpisz prawidłowy adres'}, }, distance: 'Dystans', centrallyManage: 'Centralnie zarządzaj stawkami, śledź w milach lub kilometrach i ustaw domyślną kategorię.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 276b015e0820..bcfe20c987e8 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1,13 +1,3 @@ -import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; -import StringUtils from '@libs/StringUtils'; - -import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; -import type OriginalMessage from '@src/types/onyx/OriginalMessage'; -import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; - -import type {ValueOf} from 'type-fest'; - /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / @@ -19,6 +9,16 @@ import type {ValueOf} from 'type-fest'; * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ +import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; +import StringUtils from '@libs/StringUtils'; + +import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; + +import type {ValueOf} from 'type-fest'; + import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; @@ -63,7 +63,6 @@ import type { YourPlanPriceParams, } from './params'; import type {TranslationDeepObject} from './types'; - type StateValue = { stateISO: string; stateName: string; @@ -982,8 +981,8 @@ const translations: TranslationDeepObject = { }, validateAccount: {title: 'Valide sua conta', subtitle: 'Conta', cta: 'Validar'}, addHomeAddress: { - title: 'Adicione um endereço residencial', - subtitle: 'Seu administrador habilitou exclusões de trajeto para despesas de distância. Adicione um endereço residencial para que possamos aplicá-las às suas solicitações.', + title: 'Adicionar um endereço residencial', + subtitle: 'Seu administrador ativou exclusões de deslocamento para despesas de distância. Adicione um endereço residencial para que possamos aplicá-las aos seus reembolsos.', cta: 'Adicionar endereço', }, fixFailedBilling: {title: 'Não foi possível cobrar o cartão cadastrado', subtitle: 'Assinatura'}, @@ -1226,11 +1225,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Endereço residencial obrigatório', + title: 'Endereço residencial é obrigatório', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Antes de registrar distância, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa este endereço para deduções de trajeto.` - : 'Antes de registrar distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este espaço de trabalho usa este endereço para deduções de trajeto.', + ? `Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` + : 'Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.', cta: 'Adicionar endereço residencial', }, amount: 'Valor', @@ -6827,24 +6826,21 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS summaryDisabled: 'Sem exclusão de deslocamento', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por solicitação`, optionDisabledTitle: 'Não excluir deslocamentos de casa para o trabalho', - optionDisabledHelp: 'Nenhum trajeto é removido das solicitações.', + optionDisabledHelp: 'Nenhum trajeto de deslocamento é removido das solicitações.', optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', distanceLabel: 'Distância', - summaryHomeAndOffice: 'Usar locais de casa e escritório', + summaryHomeAndOffice: 'Usar endereços de casa e do trabalho', optionHomeAndOfficeTitle: 'Calcular por casa e escritório', - optionHomeAndOfficeHelp: 'Use o endereço residencial, o regime de trabalho e a alocação de escritório do membro para calcular as exclusões de trajeto.', + optionHomeAndOfficeHelp: 'Use o endereço residencial, o regime de trabalho e a alocação de escritório da pessoa colaboradora para calcular as exclusões de deslocamento.', workspaceAddressRequired: { - title: 'Espere um momento...', - promptStart: 'Você não pode ativar a configuração calcular por casa e escritório enquanto não adicionar um endereço de escritório em ', + title: 'Calma lá...', + promptStart: 'Você não pode ativar a configuração de cálculo por casa e escritório até primeiro adicionar um endereço de escritório em', linkText: 'Visão geral', promptEnd: '.', cta: 'Entendi', }, - errors: { - distanceMustBePositive: 'A distância deve ser um número inteiro positivo.', - invalidAddress: 'Insira um endereço válido', - }, + errors: {distanceMustBePositive: 'A distância deve ser um número inteiro positivo.', invalidAddress: 'Insira um endereço válido'}, }, distance: 'Distância', centrallyManage: 'Gerencie tarifas centralmente, acompanhe em milhas ou quilômetros e defina uma categoria padrão.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 74e7a86e6fa4..bddcf202bd61 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1,13 +1,3 @@ -import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; -import StringUtils from '@libs/StringUtils'; - -import CONST from '@src/CONST'; -import type {Country} from '@src/CONST'; -import type OriginalMessage from '@src/types/onyx/OriginalMessage'; -import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; - -import type {ValueOf} from 'type-fest'; - /** * _____ __ __ * / ___/__ ___ ___ _______ _/ /____ ___/ / @@ -19,6 +9,16 @@ import type {ValueOf} from 'type-fest'; * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ +import type {OnboardingTask} from '@libs/actions/Welcome/OnboardingFlow'; +import StringUtils from '@libs/StringUtils'; + +import CONST from '@src/CONST'; +import type {Country} from '@src/CONST'; +import type OriginalMessage from '@src/types/onyx/OriginalMessage'; +import type {OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; + +import type {ValueOf} from 'type-fest'; + import {CONST as COMMON_CONST, Str} from 'expensify-common'; import startCase from 'lodash/startCase'; @@ -63,7 +63,6 @@ import type { YourPlanPriceParams, } from './params'; import type {TranslationDeepObject} from './types'; - type StateValue = { stateISO: string; stateName: string; @@ -952,11 +951,7 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, validateAccount: {title: '验证您的账户', subtitle: '账户', cta: '验证'}, - addHomeAddress: { - title: '添加家庭地址', - subtitle: '您的管理员已为距离支出启用了通勤排除。请添加家庭地址,以便将其应用于您的申报。', - cta: '添加地址', - }, + addHomeAddress: {title: '添加家庭住址', subtitle: '您的管理员已为里程报销启用了通勤排除。请添加家庭住址,以便我们将其应用于您的报销申请。', cta: '添加地址'}, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, unlockBankAccount: { workspaceTitle: '您的企业银行账户已被锁定', @@ -1179,12 +1174,12 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: '需要家庭地址', + title: '必须填写家庭住址', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `在记录距离之前,您需要在私人资料中添加家庭地址。${workspaceName} 将此地址用于通勤扣除。` - : '在记录距离之前,您需要在私人资料中添加家庭地址。此工作区将此地址用于通勤扣除。', - cta: '添加家庭地址', + ? `在记录里程之前,您需要在个人资料中添加家庭住址。${workspaceName} 会使用此地址计算通勤扣除。` + : '在开始记录行程前,您需要在个人资料中添加家庭住址。此工作区会使用该地址用于通勤扣除。', + cta: '添加家庭住址', }, amount: '金额', percent: '百分比', @@ -6645,24 +6640,21 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM summaryDisabled: '不排除通勤', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `每次报销排除 ${distance} ${unit}`, optionDisabledTitle: '不要排除通勤', - optionDisabledHelp: '不会从申报中扣除任何通勤距离。', + optionDisabledHelp: '未从报销中移除任何通勤费用。', optionFixedDistanceTitle: '为每笔报销排除固定距离', optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', distanceLabel: '距离', - summaryHomeAndOffice: '使用家庭和办公室位置', + summaryHomeAndOffice: '使用家庭和办公地点', optionHomeAndOfficeTitle: '按家庭和办公室计算', - optionHomeAndOfficeHelp: '使用成员的家庭地址、工作安排和办公室分配来计算通勤排除。', + optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公室分配来计算通勤豁免。', workspaceAddressRequired: { - title: '等一下...', - promptStart: '在你在', + title: '别急……', + promptStart: '在您先添加一个办公室地址之前,无法启用“按家庭和办公室计算”设置,添加位置于', linkText: '概览', promptEnd: '中添加办公室地址之前,无法启用按家庭和办公室计算的设置。', cta: '知道了', }, - errors: { - distanceMustBePositive: '距离必须是一个正整数。', - invalidAddress: '请输入有效地址', - }, + errors: {distanceMustBePositive: '距离必须是一个正整数。', invalidAddress: '请输入有效的地址'}, }, distance: '距离', centrallyManage: '集中管理费率,以英里或公里跟踪,并设置默认类别。', From 92e81d5ea10496510c4dae6bbf2298f515ad7873 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 8 Jul 2026 18:01:21 +0200 Subject: [PATCH 051/466] cleanup --- .../hooks/useTimeSensitiveHomeAddress.ts | 1 + .../home/TimeSensitiveSection/items/AddHomeAddress.tsx | 7 ++++++- src/pages/workspace/WorkspaceOverviewAddressPage.tsx | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts index 3288492163ce..debd893b9fbb 100644 --- a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts @@ -1,4 +1,5 @@ import useOnyx from '@hooks/useOnyx'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx b/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx index 1fd79500fbf8..5be27ab9165f 100644 --- a/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx +++ b/src/pages/home/TimeSensitiveSection/items/AddHomeAddress.tsx @@ -1,12 +1,17 @@ -import React from 'react'; import BaseWidgetItem from '@components/BaseWidgetItem'; + import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; + import Navigation from '@libs/Navigation/Navigation'; + import colors from '@styles/theme/colors'; + import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; +import React from 'react'; + function AddHomeAddress() { const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Home']); diff --git a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx index 2d04432ef4d9..7d6dc46e7f40 100644 --- a/src/pages/workspace/WorkspaceOverviewAddressPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewAddressPage.tsx @@ -60,7 +60,7 @@ function WorkspaceOverviewAddressPage({policy}: WorkspaceOverviewAddressPageProp }); Navigation.goBack(backPath); }; - + const requiresFullAddressForExclusions = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; return ( From 443aa8e04aa9812faac8297bfe6e51eafce2c55a Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 8 Jul 2026 18:16:53 +0200 Subject: [PATCH 052/466] weird messy commit --- src/pages/iou/request/step/IOURequestStepDistance.tsx | 4 ++-- .../iou/request/step/confirmation/useExpenseSubmission.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 82ed33dff4e9..25b350ab0630 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -8,9 +8,9 @@ import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentU import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; +import useDistanceHomeAddressCheck from '@hooks/useDistanceHomeAddressCheck'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useFetchRoute from '@hooks/useFetchRoute'; -import useHomeAddressGateForDistance from '@hooks/useHomeAddressGateForDistance'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -290,7 +290,7 @@ function IOURequestStepDistance({ // When the destination workspace requires home/office commuter exclusions, the per-member // commute deduction can't be computed unless the member has a home address. - const {needsHomeAddressPrompt, promptForHomeAddress} = useHomeAddressGateForDistance(policy); + const {needsHomeAddressPrompt, promptForHomeAddress} = useDistanceHomeAddressCheck(policy); const hasShownHomeAddressModalRef = useRef(false); useEffect(() => { if (!needsHomeAddressPrompt) { diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 5ef92b365074..d2c396a9b2c2 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -1,5 +1,5 @@ import useActivePolicy from '@hooks/useActivePolicy'; -import useHomeAddressGateForDistance from '@hooks/useHomeAddressGateForDistance'; +import useDistanceHomeAddressCheck from '@hooks/useDistanceHomeAddressCheck'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import useLocalize from '@hooks/useLocalize'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; @@ -299,7 +299,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { // Any distance flow that submits through this hook is blocked while the user is missing a home address on a // homeAndOffice workspace. - const {needsHomeAddressPrompt: distanceNeedsHomeAddress, promptForHomeAddress: promptForDistanceHomeAddress} = useHomeAddressGateForDistance(policy); + const {needsHomeAddressPrompt: distanceNeedsHomeAddress, promptForHomeAddress: promptForDistanceHomeAddress} = useDistanceHomeAddressCheck(policy); function performPostBatchCleanup({ participant, From 600b409483e5296543c379b9059dfe83c0d1a73e Mon Sep 17 00:00:00 2001 From: Faizan Shoukat Abbasi Date: Tue, 14 Jul 2026 01:32:12 +0500 Subject: [PATCH 053/466] Fixed AI feedbacks --- .../Search/SearchAutocompleteList.tsx | 57 ++++++++++++++----- .../Search/SearchRouter/SearchRouter.tsx | 2 +- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 567a9f693c47..2da87c71157c 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -197,7 +197,6 @@ function SearchAutocompleteList({ const allCards = personalAndWorkspaceCards ?? CONST.EMPTY_OBJECT; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const effectiveInputQueryValue = inputQueryValue ?? autocompleteQueryValue; - const isInputAheadOfDebounce = effectiveInputQueryValue !== autocompleteQueryValue; const hasEffectiveInputQuery = effectiveInputQueryValue.trim() !== ''; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -277,6 +276,13 @@ function SearchAutocompleteList({ const prevQueryRef = useRef(effectiveInputQueryValue); const innerListRef = useRef(null); const hasSetInitialFocusRef = useRef(false); + // Tracks the row key we last focused programmatically (reset-to-top below), so the auto-highlight + // effect can tell whether the user has since navigated away with the arrow keys. Without this, + // auto-highlight would silently snap focus back once the debounce settles even if the user had + // already moved to a different row (e.g. Ask Concierge) in the meantime. + const lastProgrammaticFocusKeyRef = useRef(undefined); + const searchQueryItemsRef = useRef(searchQueryItems); + searchQueryItemsRef.current = searchQueryItems; // Callback ref to set both inner ref and forward to external ref const setListRef = (instance: SelectionListWithSectionsHandle | null) => { @@ -307,6 +313,7 @@ function SearchAutocompleteList({ } else { // When query changes to a non-empty value, focus on the search query item (index 0) and scroll to top. // The highlight effect below switches focus to the first result when there's a good match. + lastProgrammaticFocusKeyRef.current = searchQueryItemsRef.current?.at(0)?.keyForList; innerListRef.current?.updateAndScrollToFocusedIndex(0, true); } } @@ -404,11 +411,9 @@ function SearchAutocompleteList({ return searchOptions.recentReports; } - // User typed but debounce has not emitted yet; avoid showing stale results from the previous query. - if (isInputAheadOfDebounce) { - return []; - } - + // searchOptions/autocompleteQueryValue are debounced, so during the debounce window this still + // returns the previous query's matches instead of blanking the list. Keeping rows visible (rather + // than emptying them) is what preserves focus, Enter, and arrow-key navigation while typing. const orderedOptions = combineOrderingOfReportsAndPersonalDetails(searchOptions, autocompleteQueryValue, { sortByReportTypeInSearch: true, preferChatRoomsOverThreads: true, @@ -420,7 +425,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, hasEffectiveInputQuery, isInputAheadOfDebounce, searchOptions]); + }, [autocompleteQueryValue, hasEffectiveInputQuery, searchOptions]); // Locked rank map (stable key -> originalIndex) capturing the order of locally-known // results at the moment the query changes. Recomputed only when the query changes, so server @@ -451,9 +456,12 @@ function SearchAutocompleteList({ setFrozenLocalRank(buildRankMap(recentReportsOptions)); } - // Debounce the server search so callers that don't already debounce upstream - // (e.g. the main Spend page header via useSearchPageInput) don't fire a request per keystroke. - // For SearchRouter the upstream value is already debounced, so this just adds a no-op coalescing layer. + // Callers that pass a distinct inputQueryValue (e.g. SearchRouter) already debounce autocompleteQueryValue + // upstream, so firing handleSearch immediately here avoids stacking a second debounce on top and doubling + // the delay. Callers that don't pass inputQueryValue (e.g. the Spend page header) still get the local + // debounce below so they don't fire a server request on every keystroke. + const hasUpstreamDebounce = inputQueryValue !== undefined; + const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { return; @@ -463,8 +471,17 @@ function SearchAutocompleteList({ }, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME); useEffect(() => { + if (!handleSearch || !autocompleteQueryWithoutFilters) { + return; + } + + if (hasUpstreamDebounce) { + handleSearch(autocompleteQueryWithoutFilters); + return; + } + debounceHandleSearch(); - }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); + }, [autocompleteQueryWithoutFilters, debounceHandleSearch, handleSearch, hasUpstreamDebounce]); const reasonAttributes: SkeletonSpanReasonAttributes = { context: 'SearchAutocompleteList', @@ -579,7 +596,7 @@ function SearchAutocompleteList({ } } - if (!isInputAheadOfDebounce && autocompleteSuggestions.length > 0) { + if (autocompleteSuggestions.length > 0) { const autocompleteData: AutocompleteListItem[] = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey}) => { return { text: getAutocompleteDisplayText(filterKey, text), @@ -598,7 +615,6 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ hasEffectiveInputQuery, - isInputAheadOfDebounce, autocompleteSuggestions, expensifyIcons, frozenLocalRank, @@ -663,8 +679,9 @@ function SearchAutocompleteList({ } hasSetInitialFocusRef.current = true; + lastProgrammaticFocusKeyRef.current = firstRecentReportKey; innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, false); - }, [isLoadingOptions, firstRecentReportFlatIndex, shouldUseNarrowLayout]); + }, [isLoadingOptions, firstRecentReportFlatIndex, firstRecentReportKey, shouldUseNarrowLayout]); useEffect(() => { const targetText = autocompleteQueryValue; @@ -672,10 +689,20 @@ function SearchAutocompleteList({ if (!shouldHighlightFirstItem || firstRecentReportFlatIndex === -1 || !shouldHighlight(normalizedReferenceText, targetText)) { return; } + + // Don't override focus if the user has already navigated away (e.g. via arrow keys) from the row we + // last set programmatically. Otherwise this settle-triggered auto-highlight would silently steal focus + // back from a manual selection made during the debounce window. + const currentFocusedKey = innerListRef.current?.getFocusedOption?.()?.keyForList; + if (lastProgrammaticFocusKeyRef.current !== undefined && currentFocusedKey !== lastProgrammaticFocusKeyRef.current) { + return; + } + // Focus the header-aware flat index of the first result. A fixed index (e.g. searchQueryItems.length) // lands on the "Recent chats" section header row after the two-section switcher was introduced. + lastProgrammaticFocusKeyRef.current = firstRecentReportKey; innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, true); - }, [autocompleteQueryValue, firstRecentReportFlatIndex, normalizedReferenceText, shouldHighlightFirstItem]); + }, [autocompleteQueryValue, firstRecentReportFlatIndex, firstRecentReportKey, normalizedReferenceText, shouldHighlightFirstItem]); if (isLoading) { return ( diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 97e18fbf10fa..79744247009b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -507,7 +507,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla /> Date: Tue, 14 Jul 2026 02:04:01 +0500 Subject: [PATCH 054/466] Fixed react compiler issues --- src/components/Search/SearchAutocompleteList.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 2da87c71157c..739dbcb304f9 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -281,8 +281,6 @@ function SearchAutocompleteList({ // auto-highlight would silently snap focus back once the debounce settles even if the user had // already moved to a different row (e.g. Ask Concierge) in the meantime. const lastProgrammaticFocusKeyRef = useRef(undefined); - const searchQueryItemsRef = useRef(searchQueryItems); - searchQueryItemsRef.current = searchQueryItems; // Callback ref to set both inner ref and forward to external ref const setListRef = (instance: SelectionListWithSectionsHandle | null) => { @@ -313,11 +311,11 @@ function SearchAutocompleteList({ } else { // When query changes to a non-empty value, focus on the search query item (index 0) and scroll to top. // The highlight effect below switches focus to the first result when there's a good match. - lastProgrammaticFocusKeyRef.current = searchQueryItemsRef.current?.at(0)?.keyForList; + lastProgrammaticFocusKeyRef.current = searchQueryItems?.at(0)?.keyForList; innerListRef.current?.updateAndScrollToFocusedIndex(0, true); } } - }, [effectiveInputQueryValue, isInitialRender]); + }, [effectiveInputQueryValue, isInitialRender, searchQueryItems]); // Track external text input focus to prevent list items from stealing focus while typing useEffect(() => { From 42a1bbc3380900dc71576b8b41e99f1089d9b150 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 14 Jul 2026 13:00:43 +0200 Subject: [PATCH 055/466] handle new rules correctly --- src/CONST/index.ts | 19 +- src/ONYXKEYS.ts | 4 + src/libs/WorkflowUtils.ts | 396 ++++++++++++------ src/libs/actions/Policy/Policy.ts | 25 +- src/libs/actions/Workflow.ts | 112 ++--- .../workflows/WorkspaceWorkflowsPage.tsx | 12 +- ...paceWorkflowsApprovalsExpensesFromPage.tsx | 10 +- .../WorkspaceWorkflowsApprovalsEditPage.tsx | 4 +- src/types/onyx/ApprovalWorkflowRules.ts | 98 +++-- src/types/onyx/Policy.ts | 9 +- src/types/onyx/index.ts | 4 + tests/actions/WorkflowTest.ts | 120 ++++-- tests/unit/WorkflowUtilsTest.ts | 142 +++---- 13 files changed, 609 insertions(+), 346 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e38934eb4277..e2d90540fe33 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7202,7 +7202,6 @@ const CONST = { IS: 'is', REPORT_FIELD: 'reportField', EXPORTED_TO: 'exportedTo', - PREVIOUS_APPROVER: 'previousApprover', }, REPORT_FIELD: { // All report fields start with this, so use this to check if a search key is a report field @@ -7800,6 +7799,24 @@ const CONST = { }, }, + /** Vocabulary for the AST-based approval-workflow rules stored in the `rules` table / `ONYXKEYS.COLLECTION.RULE`. */ + APPROVAL_WORKFLOW_RULE: { + /** The kind of entity a rule is scoped to. Currently only workspace policies. */ + SCOPE: { + POLICY: 'policy', + }, + /** The report lifecycle event that fires a rule. */ + TRIGGER: { + REPORT_SUBMIT: 'ReportSubmit', + REPORT_APPROVE: 'ReportApprove', + }, + /** The action taken when a rule matches. */ + ACTION: { + FORWARD_TO: 'ForwardTo', + APPROVE_REPORT: 'ApproveReport', + }, + }, + BOOT_SPLASH_STATE: { VISIBLE: 'visible', READY_TO_BE_HIDDEN: 'readyToBeHidden', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 4949274983be..e2fdd4906928 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -814,6 +814,9 @@ const ONYXKEYS = { POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED: 'policyHasConnectionsDataBeenFetched_', POLICY_CONNECTION_SYNC_PROGRESS: 'policyConnectionSyncProgress_', POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN: 'policyMergeHRInitialSyncModalShown_', + // AST rules keyed by ruleID (`rules_`). Mirrors a row of the server `rules` table. + // Currently holds approval-workflow rules; intended for all AST rules in the future. + RULE: 'rules_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', WORKSPACE_INVITE_ROLE_DRAFT: 'workspaceInviteRoleDraft_', @@ -1389,6 +1392,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD]: OnyxTypes.ExportDownload; [ONYXKEYS.COLLECTION.POLICY]: OnyxTypes.Policy; [ONYXKEYS.COLLECTION.POLICY_DRAFTS]: OnyxTypes.Policy; + [ONYXKEYS.COLLECTION.RULE]: OnyxTypes.Rule; [ONYXKEYS.COLLECTION.POLICY_CATEGORIES]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT]: OnyxTypes.PolicyCategories; [ONYXKEYS.COLLECTION.POLICY_TAGS]: OnyxTypes.PolicyTagLists; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index ba4fb49631bc..d964a6d4cb6d 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -3,17 +3,26 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {BankAccountList} from '@src/types/onyx'; import type {ApprovalWorkflowOnyx, Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; -import type {ApprovalWorkflowFilter, ApprovalWorkflowFilterComparison, ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; +import type { + ApprovalWorkflowAction, + ApprovalWorkflowActions, + ApprovalWorkflowFilter, + ApprovalWorkflowFilterComparison, + ApprovalWorkflowRule, + ApprovalWorkflowTriggers, +} from '@src/types/onyx/ApprovalWorkflowRules'; import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; import type PersonalDetails from '@src/types/onyx/PersonalDetails'; import type Policy from '@src/types/onyx/Policy'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; +import type Rule from '@src/types/onyx/Rule'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; @@ -124,6 +133,12 @@ type PolicyConversionParams = { /** Current user's login email, used to determine if Expensify team members should be shown */ currentUserLogin?: string; + + /** + * The policy's approval-workflow rules keyed by ruleID, read from the `ONYXKEYS.COLLECTION.RULE` + * collection (see `getApprovalWorkflowRulesForPolicy`). Only consumed by the rules-based converter. + */ + rules?: Record; }; type PolicyConversionResult = { @@ -723,14 +738,13 @@ function mergeWorkflowMembersWithAvailableMembers(workflowMembers: Member[], all return [...workflowMembers, ...additionalMembers]; } -// These helpers translate the in-app `ApprovalWorkflow` model into the rule -// format the backend stores under `Policy.rules.approvalWorkflows`, and -// handle any changes -const APPROVAL_WORKFLOW_FORWARD_ACTION = 'forward' as const; +// These helpers translate the in-app `ApprovalWorkflow` model into the AST rule +// format the backend stores in the `rules` table (mirrored in `ONYXKEYS.COLLECTION.RULE`), +// and handle any changes. type ApprovalWorkflowRulesDiff = Record; -/** Build a filter: ` `. */ +/** Build a comparison filter: ` `. */ function buildComparison( operator: ValueOf, left: ApprovalWorkflowFilterComparison['left'], @@ -739,41 +753,54 @@ function buildComparison( return {operator, left, right}; } -/** Build the `from in [emails]` filter used by every approval-workflow rule. */ +/** Combine two filter nodes with a boolean `AND`. */ +function buildAnd(left: ApprovalWorkflowFilter['left'], right: ApprovalWorkflowFilter['right']): ApprovalWorkflowFilter { + return {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left, right}; +} + +/** Build the `from = [emails]` filter used by every approval-workflow rule. Kept at the top-level left of the tree. */ function buildSubmitterFilter(memberEmails: string[]): ApprovalWorkflowFilterComparison { return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, [...memberEmails]); } -/** Build the `previousApprover = email` filter used by all non-initial rules. */ -function buildPreviousApproverComparison(email: string): ApprovalWorkflowFilterComparison { - return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER, email); +/** Build the `to = email` filter that matches the approver who just approved the report. */ +function buildToComparison(email: string): ApprovalWorkflowFilterComparison { + return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, email); } -/** - * Assemble the filter tree for a single rule from its parts. The `from` comparison is always - * present; the `previousApprover` gate and `amount` comparison are optional. - */ -function buildLevelFilters( - fromComparison: ApprovalWorkflowFilterComparison, - previousApproverComparison: ApprovalWorkflowFilterComparison | undefined, - amountComparison: ApprovalWorkflowFilterComparison | undefined, -): ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison { - let right: ApprovalWorkflowFilterComparison | ApprovalWorkflowFilter | undefined; - if (previousApproverComparison && amountComparison) { - right = {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: previousApproverComparison, right: amountComparison}; - } else { - right = previousApproverComparison ?? amountComparison; - } +/** A rule that fires when a report is submitted. */ +function buildSubmitTriggers(): ApprovalWorkflowTriggers { + return {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}; +} - if (!right) { - return fromComparison; - } +/** A rule that fires when a report is approved. */ +function buildApproveTriggers(): ApprovalWorkflowTriggers { + return {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}; +} - return {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: fromComparison, right}; +/** Forward the report to `approver`. */ +function buildForwardActions(approver: string): ApprovalWorkflowActions { + return {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver}}; +} + +/** Approve (finalize) the report. */ +function buildApproveActions(): ApprovalWorkflowActions { + return {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}; } /** - * Build the `rules.approvalWorkflows` rule chain for a single `ApprovalWorkflow`. + * Build the AST rule chain for a single `ApprovalWorkflow` (members + ordered approvers). + * + * For approvers `[A0, A1, …, A_{n-1}]` and members `M` this produces: + * - a `ReportSubmit` rule: `from ∈ M` → forward to `A0`. + * - for each approver `A_i`, a `ReportApprove` rule gated on `to == A_i` (the approver who just + * approved) describing the next hop: + * - normally forward to `A_{i+1}`, or approve the report if `A_i` is the last approver. + * - if `A_i` has a positive `approvalLimit` and an `overLimitForwardsTo`, the hop splits: + * under the limit → the normal hop above; at/over the limit → forward to `overLimitForwardsTo`, + * who then finalizes the report (a terminal `ApproveReport` rule for `to == overLimitForwardsTo`). + * + * The `from` filter is always the top-level left of the tree so it can be checked cheaply. */ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): ApprovalWorkflowRule[] { const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); @@ -783,56 +810,77 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva return []; } + const firstApproverEmail = approvers.at(0)?.email; + if (!firstApproverEmail) { + return []; + } + const fromComparison = buildSubmitterFilter(memberEmails); const rules: ApprovalWorkflowRule[] = []; - // The approvers that could have approved at the PREVIOUS level and forwarded here. An empty list - // means "no previous approver" - let previousApproverEmails: string[] = []; + // On submission, route the report to the first approver. + rules.push({ + triggers: buildSubmitTriggers(), + filters: fromComparison, + actions: buildForwardActions(firstApproverEmail), + }); + // For each approver, describe what happens after THEY approve (gated on `to == approver`). for (let i = 0; i < approvers.length; i++) { const approver = approvers.at(i); - if (!approver) { + if (!approver?.email) { continue; } - // Narrow once: when the approver has a positive limit and an over-limit target, capture both - // (already non-null inside this branch) so the level can route under/over amounts separately. + const nextApproverEmail = approvers.at(i + 1)?.email; + const toComparison = buildToComparison(approver.email); + const limitSplit = approver.approvalLimit && approver.approvalLimit > 0 && approver.overLimitForwardsTo ? {limit: approver.approvalLimit, overLimitForwardsTo: approver.overLimitForwardsTo} : undefined; - const underAmount = limitSplit ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limitSplit.limit) : undefined; - const overAmount = limitSplit ? buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limitSplit.limit) : undefined; - - // One per possible previous approver - const gates = previousApproverEmails.length === 0 ? [undefined] : previousApproverEmails.map((email) => buildPreviousApproverComparison(email)); - - for (const gate of gates) { - if (limitSplit) { - rules.push({ - filters: buildLevelFilters(fromComparison, gate, underAmount), - action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: approver.email, - }); - rules.push({ - filters: buildLevelFilters(fromComparison, gate, overAmount), - action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: limitSplit.overLimitForwardsTo, - }); - } else { - rules.push({ - filters: buildLevelFilters(fromComparison, gate, undefined), - action: APPROVAL_WORKFLOW_FORWARD_ACTION, - nextReceiver: approver.email, - }); - } - } - previousApproverEmails = limitSplit ? [approver.email, limitSplit.overLimitForwardsTo] : [approver.email]; + if (limitSplit) { + const underAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limitSplit.limit); + const overAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limitSplit.limit); + + // Under the limit: continue the chain, or approve if this is the last approver. + rules.push({ + triggers: buildApproveTriggers(), + filters: buildAnd(fromComparison, buildAnd(toComparison, underAmount)), + actions: nextApproverEmail ? buildForwardActions(nextApproverEmail) : buildApproveActions(), + }); + // At/over the limit: escalate to the over-limit approver. + rules.push({ + triggers: buildApproveTriggers(), + filters: buildAnd(fromComparison, buildAnd(toComparison, overAmount)), + actions: buildForwardActions(limitSplit.overLimitForwardsTo), + }); + // The over-limit approver finalizes the report. + rules.push({ + triggers: buildApproveTriggers(), + filters: buildAnd(fromComparison, buildToComparison(limitSplit.overLimitForwardsTo)), + actions: buildApproveActions(), + }); + } else { + rules.push({ + triggers: buildApproveTriggers(), + filters: buildAnd(fromComparison, toComparison), + actions: nextApproverEmail ? buildForwardActions(nextApproverEmail) : buildApproveActions(), + }); + } } - return rules; + // Different approvers may share an `overLimitForwardsTo`, which would emit identical terminal rules. + const seen = new Set(); + return rules.filter((rule) => { + const fingerprint = JSON.stringify(rule); + if (seen.has(fingerprint)) { + return false; + } + seen.add(fingerprint); + return true; + }); } /** @@ -907,9 +955,9 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { }; return JSON.stringify({ + triggers: rule.triggers, filters: stripFromValues(rule.filters), - action: rule.action, - nextReceiver: rule.nextReceiver, + actions: rule.actions, }); } @@ -922,7 +970,7 @@ function replaceSubmitterEmails(rule: ApprovalWorkflowRule, newEmails: string[]) return { ...node, left: rewrite(node.left), - ...(node.right ? {right: rewrite(node.right)} : {}), + right: rewrite(node.right), }; }; return {...rule, filters: rewrite(rule.filters)}; @@ -949,7 +997,7 @@ function removeEmails(existingEmails: string[], emailsToRemove: string[]): strin } type ReconcileContext = { - /** The existing `Policy.rules.approvalWorkflows` snapshot, defaulting to `{}`. */ + /** The policy's existing approval-workflow rules (`ruleID -> rule body`), defaulting to `{}`. */ existingRules: Record; }; @@ -1152,71 +1200,112 @@ function findComparisonByLeft(node: ApprovalWorkflowFilter | ApprovalWorkflowFil return findComparisonByLeft(node.right, leftKey); } -/** The approver a level routes the report INTO, plus that approver's own limit split (if any). */ -type ApprovalWorkflowLevelInfo = { - /** The approver the level routes to. */ - email: string; - /** This approver's own approval limit, from the level's amount split. */ +/** The triggers of a rule as a flat list. */ +function getRuleTriggers(rule: ApprovalWorkflowRule): Array> { + return Object.values(rule.triggers ?? {}); +} + +/** The actions of a rule as a flat list. */ +function getRuleActions(rule: ApprovalWorkflowRule): ApprovalWorkflowAction[] { + return Object.values(rule.actions ?? {}); +} + +/** True when the rule fires on report submission. */ +function isSubmitRule(rule: ApprovalWorkflowRule): boolean { + return getRuleTriggers(rule).includes(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT); +} + +/** True when the rule approves (finalizes) the report. */ +function isApproveReportRule(rule: ApprovalWorkflowRule): boolean { + return getRuleActions(rule).some((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT); +} + +/** The approver a `ForwardTo` rule routes to, if any. */ +function getForwardApprover(rule: ApprovalWorkflowRule): string | undefined { + return getRuleActions(rule).find((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO)?.approver; +} + +/** + * Resolve the first approver a submitter's report is routed to on submission — the `ForwardTo` + * target of the submitter's `ReportSubmit` rule. Falls back to `employeeList.submitsTo` for + * pre-beta chains that haven't been re-saved as rules yet. + */ +function resolveFirstApprover(submitter: string, rules: Record, employees: PolicyEmployeeList): string | undefined { + for (const rule of Object.values(rules)) { + if (!isSubmitRule(rule) || !extractSubmitterEmails(rule).includes(submitter)) { + continue; + } + const approver = getForwardApprover(rule); + if (approver) { + return approver; + } + } + return employees[submitter]?.submitsTo; +} + +/** What happens after `currentApprover` approves a report from `submitter`. */ +type AfterApproveInfo = { + /** The next approver in the chain, from the normal / under-limit `ForwardTo`. `undefined` means the report is approved (terminal). */ + nextEmail?: string; + /** `currentApprover`'s own approval limit, present only when there is an over-limit split. */ approvalLimit?: number; - /** This approver's own over-limit target, from the level's amount split. */ + /** `currentApprover`'s own over-limit target, present only when there is an over-limit split. */ overLimitForwardsTo?: string; }; /** - * Resolve the level routed into by the rules by `previousApproverEmail` + * Resolve what the rules say happens after `currentApprover` approves a report from `submitter`, by + * inspecting every rule gated on `to == currentApprover`. Returns `undefined` when no rule covers + * this approver (so callers can fall back to `employeeList`). */ -function resolveLevelFromRules(submitter: string, previousApproverEmail: string | undefined, rules: Record): ApprovalWorkflowLevelInfo | undefined { - let underLimitRule: ApprovalWorkflowRule | undefined; - let overLimitRule: ApprovalWorkflowRule | undefined; - let normalRule: ApprovalWorkflowRule | undefined; +function resolveAfterApprove(submitter: string, currentApprover: string, rules: Record): AfterApproveInfo | undefined { + let hasNormalBranch = false; + let normalIsTerminal = false; + let normalNextEmail: string | undefined; + let overLimitForwardsTo: string | undefined; let approvalLimit: number | undefined; for (const rule of Object.values(rules)) { if (!extractSubmitterEmails(rule).includes(submitter)) { continue; } - const previousApproverLeaf = findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER); - if (previousApproverEmail === undefined) { - // Initial-submission level: only rules with no previousApprover gate. - if (previousApproverLeaf) { - continue; - } - } else if (!previousApproverLeaf || previousApproverLeaf.right !== previousApproverEmail) { + const toLeaf = findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO); + if (!toLeaf || toLeaf.right !== currentApprover) { continue; } const amountLeaf = findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT); + const isOverLimit = + !!amountLeaf && (amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO || amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN); + if (amountLeaf && typeof amountLeaf.right === 'number') { - if (amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN) { - underLimitRule = rule; - approvalLimit = amountLeaf.right; - } else if (amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO) { - overLimitRule = rule; - approvalLimit = approvalLimit ?? amountLeaf.right; - } else { - normalRule = rule; - } + approvalLimit = approvalLimit ?? amountLeaf.right; + } + + if (isOverLimit) { + overLimitForwardsTo = getForwardApprover(rule) ?? overLimitForwardsTo; + continue; + } + + // Normal branch: either a no-amount rule or the under-limit half of a split. + hasNormalBranch = true; + if (isApproveReportRule(rule)) { + normalIsTerminal = true; } else { - normalRule = rule; + normalNextEmail = getForwardApprover(rule) ?? normalNextEmail; } } - if (underLimitRule && overLimitRule) { - return { - email: underLimitRule.nextReceiver, - approvalLimit, - overLimitForwardsTo: overLimitRule.nextReceiver, - }; - } - if (normalRule) { - return {email: normalRule.nextReceiver}; - } - // Partial split (one half missing): use whichever rule we found. Best-effort recovery. - const partial = underLimitRule ?? overLimitRule; - if (partial) { - return {email: partial.nextReceiver}; + if (!hasNormalBranch && overLimitForwardsTo === undefined) { + return undefined; } - return undefined; + + const hasSplit = overLimitForwardsTo !== undefined; + return { + nextEmail: normalIsTerminal ? undefined : normalNextEmail, + approvalLimit: hasSplit ? approvalLimit : undefined, + overLimitForwardsTo, + }; } type BuildApproverChainFromRulesParams = { @@ -1227,20 +1316,16 @@ type BuildApproverChainFromRulesParams = { }; /** - * Loop through a submitter's approval chain forward from their initial-submission level. At each step we - * resolve the level the report is routed into to learn the next approver's email AND that - * approver's own `approvalLimit` / `overLimitForwardsTo`; + * Walk a submitter's approval chain forward. The first approver comes from the `ReportSubmit` rule; + * for each approver we look at the `to == approver` rules to learn that approver's own + * `approvalLimit` / `overLimitForwardsTo` and where the report goes next (or whether it is approved). + * Any step the rules don't cover falls back to the legacy `employeeList` fields. */ function buildApproverChainFromRules({submitter, rules, employees, personalDetailsByEmail}: BuildApproverChainFromRulesParams): Approver[] { - // The first approver, and its own limit/over, come from the initial (no-previousApprover) level. - // Fall back to employeeList.submitsTo for pre-beta workflows that haven't been re-saved yet. - const initialLevel = resolveLevelFromRules(submitter, undefined, rules); - let currentEmail: string | undefined = initialLevel?.email ?? employees[submitter]?.submitsTo; + let currentEmail: string | undefined = resolveFirstApprover(submitter, rules, employees); if (!currentEmail) { return []; } - let currentApprovalLimit: number | null = initialLevel?.approvalLimit ?? employees[currentEmail]?.approvalLimit ?? null; - let currentOverLimitForwardsTo: string | undefined = initialLevel?.overLimitForwardsTo ?? employees[currentEmail]?.overLimitForwardsTo; const chain: Approver[] = []; const seenEmails = new Set(); @@ -1249,8 +1334,11 @@ function buildApproverChainFromRules({submitter, rules, employees, personalDetai const isCircularReference = seenEmails.has(currentEmail); const employee: PolicyEmployee | undefined = employees[currentEmail]; - const nextLevel = resolveLevelFromRules(submitter, currentEmail, rules); - const forwardsTo: string | undefined = nextLevel?.email ?? employee?.forwardsTo; + const after = resolveAfterApprove(submitter, currentEmail, rules); + const approvalLimit: number | null = after?.approvalLimit ?? employee?.approvalLimit ?? null; + const overLimitForwardsTo: string | undefined = after?.overLimitForwardsTo ?? employee?.overLimitForwardsTo; + // When a rule covers this approver, trust its next hop (possibly terminal). Otherwise fall back. + const forwardsTo: string | undefined = after ? after.nextEmail : employee?.forwardsTo; chain.push({ email: currentEmail, @@ -1258,9 +1346,9 @@ function buildApproverChainFromRules({submitter, rules, employees, personalDetai avatar: personalDetailsByEmail[currentEmail]?.avatar, displayName: personalDetailsByEmail[currentEmail]?.displayName ?? currentEmail, isCircularReference, - approvalLimit: currentApprovalLimit, - overLimitForwardsTo: currentOverLimitForwardsTo, - overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(currentOverLimitForwardsTo, personalDetailsByEmail), + approvalLimit, + overLimitForwardsTo, + overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(overLimitForwardsTo, personalDetailsByEmail), pendingAction: employee?.pendingAction, errors: employee?.errors, }); @@ -1270,8 +1358,6 @@ function buildApproverChainFromRules({submitter, rules, employees, personalDetai } seenEmails.add(currentEmail); currentEmail = forwardsTo; - currentApprovalLimit = nextLevel?.approvalLimit ?? employees[currentEmail]?.approvalLimit ?? null; - currentOverLimitForwardsTo = nextLevel?.overLimitForwardsTo ?? employees[currentEmail]?.overLimitForwardsTo; } return chain; @@ -1297,10 +1383,34 @@ function getSubmitterRuleIDs(submitter: string, rules: Record rule body` map used by the + * builder, reconcilers and converters, keeping only non-deleted rules scoped to `policyID`. + */ +function getApprovalWorkflowRulesForPolicy(rulesCollection: OnyxCollection | undefined, policyID: string | undefined): Record { + const result: Record = {}; + if (!rulesCollection || !policyID) { + return result; + } + + for (const [onyxKey, rule] of Object.entries(rulesCollection)) { + if (!rule || rule.scope !== CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY || rule.scopeID !== policyID) { + continue; + } + if (rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + const ruleID = onyxKey.slice(ONYXKEYS.COLLECTION.RULE.length); + result[ruleID] = {triggers: rule.triggers, filters: rule.filters, actions: rule.actions}; + } + + return result; +} + /** * Map every submitter found in the rules to their workflow's first approver */ -function getRulesSubmitterToFirstApprover(rules: Record): Record { +function getRulesSubmitterToFirstApprover(rules: Record, employees: PolicyEmployeeList = {}): Record { const submitters = new Set(); for (const rule of Object.values(rules)) { for (const email of extractSubmitterEmails(rule)) { @@ -1310,9 +1420,9 @@ function getRulesSubmitterToFirstApprover(rules: Record = {}; for (const submitter of submitters) { - const initialLevel = resolveLevelFromRules(submitter, undefined, rules); - if (initialLevel?.email) { - result[submitter] = initialLevel.email; + const firstApprover = resolveFirstApprover(submitter, rules, employees); + if (firstApprover) { + result[submitter] = firstApprover; } } return result; @@ -1320,14 +1430,21 @@ function getRulesSubmitterToFirstApprover(rules: Record (deprecatedAllPersonalDetails = val), }); +let allRules: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.RULE, + waitForCollectionCallback: true, + callback: (val) => (allRules = val), +}); + /** * Stores in Onyx the policy ID of the last workspace that was accessed by the user */ @@ -976,7 +984,7 @@ function setWorkspaceApprovalMode( } } - const optimisticData: Array> = [ + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -995,7 +1003,7 @@ function setWorkspaceApprovalMode( optimisticData.push(...nextStepOptimisticData); } - const failureData: Array> = [ + const failureData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, @@ -1013,6 +1021,19 @@ function setWorkspaceApprovalMode( failureData.push(...nextStepFailureData); } + // Disabling approvals removes every approval workflow, which now lives in the `rules_` Onyx + // collection. Auth's DisablePolicyApprovals deletes those rows server-side, but we must also clear them + // optimistically so the workflows UI doesn't keep rebuilding stale workflows (e.g. after re-enabling). + if (approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL && allRules) { + for (const [ruleKey, rule] of Object.entries(allRules)) { + if (!rule || rule.scope !== CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY || rule.scopeID !== policyID) { + continue; + } + optimisticData.push({onyxMethod: Onyx.METHOD.SET, key: ruleKey as `${typeof ONYXKEYS.COLLECTION.RULE}${string}`, value: null}); + failureData.push({onyxMethod: Onyx.METHOD.SET, key: ruleKey as `${typeof ONYXKEYS.COLLECTION.RULE}${string}`, value: rule}); + } + } + const successData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 703ea8cb3557..9013ae553a64 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -9,6 +9,7 @@ import { buildApprovalWorkflowRules, calculateApprovers, convertApprovalWorkflowToPolicyEmployees, + getApprovalWorkflowRulesForPolicy, getOverLimitForwardsToDisplayName, mergeWorkflowMembersWithAvailableMembers, reconcileApprovalWorkflowRulesForCreate, @@ -24,15 +25,26 @@ import type {ApprovalWorkflowOnyx, PersonalDetailsList, Policy, Report} from '@s import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; +import type Rule from '@src/types/onyx/Rule'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import lodashDropRightWhile from 'lodash/dropRightWhile'; import Onyx from 'react-native-onyx'; import {completeTask} from './Task'; +/** Cache of every approval-workflow rule (`ONYXKEYS.COLLECTION.RULE`) so actions can read the current rule set. */ +let allRules: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.RULE, + waitForCollectionCallback: true, + callback: (value) => { + allRules = value ?? {}; + }, +}); + type CreateApprovalWorkflowParams = { approvalWorkflow: ApprovalWorkflow; policy: OnyxEntry; @@ -284,67 +296,61 @@ type SetApprovalWorkflowRulesParams = { policyID: string; /** - * Diff to apply to `Policy.rules.approvalWorkflows`. A value of `ApprovalWorkflowRule` - * sets/replaces the rule under its `ruleID`; a value of `null` removes it + * Diff of rule bodies keyed by ruleID. A value of `ApprovalWorkflowRule` sets/replaces the rule + * under its `ruleID` in the `ONYXKEYS.COLLECTION.RULE` collection; a value of `null` removes it. */ rulesDiff: ApprovalWorkflowRulesDiff; - /** Snapshot of the policy's `rules.approvalWorkflows` taken before applying `rulesDiff`. Used to roll back on failure. */ - previousApprovalWorkflows: Record; + /** Snapshot of the policy's rules (`ruleID -> rule body`) taken before applying `rulesDiff`. Used to roll back on failure. */ + previousRules: Record; }; /** - * Apply a set of approval-workflow rule changes to a policy via the SetApprovalWorkflow - * Auth command. + * Apply a set of approval-workflow rule changes to a policy via the SetApprovalWorkflow Auth command. * + * Each rule lives under its own `ONYXKEYS.COLLECTION.RULE` key (`rules_`). Upserts use SET so + * a structurally different replacement doesn't deep-merge stale filters; removals mark the rule as + * pending-delete optimistically and clear it on success (or restore the previous rule on failure). + * Only the rule bodies (no `scope`/`scopeID`) are sent over the wire — the backend derives those. */ -function setApprovalWorkflowRules({policyID, rulesDiff, previousApprovalWorkflows}: SetApprovalWorkflowRulesParams) { +function setApprovalWorkflowRules({policyID, rulesDiff, previousRules}: SetApprovalWorkflowRulesParams) { if (!policyID || isEmptyObject(rulesDiff)) { return; } - const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const genericError = getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'); + + const optimisticData: Array> = []; + const successData: Array> = []; + const failureData: Array> = []; + + for (const [ruleID, rule] of Object.entries(rulesDiff)) { + const ruleKey = `${ONYXKEYS.COLLECTION.RULE}${ruleID}` as const; + const previousRule = previousRules[ruleID]; + const restore: OnyxUpdate = previousRule + ? { + onyxMethod: Onyx.METHOD.SET, + key: ruleKey, + value: {...previousRule, scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, scopeID: policyID, pendingAction: null, errors: genericError}, + } + : {onyxMethod: Onyx.METHOD.SET, key: ruleKey, value: null}; + + if (rule === null) { + optimisticData.push({onyxMethod: Onyx.METHOD.MERGE, key: ruleKey, value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null}}); + successData.push({onyxMethod: Onyx.METHOD.SET, key: ruleKey, value: null}); + failureData.push(restore); + continue; + } - // Build the rollback shape - const failureApprovalWorkflows: Record = {}; - for (const ruleID of Object.keys(rulesDiff)) { - failureApprovalWorkflows[ruleID] = previousApprovalWorkflows[ruleID] ?? null; + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ruleKey, + value: {...rule, scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, scopeID: policyID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: null}, + }); + successData.push({onyxMethod: Onyx.METHOD.MERGE, key: ruleKey, value: {pendingAction: null}}); + failureData.push(restore); } - const optimisticData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: policyKey, - value: { - rules: {approvalWorkflows: rulesDiff}, - pendingFields: {approvalWorkflows: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, - errorFields: {approvalWorkflows: null}, - }, - }, - ]; - - const successData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: policyKey, - value: { - pendingFields: {approvalWorkflows: null}, - }, - }, - ]; - - const failureData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: policyKey, - value: { - rules: {approvalWorkflows: failureApprovalWorkflows}, - pendingFields: {approvalWorkflows: null}, - errorFields: {approvalWorkflows: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, - }, - }, - ]; - const parameters: SetApprovalWorkflowParams = { policyID, rules: JSON.stringify(rulesDiff), @@ -361,7 +367,7 @@ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprov return; } - const existingRules = policy.rules?.approvalWorkflows ?? {}; + const existingRules = getApprovalWorkflowRulesForPolicy(allRules, policy.id); const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); // A submitter can only belong to one workflow, so first drop these members from any OTHER @@ -374,7 +380,7 @@ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprov const rulesDiff = {...removeDiff, ...createDiff}; - setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousApprovalWorkflows: existingRules}); + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); if ( addExpenseApprovalsTaskReport && @@ -398,7 +404,7 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, return; } - const existingRules = policy.rules?.approvalWorkflows ?? {}; + const existingRules = getApprovalWorkflowRulesForPolicy(allRules, policy.id); const previousMemberEmails = initialApprovalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); const newMemberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); @@ -418,7 +424,7 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, const rulesDiff = {...removeFromOthersDiff, ...memberDiff, ...chainDiff}; - setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousApprovalWorkflows: existingRules}); + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); } /** @@ -429,11 +435,11 @@ function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: return; } - const existingRules = policy.rules?.approvalWorkflows ?? {}; + const existingRules = getApprovalWorkflowRulesForPolicy(allRules, policy.id); const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); const rulesDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); - setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousApprovalWorkflows: existingRules}); + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); } /** Set the members of the approval workflow that is currently edited */ diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 24da998a9b5f..568979141e6e 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -60,7 +60,13 @@ import { } from '@libs/PolicyUtils'; import {hasInProgressVBBA} from '@libs/ReimbursementAccountUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; -import {convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, getEligibleExistingBusinessBankAccounts, INITIAL_APPROVAL_WORKFLOW} from '@libs/WorkflowUtils'; +import { + convertApprovalWorkflowRulesToWorkflows, + convertPolicyEmployeesToApprovalWorkflows, + getApprovalWorkflowRulesForPolicy, + getEligibleExistingBusinessBankAccounts, + INITIAL_APPROVAL_WORKFLOW, +} from '@libs/WorkflowUtils'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -183,15 +189,17 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const {accountID: currentUserAccountID, email: currentUserEmail = '', login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const isUserReimburser = account?.primaryLogin !== undefined && (policy?.achAccount?.reimburser ?? policy?.owner) === account?.primaryLogin; const isMultipleApproversBetaEnabled = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS); + const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE); const {approvalWorkflows, availableMembers, usedApproverEmails} = useMemo(() => { const params = { policy, personalDetails: personalDetails ?? {}, localeCompare, currentUserLogin, + rules: getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID), }; return isMultipleApproversBetaEnabled ? convertApprovalWorkflowRulesToWorkflows(params) : convertPolicyEmployeesToApprovalWorkflows(params); - }, [policy, personalDetails, localeCompare, currentUserLogin, isMultipleApproversBetaEnabled]); + }, [policy, personalDetails, localeCompare, currentUserLogin, rulesCollection, route.params.policyID, isMultipleApproversBetaEnabled]); const canAccessSubmit2026Features = canAccessSubmitWorkspaceFeatures(policy, isSubmit2026BetaEnabled); const hasValidExistingAccounts = getEligibleExistingBusinessBankAccounts(bankAccountList, policy?.outputCurrency, true).length > 0; diff --git a/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx index 23cbab689677..5fc32eba8734 100644 --- a/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -26,7 +26,7 @@ import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {canMemberWrite, getDefaultApprover, getExcludedUsers, getMemberAccountIDsForWorkspace, isPendingDeletePolicy} from '@libs/PolicyUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; -import {getRulesSubmitterToFirstApprover} from '@libs/WorkflowUtils'; +import {getApprovalWorkflowRulesForPolicy, getRulesSubmitterToFirstApprover} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; @@ -60,6 +60,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe const {translate} = useLocalize(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.path); const [approvalWorkflow, approvalWorkflowResults] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); + const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID}`); const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); @@ -107,11 +108,12 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); // Build a map of member emails to their existing workflow's first approver. With the beta on this - // is derived from `rules.approvalWorkflows`; otherwise it falls back to the legacy employeeList - // `submitsTo` (non-default workflows only). + // is derived from the `ONYXKEYS.COLLECTION.RULE` rules; otherwise it falls back to the legacy + // employeeList `submitsTo` (non-default workflows only). const membersInExistingWorkflows = (() => { if (isMultipleApproversBetaEnabled) { - return new Map(Object.entries(getRulesSubmitterToFirstApprover(policy?.rules?.approvalWorkflows ?? {}))); + const rules = getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID); + return new Map(Object.entries(getRulesSubmitterToFirstApprover(rules, policy?.employeeList ?? {}))); } const employees = policy?.employeeList ?? {}; diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index c0a83c0ee94b..3ab51e1801f4 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -19,7 +19,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {canMemberWrite, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; -import {convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows} from '@libs/WorkflowUtils'; +import {convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, getApprovalWorkflowRulesForPolicy} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -57,6 +57,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const {translate, localeCompare} = useLocalize(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [approvalWorkflow, approvalWorkflowMetadata] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); + const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE); const isLoadingApprovalWorkflow = isLoadingOnyxValue(approvalWorkflowMetadata); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const {isBetaEnabled} = usePermissions(); @@ -128,6 +129,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true firstApprover, localeCompare, currentUserLogin, + rules: getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID), }; const result = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS) ? convertApprovalWorkflowRulesToWorkflows(conversionParams) diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index 339d37e5d82c..6d1883ad3981 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -1,21 +1,50 @@ -import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; +import type {ValueOf} from 'type-fest'; + +/** + * A report lifecycle event that can fire an approval-workflow rule (`ReportSubmit` or `ReportApprove`). + */ +type ApprovalWorkflowTrigger = ValueOf; + +/** + * The triggers of a rule, keyed by a string index (e.g. `{"0": "ReportSubmit"}`). A rule fires when + * any of its triggers matches the report event. + */ +type ApprovalWorkflowTriggers = Record; + +/** + * The name of the action a rule performs when it matches (`ForwardTo` or `ApproveReport`). + */ +type ApprovalWorkflowActionName = ValueOf; + +/** + * A single action a rule performs when it matches. + */ +type ApprovalWorkflowAction = { + /** What the rule does when it matches. */ + name: ApprovalWorkflowActionName; + + /** Email of the approver the report is forwarded to. Present only for `ForwardTo` actions. */ + approver?: string; +}; + +/** + * The actions of a rule, keyed by a string index (e.g. `{"0": {"name": "ForwardTo", "approver": "..."}}`). + */ +type ApprovalWorkflowActions = Record; + /** * Operand of a comparison in an approval-workflow filter. * - * - The left operand is a field identifier — one of the search-syntax filter keys (extended - * with `previousApprover` for approval rules). - * - The right operand is the literal value the field is compared against. For email-typed - * fields like `from`, the value may be a list of emails to match against. + * - The left operand is a field identifier — one of the search-syntax filter keys (`from`, `to`, `amount`). + * - The right operand is the literal value the field is compared against. For email-typed fields like + * `from`, the value may be a list of emails to match against. */ type ApprovalWorkflowFilterOperand = string | number | string[]; /** - * A single comparison node: ` `. - * - * `right` is optional because a future operator may be unary, but for the operators currently - * in use (`eq`, `lt`, `gte`, ...) both sides are required. + * A single comparison node: ` `. Both `left` and `right` are always present. */ type ApprovalWorkflowFilterComparison = { /** The comparison operator. */ @@ -25,45 +54,58 @@ type ApprovalWorkflowFilterComparison = { left: ApprovalWorkflowFilterOperand; /** The literal value being compared against. */ - right?: ApprovalWorkflowFilterOperand; + right: ApprovalWorkflowFilterOperand; }; /** - * Top-level filter for an approval-workflow rule. Combines two child nodes with a boolean - * operator. `left` / `right` may be either leaf comparisons or nested filters + * A boolean filter that combines two child nodes. `left` / `right` may each be either a leaf comparison + * or a nested boolean filter. Both children are always present. */ type ApprovalWorkflowFilter = { - /** Boolean combinator */ + /** Boolean combinator (`AND` in practice). */ operator: ValueOf; /** Left-hand child: leaf comparison or nested boolean filter. */ left: ApprovalWorkflowFilterComparison | ApprovalWorkflowFilter; - /** Right-hand child */ - right?: ApprovalWorkflowFilterComparison | ApprovalWorkflowFilter; + /** Right-hand child: leaf comparison or nested boolean filter. */ + right: ApprovalWorkflowFilterComparison | ApprovalWorkflowFilter; }; /** - * A single approval-workflow rule, keyed by ruleID inside `Policy.rules.approvalWorkflows`. - * When all `filters` match a report, the report is forwarded to `nextReceiver`. + * The body of a single approval-workflow rule. This is exactly what is stored in the server `rules` + * table `value` column, what is sent over the wire to `SetApprovalWorkflow`, and (extended with + * `scope`/`scopeID`) what is stored in the `ONYXKEYS.COLLECTION.RULE` collection. + * + * When the report event matches one of the `triggers` and the `filters` match the report, the rule's + * `actions` are performed (forward the report to an approver, or approve it). */ type ApprovalWorkflowRule = { - /** - * Conditions that must match the report for the rule to fire. - */ - filters: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison; + /** Report lifecycle events that fire this rule. */ + triggers: ApprovalWorkflowTriggers; - /** Currently the only supported action. Kept as a literal to leave room for future actions. */ - action: 'forward'; + /** Conditions that must match the report for the rule to fire. */ + filters: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison; - /** Email of the user the report is forwarded to when the rule matches. */ - nextReceiver: string; + /** What happens when the rule matches. */ + actions: ApprovalWorkflowActions; }; /** - * Map of approval-workflow rules keyed by ruleID, as stored on the policy under - * `rules.approvalWorkflows`. + * Map of approval-workflow rule bodies keyed by ruleID. Used as the in-memory working shape by the + * rules builder/reconcilers; on disk each rule lives under its own `ONYXKEYS.COLLECTION.RULE` key. */ type ApprovalWorkflowRules = Record; -export type {ApprovalWorkflowFilter, ApprovalWorkflowFilterComparison, ApprovalWorkflowRule, ApprovalWorkflowRules}; +export type { + ApprovalWorkflowAction, + ApprovalWorkflowActionName, + ApprovalWorkflowActions, + ApprovalWorkflowFilter, + ApprovalWorkflowFilterComparison, + ApprovalWorkflowFilterOperand, + ApprovalWorkflowRule, + ApprovalWorkflowRules, + ApprovalWorkflowTrigger, + ApprovalWorkflowTriggers, +}; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 8965fcee95e4..e5b538ecea21 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -8,7 +8,6 @@ import type {CONST as COMMON_CONST} from 'expensify-common'; import type {ValueOf} from 'type-fest'; import type * as OnyxTypes from '.'; -import type {ApprovalWorkflowRules} from './ApprovalWorkflowRules'; import type * as OnyxCommon from './OnyxCommon'; import type {WorkspaceTravelSettings} from './TravelSettings'; @@ -2634,12 +2633,6 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** A set of Agent rules defined with natural language - The rules are run by the "RuleBot" */ agentRules?: Record; - - /** - * Approval-workflow rules keyed by ruleID. Each rule routes a report to a `nextReceiver` - * when its `filters` match. - */ - approvalWorkflows?: ApprovalWorkflowRules; }; /** The "RuleBot" agent account ID */ @@ -2786,7 +2779,7 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the policy requires purchases to be on a company card */ requireCompanyCardsEnabled?: boolean; } & Partial, - 'addWorkspaceRoom' | keyof ACHAccount | keyof Attributes | 'isHREnabled' | 'isTimeTrackingEnabled' | 'timeTrackingDefaultRate' | 'approvalWorkflows' + 'addWorkspaceRoom' | keyof ACHAccount | keyof Attributes | 'isHREnabled' | 'isTimeTrackingEnabled' | 'timeTrackingDefaultRate' >; /** Stages of policy connection sync */ diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 6f5575b8e784..b1e055cad4a0 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -160,6 +160,8 @@ import type Request from './Request'; import type {AnyRequest} from './Request'; import type Response from './Response'; import type ReviewDuplicates from './ReviewDuplicates'; +import type Rule from './Rule'; +import type {RuleCollection} from './Rule'; import type {SavedCSVColumnLayoutData, SavedCSVColumnLayoutList} from './SavedCSVColumnLayout'; import type {SaveSearch} from './SaveSearch'; import type ScheduleCallDraft from './ScheduleCallDraft'; @@ -307,6 +309,8 @@ export type { RecentlyUsedTags, ReimbursementAccount, Report, + Rule, + RuleCollection, ReportNameValuePairs, ReportAction, ReportActionReactions, diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 93ef734d43be..7c1e64f64b27 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -18,6 +18,9 @@ import {calculateApprovers} from '@src/libs/WorkflowUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ApprovalWorkflowOnyx, PersonalDetailsList, Policy, Policy as PolicyType, Report} from '@src/types/onyx'; import type {Approver} from '@src/types/onyx/ApprovalWorkflow'; +import type Rule from '@src/types/onyx/Rule'; + +import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -52,6 +55,25 @@ const employee2Email = 'test2@gmail.com'; const employee3Email = 'test3@gmail.com'; const ownerEmail = 'owner@gmail.com'; +/** + * Reads every rule in the `ONYXKEYS.COLLECTION.RULE` collection that belongs to the given policy and is + * not optimistically pending deletion. This mirrors what the app treats as the policy's live rule set. + */ +async function getActivePolicyRules(policyID: string): Promise { + let collection: OnyxCollection = {}; + await getOnyxData({ + key: ONYXKEYS.COLLECTION.RULE, + waitForCollectionCallback: true, + callback: (value) => { + collection = value ?? {}; + }, + }); + return Object.values(collection).filter( + (rule): rule is Rule => + !!rule && rule.scope === CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY && rule.scopeID === policyID && rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); +} + describe('actions/Workflow', () => { function getApprovalWorkflowState(): Promise { return new Promise((resolve) => { @@ -852,7 +874,7 @@ describe('actions/Workflow', () => { }); describe('createApprovalWorkflowRules', () => { - it('writes a new rule-based approval workflow to the policy rules', async () => { + it('writes a new rule-based approval workflow to the rules collection', async () => { mockFetch.pause(); const policyID = '123456789'; @@ -880,11 +902,18 @@ describe('actions/Workflow', () => { createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport: undefined}); await waitForBatchedUpdates(); - const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - const rules = Object.values(updatedPolicy?.rules?.approvalWorkflows ?? {}); - expect(rules).toHaveLength(1); - expect(rules.at(0)?.nextReceiver).toBe(ownerEmail); - expect(rules.at(0)?.filters).toEqual({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}); + // A one-approver workflow produces a submit rule (forward to the approver) and a terminal approve rule. + const rules = await getActivePolicyRules(policyID); + expect(rules).toHaveLength(2); + + const submitRule = rules.find((rule) => Object.values(rule.triggers).includes(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT)); + expect(submitRule?.scope).toBe(CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY); + expect(submitRule?.scopeID).toBe(policyID); + expect(submitRule?.filters).toEqual({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}); + expect(submitRule?.actions[0]).toEqual({name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: ownerEmail}); + + const approveRule = rules.find((rule) => Object.values(rule.triggers).includes(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE)); + expect(approveRule?.actions[0]).toEqual({name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}); await mockFetch.resume(); await waitForBatchedUpdates(); @@ -900,17 +929,29 @@ describe('actions/Workflow', () => { ...createRandomPolicy(1), id: policyID, owner: ownerEmail, - rules: { - approvalWorkflows: { - rule1: { - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, - action: 'forward', - nextReceiver: ownerEmail, - }, - }, - }, + rules: {}, }; + // Seed the existing [employee1] → [owner] workflow as rules in the collection. + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule1`, { + scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scopeID: policyID, + triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, + actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: ownerEmail}}, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule2`, { + scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scopeID: policyID, + triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: ownerEmail}, + }, + actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}, + }); + const initialApprovalWorkflow = { members: [{email: employee1Email, displayName: employee1Email}], approvers: [{email: ownerEmail, displayName: ownerEmail, isCircularReference: false}], @@ -932,12 +973,14 @@ describe('actions/Workflow', () => { updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy}); await waitForBatchedUpdates(); - const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - const rules = Object.values(updatedPolicy?.rules?.approvalWorkflows ?? {}); - // The old rule (forwarding to the owner) is removed and a new rule forwarding to employee2 is created. - expect(updatedPolicy?.rules?.approvalWorkflows?.rule1).toBeUndefined(); - expect(rules).toHaveLength(1); - expect(rules.at(0)?.nextReceiver).toBe(employee2Email); + // The old rules (forwarding to the owner) are removed and new rules forwarding to employee2 are created. + const rules = await getActivePolicyRules(policyID); + const forwardApprovers = rules + .flatMap((rule) => Object.values(rule.actions)) + .filter((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO) + .map((action) => action.approver); + expect(forwardApprovers).toContain(employee2Email); + expect(forwardApprovers).not.toContain(ownerEmail); await mockFetch.resume(); await waitForBatchedUpdates(); @@ -953,17 +996,29 @@ describe('actions/Workflow', () => { ...createRandomPolicy(1), id: policyID, owner: ownerEmail, - rules: { - approvalWorkflows: { - rule1: { - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, - action: 'forward', - nextReceiver: ownerEmail, - }, - }, - }, + rules: {}, }; + // Seed the existing [employee1] → [owner] workflow as rules in the collection. + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule1`, { + scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scopeID: policyID, + triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, + actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: ownerEmail}}, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule2`, { + scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scopeID: policyID, + triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: ownerEmail}, + }, + actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}, + }); + const approvalWorkflow = { members: [{email: employee1Email, displayName: employee1Email}], approvers: [{email: ownerEmail, displayName: ownerEmail, isCircularReference: false}], @@ -981,8 +1036,9 @@ describe('actions/Workflow', () => { removeApprovalWorkflowRules(approvalWorkflow, policy); await waitForBatchedUpdates(); - const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - expect(updatedPolicy?.rules?.approvalWorkflows?.rule1).toBeUndefined(); + // Both rules belonged only to the removed member, so no live rules remain for the policy. + const rules = await getActivePolicyRules(policyID); + expect(rules).toHaveLength(0); await mockFetch.resume(); await waitForBatchedUpdates(); diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index bf91c1e00ee0..a85d1c39150f 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1681,9 +1681,13 @@ describe('WorkflowUtils', () => { }); describe('rule-based approval workflows', () => { - const FORWARD_ACTION = 'forward' as const; + const submitTriggers = {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}; + const approveTriggers = {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}; + const forwardActions = (approver: string) => ({'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver}}); + const approveActions = {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}; const buildFromFilter = (emails: string[]) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: emails}); - const buildPreviousApproverFilter = (email: string) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.PREVIOUS_APPROVER, right: email}); + const buildToFilter = (email: string) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: email}); + const and = (left: object, right: object) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left, right}); const keyRules = (rules: ApprovalWorkflowRule[]): Record => Object.fromEntries(rules.map((rule, index) => [`rule${index}`, rule])); describe('buildApprovalWorkflowRules', () => { @@ -1695,41 +1699,38 @@ describe('WorkflowUtils', () => { expect(buildApprovalWorkflowRules(buildWorkflow([2], []))).toEqual([]); }); - it('Should build a single `from` rule for a one-approver workflow', () => { - expect(buildApprovalWorkflowRules(buildWorkflow([2], [1]))).toEqual([{filters: buildFromFilter(['2@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}]); + it('Should build a submit rule and a terminal approve rule for a one-approver workflow', () => { + expect(buildApprovalWorkflowRules(buildWorkflow([2], [1]))).toEqual([ + {triggers: submitTriggers, filters: buildFromFilter(['2@example.com']), actions: forwardActions('1@example.com')}, + {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), buildToFilter('1@example.com')), actions: approveActions}, + ]); }); - it('Should chain approvers with `previousApprover` gates', () => { + it('Should chain approvers with `to` gates and approve at the end', () => { expect(buildApprovalWorkflowRules(buildWorkflow([3], [1, 2]))).toEqual([ - {filters: buildFromFilter(['3@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - { - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['3@example.com']), right: buildPreviousApproverFilter('1@example.com')}, - action: FORWARD_ACTION, - nextReceiver: '2@example.com', - }, + {triggers: submitTriggers, filters: buildFromFilter(['3@example.com']), actions: forwardActions('1@example.com')}, + {triggers: approveTriggers, filters: and(buildFromFilter(['3@example.com']), buildToFilter('1@example.com')), actions: forwardActions('2@example.com')}, + {triggers: approveTriggers, filters: and(buildFromFilter(['3@example.com']), buildToFilter('2@example.com')), actions: approveActions}, ]); }); - it('Should split a level into under/over amount rules when the approver has a limit split', () => { + it('Should split an approver into under/over amount rules when it has a limit split', () => { const workflow: ApprovalWorkflow = { members: [buildMember(2)], approvers: [buildApprover(1, {approvalLimit: 50000, overLimitForwardsTo: '3@example.com'})], isDefault: false, }; - const underAmountFilter = {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 50000}; - const overAmountFilter = {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 50000}; + const underAmount = {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 50000}; + const overAmount = {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 50000}; expect(buildApprovalWorkflowRules(workflow)).toEqual([ - { - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['2@example.com']), right: underAmountFilter}, - action: FORWARD_ACTION, - nextReceiver: '1@example.com', - }, - { - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: buildFromFilter(['2@example.com']), right: overAmountFilter}, - action: FORWARD_ACTION, - nextReceiver: '3@example.com', - }, + {triggers: submitTriggers, filters: buildFromFilter(['2@example.com']), actions: forwardActions('1@example.com')}, + // Under the limit and last approver → approve. + {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), and(buildToFilter('1@example.com'), underAmount)), actions: approveActions}, + // Over the limit → escalate to the over-limit approver. + {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), and(buildToFilter('1@example.com'), overAmount)), actions: forwardActions('3@example.com')}, + // The over-limit approver finalizes the report. + {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), buildToFilter('3@example.com')), actions: approveActions}, ]); }); @@ -1740,13 +1741,16 @@ describe('WorkflowUtils', () => { isDefault: false, }; - expect(buildApprovalWorkflowRules(workflow)).toEqual([{filters: buildFromFilter(['2@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}]); + expect(buildApprovalWorkflowRules(workflow)).toEqual([ + {triggers: submitTriggers, filters: buildFromFilter(['2@example.com']), actions: forwardActions('1@example.com')}, + {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), buildToFilter('1@example.com')), actions: approveActions}, + ]); }); }); describe('applyApprovalWorkflowRulesDiff', () => { - const ruleA: ApprovalWorkflowRule = {filters: buildFromFilter(['2@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}; - const ruleB: ApprovalWorkflowRule = {filters: buildFromFilter(['3@example.com']), action: FORWARD_ACTION, nextReceiver: '4@example.com'}; + const [ruleA] = buildApprovalWorkflowRules(buildWorkflow([2], [1])); + const [ruleB] = buildApprovalWorkflowRules(buildWorkflow([3], [4])); it('Should upsert non-null entries and delete null entries', () => { expect(applyApprovalWorkflowRulesDiff({a: ruleA}, {a: null, b: ruleB})).toEqual({b: ruleB}); @@ -1767,36 +1771,34 @@ describe('WorkflowUtils', () => { expect(Object.values(diff)).toEqual(newRules); }); - it('Should merge members into a structurally identical existing rule', () => { - const existingRules: Record = { - existing1: {filters: buildFromFilter(['20@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }; + it('Should merge members into structurally identical existing rules', () => { + const existingRules = keyRules(buildApprovalWorkflowRules(buildWorkflow([20], [1]))); const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); const diff = reconcileApprovalWorkflowRulesForCreate(newRules, ['10@example.com'], {existingRules}); - expect(diff).toEqual({existing1: {filters: buildFromFilter(['20@example.com', '10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}}); + // Both rules structurally match, so members are folded into the existing rules (no new IDs). + expect(Object.keys(diff)).toEqual(Object.keys(existingRules)); + expect(Object.values(diff)).toEqual(buildApprovalWorkflowRules(buildWorkflow([20, 10], [1]))); }); }); describe('reconcileApprovalWorkflowRulesForEdit', () => { - it('Should drop the old rule and create the new chain when the approver changes', () => { - const existingRules: Record = { - r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }; + it('Should drop the old rules and create the new chain when the approver changes', () => { + const existingRules = keyRules(buildApprovalWorkflowRules(buildWorkflow([10], [1]))); const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [2])); const diff = reconcileApprovalWorkflowRulesForEdit(newRules, ['10@example.com'], {existingRules}); - expect(diff.r1).toBeNull(); + for (const ruleID of Object.keys(existingRules)) { + expect(diff[ruleID]).toBeNull(); + } const createdRules = Object.entries(diff) - .filter(([ruleID]) => ruleID !== 'r1') + .filter(([ruleID]) => !(ruleID in existingRules)) .map(([, rule]) => rule); expect(createdRules).toEqual(newRules); }); it('Should leave a structurally identical rule untouched', () => { - const existingRules: Record = { - r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }; + const existingRules = keyRules(buildApprovalWorkflowRules(buildWorkflow([10], [1]))); const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); const diff = reconcileApprovalWorkflowRulesForEdit(newRules, ['10@example.com'], {existingRules}); @@ -1805,28 +1807,22 @@ describe('WorkflowUtils', () => { }); describe('reconcileApprovalWorkflowRulesForRemove', () => { - it('Should remove a rule that only contains the workflow members', () => { - const existingRules: Record = { - r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }; + it('Should remove rules that only contain the workflow members', () => { + const existingRules = keyRules(buildApprovalWorkflowRules(buildWorkflow([10], [1]))); + const diff = reconcileApprovalWorkflowRulesForRemove(['10@example.com'], {existingRules}); - expect(reconcileApprovalWorkflowRulesForRemove(['10@example.com'], {existingRules})).toEqual({r1: null}); + expect(diff).toEqual(Object.fromEntries(Object.keys(existingRules).map((ruleID) => [ruleID, null]))); }); - it('Should strip only the workflow members from a shared rule', () => { - const existingRules: Record = { - r1: {filters: buildFromFilter(['10@example.com', '20@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }; + it('Should strip only the workflow members from shared rules', () => { + const existingRules = keyRules(buildApprovalWorkflowRules(buildWorkflow([10, 20], [1]))); + const diff = reconcileApprovalWorkflowRulesForRemove(['10@example.com'], {existingRules}); - expect(reconcileApprovalWorkflowRulesForRemove(['10@example.com'], {existingRules})).toEqual({ - r1: {filters: buildFromFilter(['20@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }); + expect(diff).toEqual(keyRules(buildApprovalWorkflowRules(buildWorkflow([20], [1])))); }); it('Should ignore rules that do not contain the members', () => { - const existingRules: Record = { - r1: {filters: buildFromFilter(['30@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }; + const existingRules = keyRules(buildApprovalWorkflowRules(buildWorkflow([30], [1]))); expect(reconcileApprovalWorkflowRulesForRemove(['10@example.com'], {existingRules})).toEqual({}); }); @@ -1834,28 +1830,23 @@ describe('WorkflowUtils', () => { describe('reconcileApprovalWorkflowRulesForMembersChange', () => { it('Should add new members and drop removed ones from matching rules', () => { - const existingRules: Record = { - r1: {filters: buildFromFilter(['10@example.com', '20@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }; + const existingRules = keyRules(buildApprovalWorkflowRules(buildWorkflow([10, 20], [1]))); const diff = reconcileApprovalWorkflowRulesForMembersChange(['10@example.com', '20@example.com'], ['20@example.com', '30@example.com'], {existingRules}); - expect(diff).toEqual({r1: {filters: buildFromFilter(['20@example.com', '30@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}}); + expect(diff).toEqual(keyRules(buildApprovalWorkflowRules(buildWorkflow([20, 30], [1])))); }); it('Should skip rules with no net change to the submitter list', () => { - const existingRules: Record = { - r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }; + const existingRules = keyRules(buildApprovalWorkflowRules(buildWorkflow([10], [1]))); expect(reconcileApprovalWorkflowRulesForMembersChange(['10@example.com'], ['10@example.com'], {existingRules})).toEqual({}); }); - it('Should remove a rule when all of its members are dropped', () => { - const existingRules: Record = { - r1: {filters: buildFromFilter(['10@example.com']), action: FORWARD_ACTION, nextReceiver: '1@example.com'}, - }; + it('Should remove rules when all of their members are dropped', () => { + const existingRules = keyRules(buildApprovalWorkflowRules(buildWorkflow([10], [1]))); + const diff = reconcileApprovalWorkflowRulesForMembersChange(['10@example.com'], [], {existingRules}); - expect(reconcileApprovalWorkflowRulesForMembersChange(['10@example.com'], [], {existingRules})).toEqual({r1: null}); + expect(diff).toEqual(Object.fromEntries(Object.keys(existingRules).map((ruleID) => [ruleID, null]))); }); }); @@ -1868,7 +1859,7 @@ describe('WorkflowUtils', () => { }); describe('convertApprovalWorkflowRulesToWorkflows', () => { - const createPolicyWithRules = (employees: PolicyEmployeeList, rules: Record, defaultApprover: string) => + const createPolicy = (employees: PolicyEmployeeList, defaultApprover: string) => ({ id: 'test-policy', name: 'Test Policy', @@ -1879,15 +1870,14 @@ describe('WorkflowUtils', () => { isPolicyExpenseChatEnabled: true, employeeList: employees, approver: defaultApprover, - rules: {approvalWorkflows: rules}, }) as Policy; it('Should reconstruct a multi-approver chain from rules', () => { const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); const employees: PolicyEmployeeList = {'5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}}; - const policy = createPolicyWithRules(employees, rules, '1@example.com'); + const policy = createPolicy(employees, '1@example.com'); - const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare}); + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules}); expect(approvalWorkflows).toHaveLength(1); const workflow = approvalWorkflows.at(0); @@ -1906,9 +1896,9 @@ describe('WorkflowUtils', () => { }; const rules = keyRules(buildApprovalWorkflowRules(workflow)); const employees: PolicyEmployeeList = {'5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}}; - const policy = createPolicyWithRules(employees, rules, '1@example.com'); + const policy = createPolicy(employees, '1@example.com'); - const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare}); + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules}); const firstApprover = approvalWorkflows.at(0)?.approvers.at(0); expect(firstApprover?.email).toBe('1@example.com'); @@ -1919,9 +1909,9 @@ describe('WorkflowUtils', () => { it('Should round-trip a workflow through rules and back', () => { const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); const employees: PolicyEmployeeList = {'5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}}; - const policy = createPolicyWithRules(employees, rules, '1@example.com'); + const policy = createPolicy(employees, '1@example.com'); - const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare}); + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules}); // Rebuilding rules from the reconstructed workflow should reproduce the original rule set. const workflow = approvalWorkflows.at(0); From 23d18bcb7de227ae9ecaff1e55cbcac9a5f433c2 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 14 Jul 2026 15:35:11 +0200 Subject: [PATCH 056/466] properly group people in the same rules --- src/libs/WorkflowUtils.ts | 62 +++++++++++++--- tests/actions/WorkflowTest.ts | 122 ++++++++++++++++++++++++++++++++ tests/unit/WorkflowUtilsTest.ts | 88 ++++++++++++++++++++++- 3 files changed, 260 insertions(+), 12 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index d964a6d4cb6d..3b391748226f 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -934,10 +934,47 @@ function extractSubmitterEmails(rule: ApprovalWorkflowRule): string[] { return Array.from(emails); } +/** + * Recursively normalize a value so two logically-equal structures stringify identically: + * - object keys are sorted (order-independent), and + * - index-keyed maps (`{"0":x,"1":y}`) are collapsed to arrays (`[x,y]`). + * + * The array collapse matters because the API decodes the rules JSON to PHP associative arrays, so a + * rule's `triggers`/`actions` come back from the backend as JSON arrays while a freshly-built rule + * uses the `{"0":…}` object form. Without this, the two shapes would never match and the create flow + * would mint a brand-new rule instead of folding the submitter into the existing one. + */ +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value !== null && typeof value === 'object') { + const entries = Object.entries(value); + // A `{"0":…,"1":…}` map is the object form of a list — collapse it to an array to match the backend. + const isSequentialIndexMap = entries.length > 0 && entries.every(([key], index) => key === String(index)); + if (isSequentialIndexMap) { + return entries.map(([, val]) => canonicalize(val)); + } + // Byte-order sort is intentional: this is a locale-agnostic structural fingerprint, not user-facing text. + const sortedEntries = entries.sort(([a], [b]) => { + if (a === b) { + return 0; + } + return a < b ? -1 : 1; + }); + return Object.fromEntries(sortedEntries.map(([key, val]) => [key, canonicalize(val)])); + } + return value; +} + /** * Return a structural fingerprint of a rule with the `right` values of every `from` leaf * stripped. Two rules with the same fingerprint differ only in their submitter list, which * is what we look for when deciding whether to merge two workflows into a shared rule. + * + * Keys are canonicalized (recursively sorted) so a rule hydrated from the server folds into + * a freshly-built one even when their JSON object-key order differs — otherwise the create + * flow would mint a brand-new rule instead of adding the submitter to the existing rule. */ function structuralFingerprint(rule: ApprovalWorkflowRule): string { const stripFromValues = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): unknown => { @@ -954,11 +991,13 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { return {operator: node.operator, left: stripFromValues(node.left), right: stripFromValues(node.right)}; }; - return JSON.stringify({ - triggers: rule.triggers, - filters: stripFromValues(rule.filters), - actions: rule.actions, - }); + return JSON.stringify( + canonicalize({ + triggers: rule.triggers, + filters: stripFromValues(rule.filters), + actions: rule.actions, + }), + ); } /** Replace the `right` value on every `from` leaf with `newEmails`. */ @@ -1519,11 +1558,14 @@ function convertApprovalWorkflowRulesToWorkflows({ } } - // Group submitters that share the same rules into one workflow. A submitter's "rules" is the - // exact set of ruleIDs whose `from` includes them, so two submitters that belong to the same - // rules land in the same workflow - const submitterRuleIDs = getSubmitterRuleIDs(email, rules); - const fingerprint = submitterRuleIDs.length > 0 ? `r|${submitterRuleIDs.join(',')}` : `l|${approverChainFingerprint(chain)}`; + // Group submitters by their resolved approver chain, so everyone routing through the same chain of + // approvers renders as a single workflow card. Grouping by the exact set of ruleIDs instead would + // split two same-chain workflows whenever their rules happened to be stored as separate pairs (e.g. + // one workflow was created before the other's rules were cached). The `r`/`l` source tag keeps + // rule-based chains separate from legacy employeeList chains, which are expected to disappear on re-save. + const hasRuleBasedChain = getSubmitterRuleIDs(email, rules).length > 0; + const chainKey = approverChainFingerprint(chain); + const fingerprint = hasRuleBasedChain ? `r|${chainKey}` : `l|${chainKey}`; const existingGroup = groupedByFingerprint.get(fingerprint); if (existingGroup) { diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 7c1e64f64b27..941120118eb4 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -918,6 +918,128 @@ describe('actions/Workflow', () => { await mockFetch.resume(); await waitForBatchedUpdates(); }); + + it('folds a new submitter into the existing same-chain rules instead of creating a new pair', async () => { + mockFetch.pause(); + + const policyID = '123456789'; + const policy: Policy = { + ...createRandomPolicy(1), + id: policyID, + owner: ownerEmail, + rules: {}, + }; + + // Existing workflow: employee1 (A) and employee2 (B) submit to owner (C). + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule1`, { + scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scopeID: policyID, + triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email, employee2Email]}, + actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: ownerEmail}}, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule2`, { + scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scopeID: policyID, + triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}, + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email, employee2Email]}, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: ownerEmail}, + }, + actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}, + }); + + // New workflow: employee3 (D) submits to owner (C) — same chain as the existing workflow. + const approvalWorkflow = { + members: [{email: employee3Email, displayName: employee3Email}], + approvers: [{email: ownerEmail, displayName: ownerEmail, isCircularReference: false}], + availableMembers: [], + usedApproverEmails: [], + isDefault: false, + action: 'create', + originalApprovers: [], + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); + await waitForBatchedUpdates(); + + createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport: undefined}); + await waitForBatchedUpdates(); + + const rules = await getActivePolicyRules(policyID); + // D should be folded into the two existing rules, not create a third/fourth rule. + expect(rules).toHaveLength(2); + for (const rule of rules) { + const fromLeaf = Object.values(rule.actions).some((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO) + ? rule.filters + : (rule.filters as {left: unknown}).left; + expect((fromLeaf as {right: string[]}).right).toEqual([employee1Email, employee2Email, employee3Email]); + } + + await mockFetch.resume(); + await waitForBatchedUpdates(); + }); + + it('E2E: creating A,B→C then D→C via two successive create calls folds D into the same rules', async () => { + mockFetch.pause(); + + const policyID = '123456789'; + const policy: Policy = { + ...createRandomPolicy(1), + id: policyID, + owner: ownerEmail, + rules: {}, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); + await waitForBatchedUpdates(); + + // First: A and B submit to C. + createApprovalWorkflowRules({ + approvalWorkflow: { + members: [ + {email: employee1Email, displayName: employee1Email}, + {email: employee2Email, displayName: employee2Email}, + ], + approvers: [{email: ownerEmail, displayName: ownerEmail, isCircularReference: false}], + isDefault: false, + }, + policy, + addExpenseApprovalsTaskReport: undefined, + }); + await waitForBatchedUpdates(); + + const rulesAfterFirst = await getActivePolicyRules(policyID); + expect(rulesAfterFirst).toHaveLength(2); + + // Second: D submits to C (same approver chain). + createApprovalWorkflowRules({ + approvalWorkflow: { + members: [{email: employee3Email, displayName: employee3Email}], + approvers: [{email: ownerEmail, displayName: ownerEmail, isCircularReference: false}], + isDefault: false, + }, + policy, + addExpenseApprovalsTaskReport: undefined, + }); + await waitForBatchedUpdates(); + + const rulesAfterSecond = await getActivePolicyRules(policyID); + // Still only two rules, each now listing all three submitters. + expect(rulesAfterSecond).toHaveLength(2); + for (const rule of rulesAfterSecond) { + const fromLeaf = Object.values(rule.actions).some((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO) + ? rule.filters + : (rule.filters as {left: unknown}).left; + expect((fromLeaf as {right: string[]}).right).toEqual([employee1Email, employee2Email, employee3Email]); + } + + await mockFetch.resume(); + await waitForBatchedUpdates(); + }); }); describe('updateApprovalWorkflowRules', () => { diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index a85d1c39150f..dc5ddb97b7dd 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -25,7 +25,7 @@ import { import type {Policy} from '@src/types/onyx'; import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; -import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; +import type {ApprovalWorkflowFilter, ApprovalWorkflowFilterComparison, ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; import type {BankAccountList} from '@src/types/onyx/BankAccount'; import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; @@ -1687,7 +1687,11 @@ describe('WorkflowUtils', () => { const approveActions = {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}; const buildFromFilter = (emails: string[]) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: emails}); const buildToFilter = (email: string) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: email}); - const and = (left: object, right: object) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left, right}); + const and = (left: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison, right: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): ApprovalWorkflowFilter => ({ + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left, + right, + }); const keyRules = (rules: ApprovalWorkflowRule[]): Record => Object.fromEntries(rules.map((rule, index) => [`rule${index}`, rule])); describe('buildApprovalWorkflowRules', () => { @@ -1780,6 +1784,56 @@ describe('WorkflowUtils', () => { expect(Object.keys(diff)).toEqual(Object.keys(existingRules)); expect(Object.values(diff)).toEqual(buildApprovalWorkflowRules(buildWorkflow([20, 10], [1]))); }); + + it('Should merge into an existing rule whose triggers/actions are arrays (as the PHP backend stores them)', () => { + // The API decodes the rules JSON to PHP associative arrays, so index-keyed maps like + // {"0":"ReportSubmit"} come back as JSON arrays ["ReportSubmit"]. A freshly-built rule uses the + // object form, so the structural match must treat the two shapes as equivalent to fold the submitter in. + const existingRules: Record = { + r1: { + triggers: [CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT] as unknown as ApprovalWorkflowRule['triggers'], + filters: buildFromFilter(['20@example.com']), + actions: [{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: '1@example.com'}] as unknown as ApprovalWorkflowRule['actions'], + }, + r2: { + triggers: [CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE] as unknown as ApprovalWorkflowRule['triggers'], + filters: and(buildFromFilter(['20@example.com']), buildToFilter('1@example.com')), + actions: [{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}] as unknown as ApprovalWorkflowRule['actions'], + }, + }; + const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); + const diff = reconcileApprovalWorkflowRulesForCreate(newRules, ['10@example.com'], {existingRules}); + + // Folds into the two existing rules (r1/r2) instead of creating fresh IDs, and appends the submitter. + expect(Object.keys(diff).sort()).toEqual(['r1', 'r2']); + expect((diff.r1?.filters as {right: string[]}).right).toEqual(['20@example.com', '10@example.com']); + }); + + it('Should merge into an existing rule whose JSON keys are ordered differently (e.g. hydrated from the server)', () => { + // Server-hydrated rules can come back with a different object-key order than a freshly-built rule. + // The structural match must be order-independent so the new submitter folds into the existing rule. + const existingRules: Record = { + r1: { + actions: {'0': {approver: '1@example.com', name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO}}, + filters: {right: ['20@example.com'], left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, + triggers: submitTriggers, + }, + r2: { + actions: approveActions, + filters: { + right: {right: '1@example.com', left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, + left: {right: ['20@example.com'], left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + }, + triggers: approveTriggers, + }, + }; + const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); + const diff = reconcileApprovalWorkflowRulesForCreate(newRules, ['10@example.com'], {existingRules}); + + // Folds into the two existing rules (r1/r2) instead of creating fresh IDs. + expect(Object.keys(diff).sort()).toEqual(['r1', 'r2']); + }); }); describe('reconcileApprovalWorkflowRulesForEdit', () => { @@ -1920,6 +1974,36 @@ describe('WorkflowUtils', () => { expect(buildApprovalWorkflowRules(workflow)).toEqual(Object.values(rules)); } }); + + it('Should group submitters with the same approver chain into one workflow even when stored as separate rule pairs', () => { + // Two workflows that both route to approver 1, but stored under distinct ruleIDs (e.g. created + // before the other one's rules were cached). They must still render as a single workflow card. + const rules: Record = { + subAB: {triggers: submitTriggers, filters: buildFromFilter(['2@example.com', '3@example.com']), actions: forwardActions('1@example.com')}, + appAB: {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com', '3@example.com']), buildToFilter('1@example.com')), actions: approveActions}, + subD: {triggers: submitTriggers, filters: buildFromFilter(['4@example.com']), actions: forwardActions('1@example.com')}, + appD: {triggers: approveTriggers, filters: and(buildFromFilter(['4@example.com']), buildToFilter('1@example.com')), actions: approveActions}, + }; + const employees: PolicyEmployeeList = { + '1@example.com': {email: '1@example.com', submitsTo: '9@example.com'}, + '2@example.com': {email: '2@example.com', submitsTo: '1@example.com'}, + '3@example.com': {email: '3@example.com', submitsTo: '1@example.com'}, + '4@example.com': {email: '4@example.com', submitsTo: '1@example.com'}, + '9@example.com': {email: '9@example.com', submitsTo: '9@example.com'}, + }; + const policy = createPolicy(employees, '9@example.com'); + + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules}); + + const workflowsToApprover1 = approvalWorkflows.filter((workflow) => workflow.approvers.at(0)?.email === '1@example.com'); + expect(workflowsToApprover1).toHaveLength(1); + expect( + workflowsToApprover1 + .at(0) + ?.members.map((member) => member.email) + .sort(), + ).toEqual(['2@example.com', '3@example.com', '4@example.com']); + }); }); }); }); From 9213ee2ae0eba5568efc618f04b2f9341a65a543 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 14 Jul 2026 15:50:41 +0200 Subject: [PATCH 057/466] big cleanup --- src/libs/WorkflowUtils.ts | 50 +++++++++++++++++---------------- tests/unit/WorkflowUtilsTest.ts | 43 ++++++---------------------- 2 files changed, 35 insertions(+), 58 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 3b391748226f..1f43290d2df7 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1035,6 +1035,21 @@ function removeEmails(existingEmails: string[], emailsToRemove: string[]): strin return existingEmails.filter((email) => !removalSet.has(email)); } +/** True when any of `emails` is in `set` — i.e. a rule's submitters overlap a workflow's members. */ +function containsAnyEmail(emails: string[], set: Set): boolean { + return emails.some((email) => set.has(email)); +} + +/** Fold `memberEmails` into a rule's `from` list (union), returning the updated rule. */ +function addMembersToRule(rule: ApprovalWorkflowRule, memberEmails: string[]): ApprovalWorkflowRule { + return replaceSubmitterEmails(rule, mergeEmails(extractSubmitterEmails(rule), memberEmails)); +} + +/** Rewrite a rule's `from` list, or drop the rule entirely (`null`) when no submitters remain. */ +function rewriteOrRemoveRule(rule: ApprovalWorkflowRule, remainingEmails: string[]): ApprovalWorkflowRule | null { + return remainingEmails.length === 0 ? null : replaceSubmitterEmails(rule, remainingEmails); +} + type ReconcileContext = { /** The policy's existing approval-workflow rules (`ruleID -> rule body`), defaulting to `{}`. */ existingRules: Record; @@ -1056,8 +1071,7 @@ function reconcileApprovalWorkflowRulesForCreate(newRules: ApprovalWorkflowRule[ if (match) { const [existingID, existingRule] = match; - const mergedEmails = mergeEmails(extractSubmitterEmails(existingRule), memberEmails); - diff[existingID] = replaceSubmitterEmails(existingRule, mergedEmails); + diff[existingID] = addMembersToRule(existingRule, memberEmails); continue; } @@ -1091,8 +1105,7 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], // Pass 1: walk existing rules that belong (at least partially) to this workflow. for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { const ruleEmails = extractSubmitterEmails(existingRule); - const sharedWithThisWorkflow = ruleEmails.some((email) => memberSet.has(email)); - if (!sharedWithThisWorkflow) { + if (!containsAnyEmail(ruleEmails, memberSet)) { continue; } @@ -1103,14 +1116,8 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], continue; } - const remaining = removeEmails(ruleEmails, memberEmails); - if (remaining.length === 0) { - // No other workflow shares this rule — drop it entirely. - diff[ruleID] = null; - } else { - // Keep the rule for the other workflows that still need it, minus our members. - diff[ruleID] = replaceSubmitterEmails(existingRule, remaining); - } + // Keep the rule (minus our members) for any other workflow that still needs it; drop it otherwise. + diff[ruleID] = rewriteOrRemoveRule(existingRule, removeEmails(ruleEmails, memberEmails)); } // Pass 2: create or extend rules for any new rule that wasn't already covered. @@ -1132,15 +1139,13 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], if (structuralFingerprint(existing) !== fingerprint) { return false; } - const existingEmails = extractSubmitterEmails(existing); // "Different workflow" => no overlap with our members. - return !existingEmails.some((email) => memberSet.has(email)); + return !containsAnyEmail(extractSubmitterEmails(existing), memberSet); }); if (foreignMatch) { const [existingID, existingRule] = foreignMatch; - const mergedEmails = mergeEmails(extractSubmitterEmails(existingRule), memberEmails); - diff[existingID] = replaceSubmitterEmails(existingRule, mergedEmails); + diff[existingID] = addMembersToRule(existingRule, memberEmails); continue; } @@ -1161,13 +1166,11 @@ function reconcileApprovalWorkflowRulesForRemove(memberEmails: string[], context for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { const ruleEmails = extractSubmitterEmails(existingRule); - const sharedWithThisWorkflow = ruleEmails.some((email) => memberSet.has(email)); - if (!sharedWithThisWorkflow) { + if (!containsAnyEmail(ruleEmails, memberSet)) { continue; } - const remaining = removeEmails(ruleEmails, memberEmails); - diff[ruleID] = remaining.length === 0 ? null : replaceSubmitterEmails(existingRule, remaining); + diff[ruleID] = rewriteOrRemoveRule(existingRule, removeEmails(ruleEmails, memberEmails)); } return diff; @@ -1186,19 +1189,18 @@ function reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails: st for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { const ruleEmails = extractSubmitterEmails(existingRule); - if (!ruleEmails.some((email) => previousSet.has(email))) { + if (!containsAnyEmail(ruleEmails, previousSet)) { continue; } - const afterRemoval = removeEmails(ruleEmails, removed); - const updatedEmails = mergeEmails(afterRemoval, newMemberEmails); + const updatedEmails = mergeEmails(removeEmails(ruleEmails, removed), newMemberEmails); // No effective change to this rule's `from` — skip the round-trip. if (updatedEmails.length === ruleEmails.length && updatedEmails.every((email, idx) => email === ruleEmails.at(idx))) { continue; } - diff[ruleID] = updatedEmails.length === 0 ? null : replaceSubmitterEmails(existingRule, updatedEmails); + diff[ruleID] = rewriteOrRemoveRule(existingRule, updatedEmails); } return diff; diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index dc5ddb97b7dd..55f4b0f281f8 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1785,20 +1785,21 @@ describe('WorkflowUtils', () => { expect(Object.values(diff)).toEqual(buildApprovalWorkflowRules(buildWorkflow([20, 10], [1]))); }); - it('Should merge into an existing rule whose triggers/actions are arrays (as the PHP backend stores them)', () => { - // The API decodes the rules JSON to PHP associative arrays, so index-keyed maps like - // {"0":"ReportSubmit"} come back as JSON arrays ["ReportSubmit"]. A freshly-built rule uses the - // object form, so the structural match must treat the two shapes as equivalent to fold the submitter in. + it('Should merge into a server-hydrated rule (array-shaped triggers/actions, reordered keys)', () => { + // The API decodes the rules JSON to PHP associative arrays, so a hydrated rule comes back with + // index-keyed maps flattened to arrays (`{"0":"ReportSubmit"}` -> `["ReportSubmit"]`) and its object + // keys in a different order than a freshly-built rule. The structural match must ignore both so the + // new submitter folds into the existing rule instead of minting a fresh pair. const existingRules: Record = { r1: { - triggers: [CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT] as unknown as ApprovalWorkflowRule['triggers'], + actions: [{approver: '1@example.com', name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO}] as unknown as ApprovalWorkflowRule['actions'], filters: buildFromFilter(['20@example.com']), - actions: [{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: '1@example.com'}] as unknown as ApprovalWorkflowRule['actions'], + triggers: [CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT] as unknown as ApprovalWorkflowRule['triggers'], }, r2: { - triggers: [CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE] as unknown as ApprovalWorkflowRule['triggers'], - filters: and(buildFromFilter(['20@example.com']), buildToFilter('1@example.com')), actions: [{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}] as unknown as ApprovalWorkflowRule['actions'], + filters: and(buildFromFilter(['20@example.com']), buildToFilter('1@example.com')), + triggers: [CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE] as unknown as ApprovalWorkflowRule['triggers'], }, }; const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); @@ -1808,32 +1809,6 @@ describe('WorkflowUtils', () => { expect(Object.keys(diff).sort()).toEqual(['r1', 'r2']); expect((diff.r1?.filters as {right: string[]}).right).toEqual(['20@example.com', '10@example.com']); }); - - it('Should merge into an existing rule whose JSON keys are ordered differently (e.g. hydrated from the server)', () => { - // Server-hydrated rules can come back with a different object-key order than a freshly-built rule. - // The structural match must be order-independent so the new submitter folds into the existing rule. - const existingRules: Record = { - r1: { - actions: {'0': {approver: '1@example.com', name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO}}, - filters: {right: ['20@example.com'], left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, - triggers: submitTriggers, - }, - r2: { - actions: approveActions, - filters: { - right: {right: '1@example.com', left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, - left: {right: ['20@example.com'], left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO}, - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - }, - triggers: approveTriggers, - }, - }; - const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); - const diff = reconcileApprovalWorkflowRulesForCreate(newRules, ['10@example.com'], {existingRules}); - - // Folds into the two existing rules (r1/r2) instead of creating fresh IDs. - expect(Object.keys(diff).sort()).toEqual(['r1', 'r2']); - }); }); describe('reconcileApprovalWorkflowRulesForEdit', () => { From d14a1976fef1c7b697c6ed2b197d8df9aaaf00ea Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 15 Jul 2026 14:02:00 +0200 Subject: [PATCH 058/466] comment cleanup --- src/libs/WorkflowUtils.ts | 90 +++++++++---------------------- src/libs/actions/Policy/Policy.ts | 5 +- src/libs/actions/Workflow.ts | 12 ++--- 3 files changed, 29 insertions(+), 78 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 1f43290d2df7..1d07d1efc50a 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -738,10 +738,6 @@ function mergeWorkflowMembersWithAvailableMembers(workflowMembers: Member[], all return [...workflowMembers, ...additionalMembers]; } -// These helpers translate the in-app `ApprovalWorkflow` model into the AST rule -// format the backend stores in the `rules` table (mirrored in `ONYXKEYS.COLLECTION.RULE`), -// and handle any changes. - type ApprovalWorkflowRulesDiff = Record; /** Build a comparison filter: ` `. */ @@ -789,18 +785,9 @@ function buildApproveActions(): ApprovalWorkflowActions { } /** - * Build the AST rule chain for a single `ApprovalWorkflow` (members + ordered approvers). - * - * For approvers `[A0, A1, …, A_{n-1}]` and members `M` this produces: - * - a `ReportSubmit` rule: `from ∈ M` → forward to `A0`. - * - for each approver `A_i`, a `ReportApprove` rule gated on `to == A_i` (the approver who just - * approved) describing the next hop: - * - normally forward to `A_{i+1}`, or approve the report if `A_i` is the last approver. - * - if `A_i` has a positive `approvalLimit` and an `overLimitForwardsTo`, the hop splits: - * under the limit → the normal hop above; at/over the limit → forward to `overLimitForwardsTo`, - * who then finalizes the report (a terminal `ApproveReport` rule for `to == overLimitForwardsTo`). - * - * The `from` filter is always the top-level left of the tree so it can be checked cheaply. + * Build the AST rule chain for a single `ApprovalWorkflow`: a `ReportSubmit` rule forwarding members' + * reports to the first approver, plus a `ReportApprove` rule per approver describing the next hop + * (forward to the next approver, approve if last, or split on the approver's over-limit target). */ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): ApprovalWorkflowRule[] { const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); @@ -850,13 +837,12 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva filters: buildAnd(fromComparison, buildAnd(toComparison, underAmount)), actions: nextApproverEmail ? buildForwardActions(nextApproverEmail) : buildApproveActions(), }); - // At/over the limit: escalate to the over-limit approver. + // At/over the limit: escalate to the over-limit approver, who then finalizes the report. rules.push({ triggers: buildApproveTriggers(), filters: buildAnd(fromComparison, buildAnd(toComparison, overAmount)), actions: buildForwardActions(limitSplit.overLimitForwardsTo), }); - // The over-limit approver finalizes the report. rules.push({ triggers: buildApproveTriggers(), filters: buildAnd(fromComparison, buildToComparison(limitSplit.overLimitForwardsTo)), @@ -906,7 +892,6 @@ function forEachSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowF return; } if (isComparisonLeaf(node)) { - // A leaf has no children to recurse into; only `from` leaves are reported. if (isSubmitterFilter(node)) { callback(node); } @@ -935,14 +920,10 @@ function extractSubmitterEmails(rule: ApprovalWorkflowRule): string[] { } /** - * Recursively normalize a value so two logically-equal structures stringify identically: - * - object keys are sorted (order-independent), and - * - index-keyed maps (`{"0":x,"1":y}`) are collapsed to arrays (`[x,y]`). - * - * The array collapse matters because the API decodes the rules JSON to PHP associative arrays, so a - * rule's `triggers`/`actions` come back from the backend as JSON arrays while a freshly-built rule - * uses the `{"0":…}` object form. Without this, the two shapes would never match and the create flow - * would mint a brand-new rule instead of folding the submitter into the existing one. + * Normalize a value so two logically-equal structures stringify identically: object keys are sorted and + * index-keyed maps (`{"0":x}`) are collapsed to arrays (`[x]`). The array collapse matters because the API + * decodes the rules JSON to PHP arrays, so `triggers`/`actions` come back as JSON arrays while a freshly-built + * rule uses the `{"0":…}` object form — without it the two shapes never match and merging fails. */ function canonicalize(value: unknown): unknown { if (Array.isArray(value)) { @@ -955,7 +936,7 @@ function canonicalize(value: unknown): unknown { if (isSequentialIndexMap) { return entries.map(([, val]) => canonicalize(val)); } - // Byte-order sort is intentional: this is a locale-agnostic structural fingerprint, not user-facing text. + // Byte-order sort (not locale-aware): this is a structural fingerprint, not user-facing text. const sortedEntries = entries.sort(([a], [b]) => { if (a === b) { return 0; @@ -968,13 +949,9 @@ function canonicalize(value: unknown): unknown { } /** - * Return a structural fingerprint of a rule with the `right` values of every `from` leaf - * stripped. Two rules with the same fingerprint differ only in their submitter list, which - * is what we look for when deciding whether to merge two workflows into a shared rule. - * - * Keys are canonicalized (recursively sorted) so a rule hydrated from the server folds into - * a freshly-built one even when their JSON object-key order differs — otherwise the create - * flow would mint a brand-new rule instead of adding the submitter to the existing rule. + * Return a structural fingerprint of a rule with every `from` leaf's `right` (the submitter list) + * stripped and keys canonicalized. Two rules with the same fingerprint differ only in their submitters, + * which is what we look for when deciding whether to merge two workflows into a shared rule. */ function structuralFingerprint(rule: ApprovalWorkflowRule): string { const stripFromValues = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): unknown => { @@ -982,7 +959,6 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { return node; } if (isComparisonLeaf(node)) { - // Drop the `right` (submitter list) from `from` leaves so only the structure remains. if (isSubmitterFilter(node)) { return {operator: node.operator, left: node.left}; } @@ -1082,17 +1058,9 @@ function reconcileApprovalWorkflowRulesForCreate(newRules: ApprovalWorkflowRule[ } /** - * Reconcile an edit to an existing workflow. Returns add/replace/remove instructions to - * morph the existing rule set into the new chain while preserving rules shared with other - * workflows. - * - * Two passes: - * 1. Walk every existing rule that contains any of the workflow's members. If a new rule - * structurally matches, leave it alone; otherwise either drop this workflow's members - * from the rule (when it's shared with other workflows) or remove the rule entirely. - * 2. For every new rule that didn't already exist, look for a structurally matching rule - * under a different membership and fold this workflow into it; failing that, create - * a fresh rule with a new ruleID. + * Reconcile an edit to an existing workflow into add/replace/remove instructions, preserving rules shared + * with other workflows. Pass 1 rewrites/removes existing rules that hold this workflow's members; pass 2 + * folds each new rule into a structurally-matching foreign rule or creates it under a fresh ruleID. */ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { const diff: ApprovalWorkflowRulesDiff = {}; @@ -1206,9 +1174,7 @@ function reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails: st return diff; } -/** - * Apply an `ApprovalWorkflowRulesDiff` to a rule map, returning a new map. A `null` value removes - */ +/** Apply an `ApprovalWorkflowRulesDiff` to a rule map, returning a new map. A `null` value removes the rule. */ function applyApprovalWorkflowRulesDiff(existingRules: Record, diff: ApprovalWorkflowRulesDiff): Record { const result: Record = {...existingRules}; for (const [ruleID, value] of Object.entries(diff)) { @@ -1221,10 +1187,8 @@ function applyApprovalWorkflowRulesDiff(existingRules: Record return result; } -/** - * Map every submitter found in the rules to their workflow's first approver - */ +/** Map every submitter found in the rules to their workflow's first approver. */ function getRulesSubmitterToFirstApprover(rules: Record, employees: PolicyEmployeeList = {}): Record { const submitters = new Set(); for (const rule of Object.values(rules)) { @@ -1471,10 +1433,8 @@ function getRulesSubmitterToFirstApprover(rules: Record 0; const chainKey = approverChainFingerprint(chain); const fingerprint = hasRuleBasedChain ? `r|${chainKey}` : `l|${chainKey}`; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 6f3e41a7078f..6243e463e953 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1021,9 +1021,8 @@ function setWorkspaceApprovalMode( failureData.push(...nextStepFailureData); } - // Disabling approvals removes every approval workflow, which now lives in the `rules_` Onyx - // collection. Auth's DisablePolicyApprovals deletes those rows server-side, but we must also clear them - // optimistically so the workflows UI doesn't keep rebuilding stale workflows (e.g. after re-enabling). + // Auth's DisablePolicyApprovals deletes the policy's rule rows server-side; clear them optimistically too + // so the workflows UI doesn't keep rebuilding stale workflows (e.g. after re-enabling). if (approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL && allRules) { for (const [ruleKey, rule] of Object.entries(allRules)) { if (!rule || rule.scope !== CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY || rule.scopeID !== policyID) { diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 9013ae553a64..e35728abe9ac 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -359,9 +359,7 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousRules}: SetAppro write(WRITE_COMMANDS.SET_APPROVAL_WORKFLOW, parameters, {optimisticData, successData, failureData}); } -/** - * Create an approval workflow using the rules-based backend structure, - */ +/** Create an approval workflow using the rules-based backend structure. */ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport}: CreateApprovalWorkflowParams) { if (!policy) { return; @@ -396,9 +394,7 @@ type UpdateApprovalWorkflowRulesParams = { policy: OnyxEntry; }; -/** - * Update an existing workflow using the rules-based backend structure - */ +/** Update an existing workflow using the rules-based backend structure. */ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy}: UpdateApprovalWorkflowRulesParams) { if (!policy) { return; @@ -427,9 +423,7 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); } -/** - * Delete an approval workflow using the rules-based backend structure, gated behind the `multipleApprovers` beta. - */ +/** Delete an approval workflow using the rules-based backend structure, gated behind the `multipleApprovers` beta. */ function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry) { if (!policy) { return; From 0ea1d6a792dc887092a3085412a47d5337e562db Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 15 Jul 2026 14:36:48 +0200 Subject: [PATCH 059/466] better comparison --- src/libs/WorkflowUtils.ts | 27 ++++++++++++++ src/libs/actions/Workflow.ts | 5 ++- .../approvals/ApprovalWorkflowEditor.tsx | 6 ++-- ...paceWorkflowsApprovalsExpensesFromPage.tsx | 25 +++++++++++-- ...aceWorkflowsApprovalsApprovalLimitPage.tsx | 2 +- ...orkspaceWorkflowsApprovalsApproverPage.tsx | 6 ++-- .../WorkspaceWorkflowsApprovalsEditPage.tsx | 2 ++ src/types/onyx/ApprovalWorkflow.ts | 5 +++ tests/unit/WorkflowUtilsTest.ts | 36 +++++++++++++++++++ 9 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 1d07d1efc50a..080dec06cc43 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1431,6 +1431,31 @@ function getRulesSubmitterToFirstApprover(rules: Record, employees: PolicyEmployeeList = {}): Record { + const submitters = new Set(); + for (const rule of Object.values(rules)) { + for (const email of extractSubmitterEmails(rule)) { + submitters.add(email); + } + } + + const result: Record = {}; + for (const submitter of submitters) { + // The fingerprint ignores display/avatar fields, so personal details aren't needed here. + const chain = buildApproverChainFromRules({submitter, rules, employees, personalDetailsByEmail: {}}); + if (chain.length > 0) { + result[submitter] = approverChainFingerprint(chain); + } + } + return result; +} + /** * Beta-enabled counterpart to `convertPolicyEmployeesToApprovalWorkflows`: rebuild the same * `PolicyConversionResult` from `params.rules`, falling back to `employeeList` for any chain step the @@ -1580,6 +1605,7 @@ function convertApprovalWorkflowRulesToWorkflows({ export { applyApprovalWorkflowRulesDiff, + approverChainFingerprint, buildApprovalWorkflowRules, calculateApprovers, convertApprovalWorkflowRulesToWorkflows, @@ -1588,6 +1614,7 @@ export { getApprovalLimitDescription, getApprovalWorkflowRulesForPolicy, getRulesSubmitterToFirstApprover, + getRulesSubmitterToWorkflowKey, getEligibleExistingBusinessBankAccounts, getOpenConnectedToPolicyBusinessBankAccounts, getOverLimitForwardsToDisplayName, diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index e35728abe9ac..9e60e8679d86 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -534,10 +534,12 @@ type SelectApprovalWorkflowForEditParams = { usedApproverEmails: string[]; /** Override for the approvers list (the Edit page uses this to seed an optimistic agent). */ approvers?: Approver[]; + /** Identity anchor of the member whose workflow is being edited, preserved across sub-page back routes. */ + memberEmail?: string; }; /** Commits a workflow to onyx in EDIT mode so any sub-page can be entered directly, skipping the Edit RHP. */ -function selectApprovalWorkflowForEdit({workflow, defaultWorkflowMembers, usedApproverEmails, approvers}: SelectApprovalWorkflowForEditParams) { +function selectApprovalWorkflowForEdit({workflow, defaultWorkflowMembers, usedApproverEmails, approvers, memberEmail}: SelectApprovalWorkflowForEditParams) { setApprovalWorkflow({ ...workflow, approvers: approvers ?? workflow.approvers, @@ -546,6 +548,7 @@ function selectApprovalWorkflowForEdit({workflow, defaultWorkflowMembers, usedAp action: CONST.APPROVAL_WORKFLOW.ACTION.EDIT, errors: null, originalApprovers: workflow.approvers, + memberEmail, }); } diff --git a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx index 9e11be550cb8..27dd3c02b128 100644 --- a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx +++ b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx @@ -148,13 +148,15 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic // Always pass a backTo so that after editing expenses-from (including the invite-a-member detour), // we return to the page we came from. For EDIT that's the edit page; for CREATE we're on the // confirm (new) page, so return there instead of falling through to the Approver step. + // Preserve memberEmail so a fresh edit mount (e.g. after refresh) re-resolves this exact workflow + // instead of the first one sharing firstApproverEmail. const backTo = approvalWorkflow.action === CONST.APPROVAL_WORKFLOW.ACTION.EDIT - ? ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyID, firstApproverEmail) + ? ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyID, firstApproverEmail, approvalWorkflow.memberEmail) : ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(policyID); Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.path, backTo)); - }, [approvalWorkflow.action, approvalWorkflow.originalApprovers, policyID]); + }, [approvalWorkflow.action, approvalWorkflow.originalApprovers, approvalWorkflow.memberEmail, policyID]); // User should be allowed to add additional approver only if they upgraded to Control Plan, otherwise redirected to the Upgrade Page const addAdditionalApprover = useCallback(() => { diff --git a/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx index 5fc32eba8734..e57884726b4e 100644 --- a/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -26,7 +26,7 @@ import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import {canMemberWrite, getDefaultApprover, getExcludedUsers, getMemberAccountIDsForWorkspace, isPendingDeletePolicy} from '@libs/PolicyUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; -import {getApprovalWorkflowRulesForPolicy, getRulesSubmitterToFirstApprover} from '@libs/WorkflowUtils'; +import {approverChainFingerprint, getApprovalWorkflowRulesForPolicy, getRulesSubmitterToFirstApprover, getRulesSubmitterToWorkflowKey} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; @@ -132,6 +132,18 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe return map; })(); + // Beta only: identity (full approver-chain fingerprint) of each submitter's current workflow, plus the + // identity of the workflow being edited. Comparing these — instead of just first approvers — lets us warn + // before moving a member out of a workflow that merely shares its first approver with this one. + const submitterToWorkflowKey = (() => { + if (!isMultipleApproversBetaEnabled) { + return undefined; + } + const rules = getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID); + return new Map(Object.entries(getRulesSubmitterToWorkflowKey(rules, policy?.employeeList ?? {}))); + })(); + const currentWorkflowKey = approverChainFingerprint(approvalWorkflow?.originalApprovers ?? []); + useEffect(() => { if (!approvalWorkflow?.members) { return; @@ -495,7 +507,14 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe const newMember = members.find((m) => !selectedMembers.some((s) => s.login === m.login)); const existingApproverEmail = newMember?.login ? membersInExistingWorkflows.get(newMember.login) : undefined; - if (newMember && existingApproverEmail && existingApproverEmail !== firstApprover) { + // With the beta on, compare full workflow identities so a member that submits to the same + // first approver but through a different chain is still treated as a cross-workflow move. + // Legacy has no diverging chains here, so first-approver comparison is enough. + const belongsToDifferentWorkflow = isMultipleApproversBetaEnabled + ? !!newMember?.login && !!submitterToWorkflowKey?.get(newMember.login) && submitterToWorkflowKey.get(newMember.login) !== currentWorkflowKey + : !!existingApproverEmail && existingApproverEmail !== firstApprover; + + if (newMember && existingApproverEmail && belongsToDifferentWorkflow) { const memberName = Str.removeSMSDomain(newMember.text ?? newMember.login ?? ''); const approverDetails = getPersonalDetailByEmail(existingApproverEmail); const approverName = Str.removeSMSDomain(approverDetails?.displayName ?? existingApproverEmail); @@ -526,6 +545,8 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe firstApprover, selectedMembers, membersInExistingWorkflows, + submitterToWorkflowKey, + currentWorkflowKey, showConfirmModal, translate, ], diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx index 64c101a157a9..28bb421c26e1 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApprovalLimitPage.tsx @@ -99,7 +99,7 @@ function WorkspaceWorkflowsApprovalsApprovalLimitPage({policy, isLoadingReportDa // screen carries extra seed params, so a strict param match misses it and REPLACE mounts // a fresh edit screen that re-derives the workflow from the policy — wiping the unsaved // draft (the seeded agent and any in-progress edits). POP_TO returns to the live screen. - Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyID, firstApprover), {compareParams: false}); + Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyID, firstApprover, approvalWorkflow?.memberEmail), {compareParams: false}); return; } // Mark that we've completed the initial wizard flow before navigating to the summary page diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx index d58de1e3e432..66714ef415d7 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx @@ -153,14 +153,14 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa backToRoute = createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.path, ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID)); clearApprovalWorkflowApprovers(); } else if (approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.EDIT) { - backToRoute = rhpRoutes.length > 1 ? undefined : ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover); + backToRoute = rhpRoutes.length > 1 ? undefined : ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover, approvalWorkflow?.memberEmail); } else { backToRoute = ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(route.params.policyID); } // Don't compare params: the edit screen may carry "Add agent" seed params, so a strict param // match would miss it and REPLACE would mount a fresh edit screen that wipes the unsaved draft. Navigation.goBack(backToRoute, {compareParams: false}); - }, [isInitialCreationFlow, approvalWorkflow?.action, route.params.policyID, rhpRoutes.length, firstApprover]); + }, [isInitialCreationFlow, approvalWorkflow?.action, approvalWorkflow?.memberEmail, route.params.policyID, rhpRoutes.length, firstApprover]); const toggleApprover = useCallback( (approvers: SelectionListApprover[]) => { @@ -172,7 +172,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa clearApprovalWorkflowApprover({approverIndex, currentApprovalWorkflow: approvalWorkflow}); if (isChangeApproverRoute && approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.EDIT) { // Don't compare params — see goBack above. - Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover), {compareParams: false}); + Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover, approvalWorkflow?.memberEmail), {compareParams: false}); return; } goBack(); diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index 3ab51e1801f4..fd97efc37254 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -186,6 +186,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true workflow: currentApprovalWorkflow, defaultWorkflowMembers, usedApproverEmails, + memberEmail: route.params.memberEmail, }); // Snapshot for diffing on save; runs alongside selectApprovalWorkflowForEdit above. setInitialApprovalWorkflow(currentApprovalWorkflow); @@ -197,6 +198,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true policy, route.params.policyID, route.params.firstApproverEmail, + route.params.memberEmail, approvalWorkflow?.action, approvalWorkflow?.originalApprovers, isLoadingApprovalWorkflow, diff --git a/src/types/onyx/ApprovalWorkflow.ts b/src/types/onyx/ApprovalWorkflow.ts index 3ce3ee813231..29eb6d727bfe 100644 --- a/src/types/onyx/ApprovalWorkflow.ts +++ b/src/types/onyx/ApprovalWorkflow.ts @@ -162,6 +162,11 @@ type ApprovalWorkflowOnyx = Omit & { */ originalApprovers: Approver[]; + /** + * Email of the member whose workflow this edit session belongs to. + */ + memberEmail?: string; + /** * Whether the user is in the initial creation flow */ diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 55f4b0f281f8..e9d5784a949f 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -15,6 +15,7 @@ import { getOpenConnectedToPolicyBusinessBankAccounts, getOverLimitForwardsToDisplayName, getRulesSubmitterToFirstApprover, + getRulesSubmitterToWorkflowKey, mergeWorkflowMembersWithAvailableMembers, reconcileApprovalWorkflowRulesForCreate, reconcileApprovalWorkflowRulesForEdit, @@ -1887,6 +1888,41 @@ describe('WorkflowUtils', () => { }); }); + describe('getRulesSubmitterToWorkflowKey', () => { + // Submitters 2 and 3 share the full chain 1 -> 8, while 4 shares only the first approver (1) but + // then diverges to 9. The workflow key must group 2 and 3 together and set 4 apart, so callers can + // tell a real cross-workflow move from a same-first-approver re-add. + const rules: Record = { + sub2: {triggers: submitTriggers, filters: buildFromFilter(['2@example.com']), actions: forwardActions('1@example.com')}, + app2at1: {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), buildToFilter('1@example.com')), actions: forwardActions('8@example.com')}, + app2at8: {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), buildToFilter('8@example.com')), actions: approveActions}, + sub3: {triggers: submitTriggers, filters: buildFromFilter(['3@example.com']), actions: forwardActions('1@example.com')}, + app3at1: {triggers: approveTriggers, filters: and(buildFromFilter(['3@example.com']), buildToFilter('1@example.com')), actions: forwardActions('8@example.com')}, + app3at8: {triggers: approveTriggers, filters: and(buildFromFilter(['3@example.com']), buildToFilter('8@example.com')), actions: approveActions}, + sub4: {triggers: submitTriggers, filters: buildFromFilter(['4@example.com']), actions: forwardActions('1@example.com')}, + app4at1: {triggers: approveTriggers, filters: and(buildFromFilter(['4@example.com']), buildToFilter('1@example.com')), actions: forwardActions('9@example.com')}, + app4at9: {triggers: approveTriggers, filters: and(buildFromFilter(['4@example.com']), buildToFilter('9@example.com')), actions: approveActions}, + }; + + it('Should give submitters with identical chains the same key', () => { + const keys = getRulesSubmitterToWorkflowKey(rules); + + expect(keys['2@example.com']).toBe(keys['3@example.com']); + }); + + it('Should give submitters that share a first approver but diverge later different keys', () => { + const keys = getRulesSubmitterToWorkflowKey(rules); + + // All three route to approver 1 first, so first-approver alone cannot distinguish them. + expect(getRulesSubmitterToFirstApprover(rules)).toEqual({ + '2@example.com': '1@example.com', + '3@example.com': '1@example.com', + '4@example.com': '1@example.com', + }); + expect(keys['4@example.com']).not.toBe(keys['2@example.com']); + }); + }); + describe('convertApprovalWorkflowRulesToWorkflows', () => { const createPolicy = (employees: PolicyEmployeeList, defaultApprover: string) => ({ From d28316ff60db390f181c5d4dabbcc0a2bdbb2709 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 15 Jul 2026 15:18:58 +0200 Subject: [PATCH 060/466] general cleanup --- src/libs/WorkflowUtils.ts | 22 ++--- src/libs/actions/Policy/Policy.ts | 7 +- src/libs/actions/Workflow.ts | 4 +- .../workflows/WorkspaceWorkflowsPage.tsx | 4 +- ...paceWorkflowsApprovalsExpensesFromPage.tsx | 13 +-- src/types/onyx/ApprovalWorkflowRules.ts | 7 -- tests/actions/WorkflowTest.ts | 96 +++++++------------ tests/unit/WorkflowUtilsTest.ts | 13 ++- 8 files changed, 63 insertions(+), 103 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 080dec06cc43..653575b28b19 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -740,7 +740,6 @@ function mergeWorkflowMembersWithAvailableMembers(workflowMembers: Member[], all type ApprovalWorkflowRulesDiff = Record; -/** Build a comparison filter: ` `. */ function buildComparison( operator: ValueOf, left: ApprovalWorkflowFilterComparison['left'], @@ -749,39 +748,37 @@ function buildComparison( return {operator, left, right}; } -/** Combine two filter nodes with a boolean `AND`. */ function buildAnd(left: ApprovalWorkflowFilter['left'], right: ApprovalWorkflowFilter['right']): ApprovalWorkflowFilter { return {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left, right}; } -/** Build the `from = [emails]` filter used by every approval-workflow rule. Kept at the top-level left of the tree. */ function buildSubmitterFilter(memberEmails: string[]): ApprovalWorkflowFilterComparison { return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, [...memberEmails]); } -/** Build the `to = email` filter that matches the approver who just approved the report. */ function buildToComparison(email: string): ApprovalWorkflowFilterComparison { return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, email); } -/** A rule that fires when a report is submitted. */ +/** The index-keyed object shape the rules API uses for lists (`['a', 'b'] -> {'0': 'a', '1': 'b'}`). */ +function toIndexMap(values: T[]): Record { + return Object.fromEntries(values.map((value, index) => [String(index), value])); +} + function buildSubmitTriggers(): ApprovalWorkflowTriggers { - return {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}; + return toIndexMap([CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT]); } -/** A rule that fires when a report is approved. */ function buildApproveTriggers(): ApprovalWorkflowTriggers { - return {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}; + return toIndexMap([CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE]); } -/** Forward the report to `approver`. */ function buildForwardActions(approver: string): ApprovalWorkflowActions { - return {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver}}; + return toIndexMap([{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver}]); } -/** Approve (finalize) the report. */ function buildApproveActions(): ApprovalWorkflowActions { - return {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}; + return toIndexMap([{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}]); } /** @@ -1611,6 +1608,7 @@ export { convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, + extractSubmitterEmails, getApprovalLimitDescription, getApprovalWorkflowRulesForPolicy, getRulesSubmitterToFirstApprover, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 6243e463e953..a7803b645de5 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -320,7 +320,7 @@ Onyx.connect({ }); let allRules: OnyxCollection; -Onyx.connect({ +Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.RULE, waitForCollectionCallback: true, callback: (val) => (allRules = val), @@ -1028,8 +1028,9 @@ function setWorkspaceApprovalMode( if (!rule || rule.scope !== CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY || rule.scopeID !== policyID) { continue; } - optimisticData.push({onyxMethod: Onyx.METHOD.SET, key: ruleKey as `${typeof ONYXKEYS.COLLECTION.RULE}${string}`, value: null}); - failureData.push({onyxMethod: Onyx.METHOD.SET, key: ruleKey as `${typeof ONYXKEYS.COLLECTION.RULE}${string}`, value: rule}); + const ruleOnyxKey: `${typeof ONYXKEYS.COLLECTION.RULE}${string}` = `${ONYXKEYS.COLLECTION.RULE}${ruleKey.slice(ONYXKEYS.COLLECTION.RULE.length)}`; + optimisticData.push({onyxMethod: Onyx.METHOD.SET, key: ruleOnyxKey, value: null}); + failureData.push({onyxMethod: Onyx.METHOD.SET, key: ruleOnyxKey, value: rule}); } } diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 9e60e8679d86..41c790d41d09 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -37,7 +37,7 @@ import {completeTask} from './Task'; /** Cache of every approval-workflow rule (`ONYXKEYS.COLLECTION.RULE`) so actions can read the current rule set. */ let allRules: OnyxCollection = {}; -Onyx.connect({ +Onyx.connectWithoutView({ key: ONYXKEYS.COLLECTION.RULE, waitForCollectionCallback: true, callback: (value) => { @@ -423,7 +423,7 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); } -/** Delete an approval workflow using the rules-based backend structure, gated behind the `multipleApprovers` beta. */ +/** Delete an approval workflow using the rules-based backend structure. */ function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry) { if (!policy) { return; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 568979141e6e..752764166d98 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -570,8 +570,8 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { /> {displayedWorkflows.map((workflow) => { const firstApproverEmail = workflow.approvers.at(0)?.email ?? ''; - // A workflow's first approver isn't unique once rule-based chains diverge, but a member - // belongs to exactly one workflow + // The first approver isn't unique once rule-based chains diverge, so anchor the key/edit route + // on a member too (each member belongs to exactly one workflow). const firstMemberEmail = workflow.members.at(0)?.email ?? ''; return ( diff --git a/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx index e57884726b4e..d59d3c151cfe 100644 --- a/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/DynamicWorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -107,13 +107,14 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe const isCreateAction = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE; const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); + const policyRules = isMultipleApproversBetaEnabled ? getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID) : {}; + // Build a map of member emails to their existing workflow's first approver. With the beta on this // is derived from the `ONYXKEYS.COLLECTION.RULE` rules; otherwise it falls back to the legacy // employeeList `submitsTo` (non-default workflows only). const membersInExistingWorkflows = (() => { if (isMultipleApproversBetaEnabled) { - const rules = getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID); - return new Map(Object.entries(getRulesSubmitterToFirstApprover(rules, policy?.employeeList ?? {}))); + return new Map(Object.entries(getRulesSubmitterToFirstApprover(policyRules, policy?.employeeList ?? {}))); } const employees = policy?.employeeList ?? {}; @@ -135,13 +136,7 @@ function DynamicWorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingRe // Beta only: identity (full approver-chain fingerprint) of each submitter's current workflow, plus the // identity of the workflow being edited. Comparing these — instead of just first approvers — lets us warn // before moving a member out of a workflow that merely shares its first approver with this one. - const submitterToWorkflowKey = (() => { - if (!isMultipleApproversBetaEnabled) { - return undefined; - } - const rules = getApprovalWorkflowRulesForPolicy(rulesCollection, route.params.policyID); - return new Map(Object.entries(getRulesSubmitterToWorkflowKey(rules, policy?.employeeList ?? {}))); - })(); + const submitterToWorkflowKey = isMultipleApproversBetaEnabled ? new Map(Object.entries(getRulesSubmitterToWorkflowKey(policyRules, policy?.employeeList ?? {}))) : undefined; const currentWorkflowKey = approverChainFingerprint(approvalWorkflow?.originalApprovers ?? []); useEffect(() => { diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index 6d1883ad3981..838baabb4163 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -91,12 +91,6 @@ type ApprovalWorkflowRule = { actions: ApprovalWorkflowActions; }; -/** - * Map of approval-workflow rule bodies keyed by ruleID. Used as the in-memory working shape by the - * rules builder/reconcilers; on disk each rule lives under its own `ONYXKEYS.COLLECTION.RULE` key. - */ -type ApprovalWorkflowRules = Record; - export type { ApprovalWorkflowAction, ApprovalWorkflowActionName, @@ -105,7 +99,6 @@ export type { ApprovalWorkflowFilterComparison, ApprovalWorkflowFilterOperand, ApprovalWorkflowRule, - ApprovalWorkflowRules, ApprovalWorkflowTrigger, ApprovalWorkflowTriggers, }; diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 941120118eb4..15f84ba0bde1 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -14,7 +14,7 @@ import { updateApprovalWorkflow, updateApprovalWorkflowRules, } from '@src/libs/actions/Workflow'; -import {calculateApprovers} from '@src/libs/WorkflowUtils'; +import {calculateApprovers, extractSubmitterEmails} from '@src/libs/WorkflowUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ApprovalWorkflowOnyx, PersonalDetailsList, Policy, Policy as PolicyType, Report} from '@src/types/onyx'; import type {Approver} from '@src/types/onyx/ApprovalWorkflow'; @@ -74,6 +74,33 @@ async function getActivePolicyRules(policyID: string): Promise { ); } +/** Build the index-keyed object shape the rules API uses for lists (`['a'] -> {'0': 'a'}`). */ +function indexMap(...values: T[]): Record { + return Object.fromEntries(values.map((value, index) => [String(index), value])); +} + +/** Seed the two rules (submit -> forward, approve -> finalize) that describe a `submitters -> approver` workflow. */ +async function seedForwardApproveRules(policyID: string, submitters: string[], approver: string) { + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule1`, { + scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scopeID: policyID, + triggers: indexMap(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT), + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: submitters}, + actions: indexMap({name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver}), + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule2`, { + scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scopeID: policyID, + triggers: indexMap(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE), + filters: { + operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, + left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: submitters}, + right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: approver}, + }, + actions: indexMap({name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}), + }); +} + describe('actions/Workflow', () => { function getApprovalWorkflowState(): Promise { return new Promise((resolve) => { @@ -931,24 +958,7 @@ describe('actions/Workflow', () => { }; // Existing workflow: employee1 (A) and employee2 (B) submit to owner (C). - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule1`, { - scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, - scopeID: policyID, - triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}, - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email, employee2Email]}, - actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: ownerEmail}}, - }); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule2`, { - scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, - scopeID: policyID, - triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email, employee2Email]}, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: ownerEmail}, - }, - actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}, - }); + await seedForwardApproveRules(policyID, [employee1Email, employee2Email], ownerEmail); // New workflow: employee3 (D) submits to owner (C) — same chain as the existing workflow. const approvalWorkflow = { @@ -972,10 +982,7 @@ describe('actions/Workflow', () => { // D should be folded into the two existing rules, not create a third/fourth rule. expect(rules).toHaveLength(2); for (const rule of rules) { - const fromLeaf = Object.values(rule.actions).some((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO) - ? rule.filters - : (rule.filters as {left: unknown}).left; - expect((fromLeaf as {right: string[]}).right).toEqual([employee1Email, employee2Email, employee3Email]); + expect(extractSubmitterEmails(rule)).toEqual([employee1Email, employee2Email, employee3Email]); } await mockFetch.resume(); @@ -1031,10 +1038,7 @@ describe('actions/Workflow', () => { // Still only two rules, each now listing all three submitters. expect(rulesAfterSecond).toHaveLength(2); for (const rule of rulesAfterSecond) { - const fromLeaf = Object.values(rule.actions).some((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO) - ? rule.filters - : (rule.filters as {left: unknown}).left; - expect((fromLeaf as {right: string[]}).right).toEqual([employee1Email, employee2Email, employee3Email]); + expect(extractSubmitterEmails(rule)).toEqual([employee1Email, employee2Email, employee3Email]); } await mockFetch.resume(); @@ -1055,24 +1059,7 @@ describe('actions/Workflow', () => { }; // Seed the existing [employee1] → [owner] workflow as rules in the collection. - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule1`, { - scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, - scopeID: policyID, - triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}, - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, - actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: ownerEmail}}, - }); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule2`, { - scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, - scopeID: policyID, - triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: ownerEmail}, - }, - actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}, - }); + await seedForwardApproveRules(policyID, [employee1Email], ownerEmail); const initialApprovalWorkflow = { members: [{email: employee1Email, displayName: employee1Email}], @@ -1122,24 +1109,7 @@ describe('actions/Workflow', () => { }; // Seed the existing [employee1] → [owner] workflow as rules in the collection. - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule1`, { - scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, - scopeID: policyID, - triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}, - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, - actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: ownerEmail}}, - }); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule2`, { - scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, - scopeID: policyID, - triggers: {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}, - filters: { - operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, - left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}, - right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: ownerEmail}, - }, - actions: {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}, - }); + await seedForwardApproveRules(policyID, [employee1Email], ownerEmail); const approvalWorkflow = { members: [{email: employee1Email, displayName: employee1Email}], diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index e9d5784a949f..1ba3f56433cf 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -10,6 +10,7 @@ import { convertApprovalWorkflowRulesToWorkflows, convertApprovalWorkflowToPolicyEmployees, convertPolicyEmployeesToApprovalWorkflows, + extractSubmitterEmails, getApprovalLimitDescription, getEligibleExistingBusinessBankAccounts, getOpenConnectedToPolicyBusinessBankAccounts, @@ -1694,6 +1695,8 @@ describe('WorkflowUtils', () => { right, }); const keyRules = (rules: ApprovalWorkflowRule[]): Record => Object.fromEntries(rules.map((rule, index) => [`rule${index}`, rule])); + // Present a list as the array-shaped value the API returns for `triggers`/`actions` (PHP decodes index maps to arrays). + const asServerShape = (value: unknown[]): T => value as unknown as T; describe('buildApprovalWorkflowRules', () => { it('Should return an empty list when there are no members', () => { @@ -1793,14 +1796,14 @@ describe('WorkflowUtils', () => { // new submitter folds into the existing rule instead of minting a fresh pair. const existingRules: Record = { r1: { - actions: [{approver: '1@example.com', name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO}] as unknown as ApprovalWorkflowRule['actions'], + actions: asServerShape([{approver: '1@example.com', name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO}]), filters: buildFromFilter(['20@example.com']), - triggers: [CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT] as unknown as ApprovalWorkflowRule['triggers'], + triggers: asServerShape([CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT]), }, r2: { - actions: [{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}] as unknown as ApprovalWorkflowRule['actions'], + actions: asServerShape([{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}]), filters: and(buildFromFilter(['20@example.com']), buildToFilter('1@example.com')), - triggers: [CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE] as unknown as ApprovalWorkflowRule['triggers'], + triggers: asServerShape([CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE]), }, }; const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); @@ -1808,7 +1811,7 @@ describe('WorkflowUtils', () => { // Folds into the two existing rules (r1/r2) instead of creating fresh IDs, and appends the submitter. expect(Object.keys(diff).sort()).toEqual(['r1', 'r2']); - expect((diff.r1?.filters as {right: string[]}).right).toEqual(['20@example.com', '10@example.com']); + expect(diff.r1 ? extractSubmitterEmails(diff.r1) : []).toEqual(['20@example.com', '10@example.com']); }); }); From 3d449edb4977e6caa6f04e77fc1eff9d74a9aba4 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 15 Jul 2026 15:33:24 +0200 Subject: [PATCH 061/466] renaming --- .../{ApprovalWorkflowParams.ts => SetApprovalWorkflowParams.ts} | 0 src/libs/API/parameters/index.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/libs/API/parameters/{ApprovalWorkflowParams.ts => SetApprovalWorkflowParams.ts} (100%) diff --git a/src/libs/API/parameters/ApprovalWorkflowParams.ts b/src/libs/API/parameters/SetApprovalWorkflowParams.ts similarity index 100% rename from src/libs/API/parameters/ApprovalWorkflowParams.ts rename to src/libs/API/parameters/SetApprovalWorkflowParams.ts diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 66a5c6194c1a..314877754206 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -546,7 +546,7 @@ export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; export type {default as SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams} from './SetPolicyCategoryReceiptsAndItemizedReceiptRequiredParams'; export type {default as SetPolicyCodingRuleParams} from './SetPolicyCodingRuleParams'; -export type {default as SetApprovalWorkflowParams} from './ApprovalWorkflowParams'; +export type {default as SetApprovalWorkflowParams} from './SetApprovalWorkflowParams'; export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; export type {default as RevokeMultifactorAuthenticationCredentialsParams} from './RevokeMultifactorAuthenticationCredentialsParams'; export type {default as TroubleshootMultifactorAuthenticationParams} from './TroubleshootMultifactorAuthenticationParams'; From ea1de108ca2e11018399bdf999329073baa99fbb Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 15 Jul 2026 15:40:35 +0200 Subject: [PATCH 062/466] small refactor --- src/libs/WorkflowUtils.ts | 7 ++++++- src/libs/actions/Workflow.ts | 9 +++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 653575b28b19..9f8ce6820260 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -786,8 +786,12 @@ function buildApproveActions(): ApprovalWorkflowActions { * reports to the first approver, plus a `ReportApprove` rule per approver describing the next hop * (forward to the next approver, approve if last, or split on the approver's over-limit target). */ +function getWorkflowMemberEmails(members: Member[]): string[] { + return members.map((member) => member.email).filter((email): email is string => !!email); +} + function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): ApprovalWorkflowRule[] { - const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); + const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); const approvers = approvalWorkflow.approvers; if (memberEmails.length === 0 || approvers.length === 0) { @@ -1613,6 +1617,7 @@ export { getApprovalWorkflowRulesForPolicy, getRulesSubmitterToFirstApprover, getRulesSubmitterToWorkflowKey, + getWorkflowMemberEmails, getEligibleExistingBusinessBankAccounts, getOpenConnectedToPolicyBusinessBankAccounts, getOverLimitForwardsToDisplayName, diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 41c790d41d09..00d4d59b722d 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -11,6 +11,7 @@ import { convertApprovalWorkflowToPolicyEmployees, getApprovalWorkflowRulesForPolicy, getOverLimitForwardsToDisplayName, + getWorkflowMemberEmails, mergeWorkflowMembersWithAvailableMembers, reconcileApprovalWorkflowRulesForCreate, reconcileApprovalWorkflowRulesForEdit, @@ -366,7 +367,7 @@ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprov } const existingRules = getApprovalWorkflowRulesForPolicy(allRules, policy.id); - const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); + const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); // A submitter can only belong to one workflow, so first drop these members from any OTHER // workflow's rules, then add them to the new workflow. @@ -401,8 +402,8 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, } const existingRules = getApprovalWorkflowRulesForPolicy(allRules, policy.id); - const previousMemberEmails = initialApprovalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); - const newMemberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); + const previousMemberEmails = getWorkflowMemberEmails(initialApprovalWorkflow.members); + const newMemberEmails = getWorkflowMemberEmails(approvalWorkflow.members); // 1. A submitter can only belong to one workflow, so drop joining members (those not previously in // this workflow) from any OTHER workflow's rules first. @@ -430,7 +431,7 @@ function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: } const existingRules = getApprovalWorkflowRulesForPolicy(allRules, policy.id); - const memberEmails = approvalWorkflow.members.map((member) => member.email).filter((email): email is string => !!email); + const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); const rulesDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); From f879ada7b797c9796fe26110f60c6a9d0561a5f7 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 15 Jul 2026 16:01:24 +0200 Subject: [PATCH 063/466] Add missing file --- src/types/onyx/Rule.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/types/onyx/Rule.ts diff --git a/src/types/onyx/Rule.ts b/src/types/onyx/Rule.ts new file mode 100644 index 000000000000..65a7e8c0d9ab --- /dev/null +++ b/src/types/onyx/Rule.ts @@ -0,0 +1,39 @@ +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +import type {ApprovalWorkflowRule} from './ApprovalWorkflowRules'; +import type {Errors, OnyxValueWithOfflineFeedback} from './OnyxCommon'; + +/** The kind of entity a rule is scoped to (currently only workspace policies). */ +type RuleScope = ValueOf; + +/** + * A rule as stored in the `ONYXKEYS.COLLECTION.RULE` collection under `rules_`. This mirrors a + * row of the server `rules` table: the AST rule body (`triggers`/`filters`/`actions`) plus the + * `scope`/`scopeID` that associate it with an entity (a policy, for approval workflows). + * + * The same collection is intended to hold every kind of AST rule in the future, not just approval + * workflows. + */ +type Rule = OnyxValueWithOfflineFeedback< + ApprovalWorkflowRule & { + /** What kind of entity this rule is scoped to. */ + scope: RuleScope; + + /** ID of the scoped entity (the policyID for `policy`-scoped rules). */ + scopeID: string; + + /** ISO timestamp of when the rule was created. */ + created?: string; + + /** Errors from the latest failed write of this rule. */ + errors?: Errors; + } +>; + +/** A collection of rules keyed by their `ONYXKEYS.COLLECTION.RULE` key (`rules_`). */ +type RuleCollection = Record; + +export default Rule; +export type {RuleCollection, RuleScope}; From 9c651e0d9b6fa114c5ac1b50f68e86c4520c45e2 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 15 Jul 2026 16:10:50 +0200 Subject: [PATCH 064/466] knip --- src/types/onyx/ApprovalWorkflowRules.ts | 12 +----------- src/types/onyx/Rule.ts | 4 ---- src/types/onyx/index.ts | 2 -- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index 838baabb4163..2f78e569e315 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -91,14 +91,4 @@ type ApprovalWorkflowRule = { actions: ApprovalWorkflowActions; }; -export type { - ApprovalWorkflowAction, - ApprovalWorkflowActionName, - ApprovalWorkflowActions, - ApprovalWorkflowFilter, - ApprovalWorkflowFilterComparison, - ApprovalWorkflowFilterOperand, - ApprovalWorkflowRule, - ApprovalWorkflowTrigger, - ApprovalWorkflowTriggers, -}; +export type {ApprovalWorkflowAction, ApprovalWorkflowActions, ApprovalWorkflowFilter, ApprovalWorkflowFilterComparison, ApprovalWorkflowRule, ApprovalWorkflowTriggers}; diff --git a/src/types/onyx/Rule.ts b/src/types/onyx/Rule.ts index 65a7e8c0d9ab..e49d8c58e8ce 100644 --- a/src/types/onyx/Rule.ts +++ b/src/types/onyx/Rule.ts @@ -32,8 +32,4 @@ type Rule = OnyxValueWithOfflineFeedback< } >; -/** A collection of rules keyed by their `ONYXKEYS.COLLECTION.RULE` key (`rules_`). */ -type RuleCollection = Record; - export default Rule; -export type {RuleCollection, RuleScope}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index b1e055cad4a0..a05252052bb3 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -161,7 +161,6 @@ import type {AnyRequest} from './Request'; import type Response from './Response'; import type ReviewDuplicates from './ReviewDuplicates'; import type Rule from './Rule'; -import type {RuleCollection} from './Rule'; import type {SavedCSVColumnLayoutData, SavedCSVColumnLayoutList} from './SavedCSVColumnLayout'; import type {SaveSearch} from './SaveSearch'; import type ScheduleCallDraft from './ScheduleCallDraft'; @@ -310,7 +309,6 @@ export type { ReimbursementAccount, Report, Rule, - RuleCollection, ReportNameValuePairs, ReportAction, ReportActionReactions, From ee13b8cfc193ebfb4a256c5171b97b3c6ce3a3a1 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 15 Jul 2026 16:37:26 +0200 Subject: [PATCH 065/466] fix test --- src/libs/ExportOnyxState/common.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index cf4ad366f553..84fe0d63841a 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -334,6 +334,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING, + ONYXKEYS.COLLECTION.RULE, ONYXKEYS.COLLECTION.SAML_METADATA, ONYXKEYS.COLLECTION.SECURITY_GROUP, ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT, From 6ecaed33db0cccb8187d9af89adee19c6c656ae3 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Wed, 15 Jul 2026 22:49:09 +0700 Subject: [PATCH 066/466] Add intl deposit account collection --- src/CONST/index.ts | 14 +-- src/languages/de.ts | 4 + src/languages/en.ts | 4 + src/languages/es.ts | 4 + src/languages/fr.ts | 4 + src/languages/it.ts | 4 + src/languages/ja.ts | 4 + src/languages/nl.ts | 4 + src/languages/pl.ts | 4 + src/languages/pt-BR.ts | 4 + src/languages/zh-hans.ts | 4 + .../AddPersonalBankAccountParams.ts | 2 + src/libs/actions/BankAccounts.ts | 2 + .../InternationalDepositAccountContent.tsx | 27 ++++-- .../PersonalInfo/PersonalInfo.tsx | 21 ++++- .../InternationalBankAccountDetailsStep.tsx | 88 +++++++++++++++++++ .../utils/getSkippedStepsPersonalInfo.ts | 18 ++-- .../InternationalBankAccountDetails.tsx | 87 ++++++++++++++++++ src/selectors/Policy.ts | 13 +++ src/types/form/PersonalBankAccountForm.ts | 4 + src/types/onyx/Policy.ts | 9 ++ 21 files changed, 306 insertions(+), 19 deletions(-) create mode 100644 src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx create mode 100644 src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7d37ca62ac30..c7e79f04b734 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -738,6 +738,8 @@ const CONST = { // The back-end is always returning account number with 4 last digits and mask the rest with X MASKED_US_ACCOUNT_NUMBER: /^[X]{0,13}[0-9]{4}$/, SWIFT_BIC: /^[A-Za-z0-9]{8,11}$/, + IBAN: /^[a-zA-Z]{2}[0-9]{2}[a-zA-Z0-9]{4}[0-9]{7}([a-zA-Z0-9]?){0,16}$/, + INTERNATIONAL_SWIFT_CODE: /^.{1,12}$/, }, STATE: { VERIFYING: 'VERIFYING', @@ -7972,6 +7974,7 @@ const CONST = { PAGE_NAME: { COUNTRY: 'country', ACCOUNT_DETAILS: 'account-details', + INTERNATIONAL_BANK_ACCOUNT_DETAILS: 'international-bank-account-details', ACCOUNT_TYPE: 'account-type', BANK_INFORMATION: 'bank-information', ACCOUNT_HOLDER_DETAILS: 'account-holder-details', @@ -7982,11 +7985,12 @@ const CONST = { MAPPING: { COUNTRY_SELECTOR: 0, BANK_ACCOUNT_DETAILS: 1, - ACCOUNT_TYPE: 2, - BANK_INFORMATION: 3, - ACCOUNT_HOLDER_INFORMATION: 4, - CONFIRMATION: 5, - SUCCESS: 6, + INTERNATIONAL_BANK_ACCOUNT_DETAILS: 2, + ACCOUNT_TYPE: 3, + BANK_INFORMATION: 4, + ACCOUNT_HOLDER_INFORMATION: 5, + CONFIRMATION: 6, + SUCCESS: 7, }, }, }, diff --git a/src/languages/de.ts b/src/languages/de.ts index ab7159789b5f..a2d7aacb9f4f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3636,6 +3636,9 @@ ${amount} für ${merchant} – ${date}`, thisBankAccount: 'Dieses Bankkonto wird für Geschäftszahlungen in deinem Workspace verwendet', accountNumber: 'Kontonummer', routingNumber: 'Routing-Nummer', + internationalBankAccountDetails: 'Internationale Bankkontodaten', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BIC-Code', chooseAnAccountBelow: 'Wähle unten ein Konto aus', addBankAccount: 'Bankkonto hinzufügen', chooseAnAccount: 'Konto auswählen', @@ -3686,6 +3689,7 @@ ${amount} für ${merchant} – ${date}`, restrictedBusiness: 'Bitte bestätigen Sie, dass das Unternehmen nicht auf der Liste der eingeschränkten Unternehmen steht.', routingNumber: 'Bitte geben Sie eine gültige Bankleitzahl ein', accountNumber: 'Bitte eine gültige Kontonummer eingeben', + swiftCodeOrIban: 'Bitte gib einen gültigen Swift-Code/IBAN ein', routingAndAccountNumberCannotBeSame: 'Bankleitzahl und Kontonummer dürfen nicht identisch sein', companyType: 'Bitte wähle einen gültigen Unternehmenstyp aus', tooManyAttempts: diff --git a/src/languages/en.ts b/src/languages/en.ts index b38453141ed1..6c87a7f7efe2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3734,6 +3734,9 @@ const translations = { thisBankAccount: 'This bank account will be used for business payments on your workspace', accountNumber: 'Account number', routingNumber: 'Routing number', + internationalBankAccountDetails: 'International bank account details', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BIC code', chooseAnAccountBelow: 'Choose an account below', addBankAccount: 'Add bank account', chooseAnAccount: 'Choose an account', @@ -3783,6 +3786,7 @@ const translations = { restrictedBusiness: "Please confirm the business isn't on the list of restricted businesses", routingNumber: 'Please enter a valid routing number', accountNumber: 'Please enter a valid account number', + swiftCodeOrIban: 'Please enter a valid Swift code/IBAN', routingAndAccountNumberCannotBeSame: "Routing and account numbers can't match", companyType: 'Please select a valid company type', tooManyAttempts: 'Due to a high number of login attempts, this option has been disabled for 24 hours. Please try again later or enter details manually instead.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 80f5e64f2d4c..72489554684e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3508,6 +3508,9 @@ ${amount} para ${merchant} - ${date}`, thisBankAccount: 'Esta cuenta bancaria se utilizará para pagos comerciales en tu espacio de trabajo', accountNumber: 'Número de cuenta', routingNumber: 'Número de ruta', + internationalBankAccountDetails: 'Datos de la cuenta bancaria internacional', + iban: 'IBAN', + swiftBicCode: 'Código SWIFT/BIC', chooseAnAccountBelow: 'Elige una cuenta a continuación', addBankAccount: 'Añadir cuenta bancaria', chooseAnAccount: 'Elige una cuenta', @@ -3557,6 +3560,7 @@ ${amount} para ${merchant} - ${date}`, restrictedBusiness: 'Por favor, confirma que la empresa no está en la lista de negocios restringidos', routingNumber: 'Por favor, introduce un número de ruta válido', accountNumber: 'Por favor, introduce un número de cuenta válido', + swiftCodeOrIban: 'Por favor, introduce un código Swift/IBAN válido', routingAndAccountNumberCannotBeSame: 'Los números de ruta y de cuenta no pueden ser iguales', companyType: 'Por favor, selecciona un tipo de compañía válido', tooManyAttempts: diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2c80ad56187f..abf21cf6a17c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3646,6 +3646,9 @@ ${amount} pour ${merchant} - ${date}`, thisBankAccount: 'Ce compte bancaire sera utilisé pour les paiements professionnels dans votre espace de travail', accountNumber: 'Numéro de compte', routingNumber: 'Numéro de routage', + internationalBankAccountDetails: 'Coordonnées bancaires internationales', + iban: 'IBAN', + swiftBicCode: 'Code SWIFT/BIC', chooseAnAccountBelow: 'Choisissez un compte ci-dessous', addBankAccount: 'Ajouter un compte bancaire', chooseAnAccount: 'Choisissez un compte', @@ -3696,6 +3699,7 @@ ${amount} pour ${merchant} - ${date}`, restrictedBusiness: 'Veuillez confirmer que l’entreprise ne figure pas sur la liste des entreprises restreintes', routingNumber: 'Veuillez saisir un numéro d’acheminement valide', accountNumber: 'Veuillez saisir un numéro de compte valide', + swiftCodeOrIban: 'Veuillez saisir un code Swift/IBAN valide', routingAndAccountNumberCannotBeSame: 'Le code de routage et le numéro de compte ne peuvent pas être identiques', companyType: 'Veuillez sélectionner un type d’entreprise valide', tooManyAttempts: diff --git a/src/languages/it.ts b/src/languages/it.ts index c6cce232c27a..73a344d03e2c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3627,6 +3627,9 @@ ${amount} per ${merchant} - ${date}`, thisBankAccount: 'Questo conto bancario sarà utilizzato per i pagamenti aziendali nel tuo spazio di lavoro', accountNumber: 'Numero di conto', routingNumber: 'Numero di instradamento', + internationalBankAccountDetails: 'Dati del conto bancario internazionale', + iban: 'IBAN', + swiftBicCode: 'Codice SWIFT/BIC', chooseAnAccountBelow: 'Scegli un account qui sotto', addBankAccount: 'Aggiungi conto bancario', chooseAnAccount: 'Scegli un account', @@ -3676,6 +3679,7 @@ ${amount} per ${merchant} - ${date}`, restrictedBusiness: 'Conferma che l’azienda non sia nell’elenco delle attività soggette a restrizioni', routingNumber: 'Inserisci un numero di instradamento valido', accountNumber: 'Inserisci un numero di conto valido', + swiftCodeOrIban: 'Inserisci un codice Swift/IBAN valido', routingAndAccountNumberCannotBeSame: 'I numeri di routing e di conto non possono coincidere', companyType: 'Seleziona un tipo di azienda valido', tooManyAttempts: 'A causa di un numero elevato di tentativi di accesso, questa opzione è stata disabilitata per 24 ore. Riprova più tardi o inserisci invece i dati manualmente.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index cd0d1dd46a70..353967b497a2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3594,6 +3594,9 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' thisBankAccount: 'この銀行口座は、ワークスペースでのビジネス支払いに使用されます', accountNumber: '口座番号', routingNumber: 'ルーティング番号', + internationalBankAccountDetails: '海外銀行口座情報', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BICコード', chooseAnAccountBelow: '下のアカウントを選択してください', addBankAccount: '銀行口座を追加', chooseAnAccount: 'アカウントを選択', @@ -3643,6 +3646,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' restrictedBusiness: 'ビジネスが制限対象の事業一覧に含まれていないことを確認してください', routingNumber: '有効なルーティング番号を入力してください', accountNumber: '有効な口座番号を入力してください', + swiftCodeOrIban: '有効なSwiftコード/IBANを入力してください', routingAndAccountNumberCannotBeSame: 'ルーティング番号と口座番号を同じにすることはできません', companyType: '有効な会社の種類を選択してください', tooManyAttempts: 'ログイン試行回数が多すぎるため、このオプションは24時間無効になっています。時間をおいてから再度お試しいただくか、代わりに詳細情報を手動で入力してください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 983cfb709e44..7de9e185ddeb 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3623,6 +3623,9 @@ ${amount} voor ${merchant} - ${date}`, thisBankAccount: 'Deze bankrekening wordt gebruikt voor zakelijke betalingen in je workspace', accountNumber: 'Rekeningnummer', routingNumber: 'Routingnummer', + internationalBankAccountDetails: 'Internationale bankrekeninggegevens', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BIC-code', chooseAnAccountBelow: 'Kies een account hieronder', addBankAccount: 'Bankrekening toevoegen', chooseAnAccount: 'Kies een account', @@ -3672,6 +3675,7 @@ ${amount} voor ${merchant} - ${date}`, restrictedBusiness: 'Bevestig dat het bedrijf niet op de lijst met beperkte bedrijven staat', routingNumber: 'Voer een geldig routingnummer in', accountNumber: 'Voer een geldig rekeningnummer in', + swiftCodeOrIban: 'Voer een geldige Swift-code/IBAN in', routingAndAccountNumberCannotBeSame: 'Routing- en rekeningnummers mogen niet hetzelfde zijn', companyType: 'Selecteer een geldig bedrijfstype', tooManyAttempts: 'Vanwege een groot aantal inlogpogingen is deze optie voor 24 uur uitgeschakeld. Probeer het later opnieuw of voer de gegevens in plaats daarvan handmatig in.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8ba7155c1df9..ec12c9b3686d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3608,6 +3608,9 @@ ${amount} dla ${merchant} - ${date}`, thisBankAccount: 'To konto bankowe będzie używane do płatności firmowych w Twoim obszarze roboczym', accountNumber: 'Numer konta', routingNumber: 'Numer rozliczeniowy', + internationalBankAccountDetails: 'Dane międzynarodowego konta bankowego', + iban: 'IBAN', + swiftBicCode: 'Kod SWIFT/BIC', chooseAnAccountBelow: 'Wybierz konto poniżej', addBankAccount: 'Dodaj konto bankowe', chooseAnAccount: 'Wybierz konto', @@ -3657,6 +3660,7 @@ ${amount} dla ${merchant} - ${date}`, restrictedBusiness: 'Potwierdź, że firma nie znajduje się na liście działalności objętych ograniczeniami', routingNumber: 'Wprowadź prawidłowy numer rozliczeniowy', accountNumber: 'Wprowadź prawidłowy numer konta', + swiftCodeOrIban: 'Wprowadź prawidłowy kod Swift/IBAN', routingAndAccountNumberCannotBeSame: 'Numer rozliczeniowy i numer konta nie mogą być takie same', companyType: 'Wybierz prawidłowy typ firmy', tooManyAttempts: 'Ze względu na dużą liczbę prób logowania ta opcja została wyłączona na 24 godziny. Spróbuj ponownie później lub wprowadź dane ręcznie.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 6e1b8d8a3984..12d08613e967 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3612,6 +3612,9 @@ ${amount} para ${merchant} - ${date}`, thisBankAccount: 'Esta conta bancária será usada para pagamentos empresariais no seu workspace', accountNumber: 'Número da conta', routingNumber: 'Número de roteamento', + internationalBankAccountDetails: 'Dados da conta bancária internacional', + iban: 'IBAN', + swiftBicCode: 'Código SWIFT/BIC', chooseAnAccountBelow: 'Escolha uma conta abaixo', addBankAccount: 'Adicionar conta bancária', chooseAnAccount: 'Escolha uma conta', @@ -3661,6 +3664,7 @@ ${amount} para ${merchant} - ${date}`, restrictedBusiness: 'Confirme que a empresa não está na lista de empresas restritas', routingNumber: 'Insira um número de roteamento válido', accountNumber: 'Insira um número de conta válido', + swiftCodeOrIban: 'Por favor, insira um código Swift/IBAN válido', routingAndAccountNumberCannotBeSame: 'Os números de roteamento e da conta não podem ser iguais', companyType: 'Selecione um tipo de empresa válido', tooManyAttempts: 'Devido ao alto número de tentativas de login, esta opção foi desativada por 24 horas. Tente novamente mais tarde ou insira os dados manualmente.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 7ad0e99a0c82..776d0716e23c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3521,6 +3521,9 @@ ${amount},商户:${merchant} - 日期:${date}`, thisBankAccount: '此银行账户将用于您工作区的业务付款', accountNumber: '账号编号', routingNumber: '路由号码', + internationalBankAccountDetails: '国际银行账户信息', + iban: 'IBAN', + swiftBicCode: 'SWIFT/BIC 代码', chooseAnAccountBelow: '在下方选择一个账户', addBankAccount: '添加银行账户', chooseAnAccount: '选择一个账户', @@ -3569,6 +3572,7 @@ ${amount},商户:${merchant} - 日期:${date}`, restrictedBusiness: '请确认该企业不在受限制业务列表中', routingNumber: '请输入有效的路由号码', accountNumber: '请输入有效的账户号码', + swiftCodeOrIban: '请输入有效的 Swift 代码/IBAN', routingAndAccountNumberCannotBeSame: '路由号码和账号不能相同', companyType: '请选择一个有效的公司类型', tooManyAttempts: '由于登录尝试次数过多,此选项已被禁用 24 小时。请稍后重试,或改为手动输入详细信息。', diff --git a/src/libs/API/parameters/AddPersonalBankAccountParams.ts b/src/libs/API/parameters/AddPersonalBankAccountParams.ts index 496c9a6b02c6..65c6847ffc76 100644 --- a/src/libs/API/parameters/AddPersonalBankAccountParams.ts +++ b/src/libs/API/parameters/AddPersonalBankAccountParams.ts @@ -18,6 +18,8 @@ type AddPersonalBankAccountParams = { addressZip?: string; addressCountry?: string; confirmedOwnershipDetails?: boolean; + iban?: string; + swiftCode?: string; }; export default AddPersonalBankAccountParams; diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 82f738b34cf8..3ff824a530cd 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -509,6 +509,8 @@ function addPersonalBankAccount( addressZip: account?.addressZipCode, addressCountry: account?.country, confirmedOwnershipDetails: account?.confirmedOwnershipDetails, + iban: account?.iban, + swiftCode: account?.swiftCode, }; if (policyID) { parameters.policyID = policyID; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx index 24ce1972a192..345b73d264a3 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -4,6 +4,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useRootNavigationState from '@hooks/useRootNavigationState'; import useSubPage from '@hooks/useSubPage'; @@ -22,14 +23,15 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import {createShouldCollectInternationalDepositDetailsSelector} from '@src/selectors/Policy'; import type {InternationalBankAccountForm} from '@src/types/form'; -import type {BankAccountList, CorpayFields, PrivatePersonalDetails} from '@src/types/onyx'; +import type {BankAccountList, CorpayFields, Policy, PrivatePersonalDetails} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import React from 'react'; +import React, {useCallback} from 'react'; import type CustomSubPageProps from './types'; @@ -39,6 +41,7 @@ import BankAccountDetails from './subPages/BankAccountDetails'; import BankInformation from './subPages/BankInformation'; import Confirmation from './subPages/Confirmation'; import CountrySelection from './subPages/CountrySelection'; +import InternationalBankAccountDetails from './subPages/InternationalBankAccountDetails'; import Success from './subPages/Success'; import {getFieldsMap, getInitialPersonalDetailsValues, getInitialSubstep, getSubstepValues, testValidation} from './utils'; @@ -55,6 +58,7 @@ type InternationalDepositAccountContentProps = { const pages = [ {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.COUNTRY, component: CountrySelection}, {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS, component: BankAccountDetails}, + {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS, component: InternationalBankAccountDetails}, {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE, component: AccountType}, {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.BANK_INFORMATION, component: BankInformation}, {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS, component: AccountHolderInformation}, @@ -62,8 +66,11 @@ const pages = [ {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.SUCCESS, component: Success}, ]; -function getSkippedPages(skipAccountTypeStep: boolean, skipAccountHolderInformationStep: boolean) { +function getSkippedPages(skipAccountTypeStep: boolean, skipAccountHolderInformationStep: boolean, skipInternationalBankAccountDetailsStep: boolean) { const skippedSteps = []; + if (skipInternationalBankAccountDetailsStep) { + skippedSteps.push(CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS); + } if (skipAccountTypeStep) { skippedSteps.push(CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE); } @@ -87,6 +94,12 @@ function InternationalDepositAccountContent({ const fieldsMap = getFieldsMap(corpayFields); const values = getSubstepValues(privatePersonalDetails, corpayFields, bankAccountList, draftValues, country, fieldsMap); + const bankCountry = values.bankCountry; + const shouldCollectInternationalDepositDetailsSelector = useCallback( + (policies: OnyxCollection) => createShouldCollectInternationalDepositDetailsSelector(bankCountry)(policies), + [bankCountry], + ); + const [shouldCollectInternationalDepositDetails] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: shouldCollectInternationalDepositDetailsSelector}); const initialAccountHolderDetailsValues = getInitialPersonalDetailsValues(privatePersonalDetails); @@ -96,7 +109,11 @@ function InternationalDepositAccountContent({ const skipAccountHolderInformationStep = testValidation(initialAccountHolderDetailsValues, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS]); - const skippedPages = getSkippedPages(skipAccountTypeStep, skipAccountHolderInformationStep); + // Skip the international bank account details step when no collecting policy needs international details for the + // selected bank country, or once we already have both the IBAN and SWIFT/BIC code. + const skipInternationalBankAccountDetailsStep = !shouldCollectInternationalDepositDetails || (!!values.iban && !!values.swiftCode); + + const skippedPages = getSkippedPages(skipAccountTypeStep, skipAccountHolderInformationStep, skipInternationalBankAccountDetailsStep); const route = useRoute>(); const topmostFullScreenRoute = useRootNavigationState((state) => state?.routes.findLast((r) => isFullScreenName(r.name))); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx index 34f3e81e2c17..82bbf9ba65c5 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx @@ -14,11 +14,16 @@ import {addPersonalBankAccount} from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {createShouldCollectInternationalDepositDetailsSelector} from '@src/selectors/Policy'; +import type {Policy} from '@src/types/onyx'; -import React, {useEffect, useRef} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; + +import React, {useCallback, useEffect, useRef} from 'react'; import Address from './substeps/AddressStep'; import Confirmation from './substeps/ConfirmationStep'; +import InternationalBankAccountDetails from './substeps/InternationalBankAccountDetailsStep'; import LegalName from './substeps/LegalNameStep'; import ManualBankAccountDetails from './substeps/ManualBankAccountDetailsStep'; import PhoneNumber from './substeps/PhoneNumberStep'; @@ -26,8 +31,8 @@ import PlaidBankAccount from './substeps/PlaidBankAccountStep'; import getSkippedStepsPersonalInfo from './utils/getSkippedStepsPersonalInfo'; const bodyContentInfoSet: Array> = [LegalName, Address, PhoneNumber, Confirmation]; -const bodyContentWithPlaid: Array> = [PlaidBankAccount, ...bodyContentInfoSet]; -const bodyContentWithManualSetup: Array> = [ManualBankAccountDetails, ...bodyContentInfoSet]; +const bodyContentWithPlaid: Array> = [PlaidBankAccount, InternationalBankAccountDetails, ...bodyContentInfoSet]; +const bodyContentWithManualSetup: Array> = [ManualBankAccountDetails, InternationalBankAccountDetails, ...bodyContentInfoSet]; const DEFAULT_OBJECT = {}; const ACCOUNT_OWNERSHIP_ERROR_SUBSTRING = 'account ownership'; @@ -45,6 +50,11 @@ function PersonalInfoPage() { const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA); + const shouldCollectInternationalDepositDetailsSelector = useCallback( + (policies: OnyxCollection) => createShouldCollectInternationalDepositDetailsSelector(CONST.COUNTRY.US)(policies), + [], + ); + const [shouldCollectInternationalDepositDetails] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: shouldCollectInternationalDepositDetailsSelector}); const submitBankAccountForm = () => { const bankAccounts = plaidData?.bankAccounts ?? []; @@ -69,7 +79,10 @@ function PersonalInfoPage() { addPersonalBankAccount(accountData, personalPolicyID); }; - const skipSteps = getSkippedStepsPersonalInfo(privatePersonalDetails); + const isAccountNumberIBAN = CONST.BANK_ACCOUNT.REGEX.IBAN.test((personalBankAccount?.accountNumber ?? '').trim()); + const shouldSkipInternationalBankAccountDetails = !shouldCollectInternationalDepositDetails || (isAccountNumberIBAN && !!personalBankAccount?.swiftCode); + + const skipSteps = getSkippedStepsPersonalInfo(privatePersonalDetails, shouldSkipInternationalBankAccountDetails); const { componentToRender: SubStep, diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx new file mode 100644 index 000000000000..581aed8ca6f7 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx @@ -0,0 +1,88 @@ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalBankAccountDetailsFormSubmit from '@hooks/usePersonalBankAccountDetailsFormSubmit'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getFieldRequiredErrors} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/PersonalBankAccountForm'; + +import React from 'react'; + +type InternationalBankAccountDetailsProps = SubStepProps; + +const BANK_INFO_STEP_KEYS = INPUT_IDS.BANK_INFO_STEP; +const STEP_FIELDS = [BANK_INFO_STEP_KEYS.IBAN, BANK_INFO_STEP_KEYS.SWIFT_CODE]; + +function InternationalBankAccountDetailsStep({onNext, isEditing}: InternationalBankAccountDetailsProps) { + const [bankAccountPersonalDetails] = useOnyx(ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM_DRAFT); + + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + + if (values.iban && !CONST.BANK_ACCOUNT.REGEX.IBAN.test(values.iban.trim())) { + errors.iban = translate('bankAccount.error.swiftCodeOrIban'); + } + if (values.swiftCode && !CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(values.swiftCode.trim())) { + errors.swiftCode = translate('bankAccount.error.swiftCodeOrIban'); + } + + return errors; + }; + + const handleSubmit = usePersonalBankAccountDetailsFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: true, + }); + + return ( + + {translate('bankAccount.internationalBankAccountDetails')} + + + + ); +} + +InternationalBankAccountDetailsStep.displayName = 'InternationalBankAccountDetailsStep'; + +export default InternationalBankAccountDetailsStep; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/utils/getSkippedStepsPersonalInfo.ts b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/utils/getSkippedStepsPersonalInfo.ts index 2b56438f356d..12c8715e1fed 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/utils/getSkippedStepsPersonalInfo.ts +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/utils/getSkippedStepsPersonalInfo.ts @@ -4,24 +4,32 @@ import CONST from '@src/CONST'; import type {PrivatePersonalDetails} from '@src/types/onyx'; /** - * Returns the initial substep for the Personal Info step based on already existing data + * Returns the steps to skip in the Personal Info flow based on already existing data. + * + * The international bank account details step sits at index 1 (right after the bank account details step), + * so the legal name / address / phone number steps that follow it are at indexes 2, 3 and 4. */ -function getSkippedStepsPersonalInfo(data?: Partial): number[] { +function getSkippedStepsPersonalInfo(data: Partial | undefined, shouldSkipInternationalBankAccountDetails: boolean): number[] { const currentAddress = getCurrentAddress(data); const skippedSteps = []; - if (!!data?.legalFirstName && !!data?.legalLastName) { + + if (shouldSkipInternationalBankAccountDetails) { skippedSteps.push(1); } + if (!!data?.legalFirstName && !!data?.legalLastName) { + skippedSteps.push(2); + } + const isUsOrCanada = currentAddress?.country === CONST.COUNTRY.US || currentAddress?.country === CONST.COUNTRY.CA; const hasValidState = !isUsOrCanada || !!currentAddress?.state; if (!!currentAddress?.street && !!currentAddress?.city && hasValidState && !!currentAddress?.zip) { - skippedSteps.push(2); + skippedSteps.push(3); } if (data?.phoneNumber) { - skippedSteps.push(3); + skippedSteps.push(4); } return skippedSteps; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx new file mode 100644 index 000000000000..c02b0898efb5 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx @@ -0,0 +1,87 @@ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useInternationalBankAccountFormSubmit from '@hooks/useInternationalBankAccountFormSubmit'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getFieldRequiredErrors} from '@libs/ValidationUtils'; + +import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React, {useCallback} from 'react'; +import {View} from 'react-native'; + +const IBAN = 'iban'; +const SWIFT_CODE = 'swiftCode'; +const STEP_FIELDS = [IBAN, SWIFT_CODE]; + +function InternationalBankAccountDetails({isEditing, onNext, formValues}: CustomSubPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const handleSubmit = useInternationalBankAccountFormSubmit({ + fieldIds: STEP_FIELDS, + onNext, + shouldSaveDraft: true, + }); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); + + if (values[IBAN] && !CONST.BANK_ACCOUNT.REGEX.IBAN.test(String(values[IBAN]).trim())) { + errors[IBAN] = translate('bankAccount.error.swiftCodeOrIban'); + } + if (values[SWIFT_CODE] && !CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(String(values[SWIFT_CODE]).trim())) { + errors[SWIFT_CODE] = translate('bankAccount.error.swiftCodeOrIban'); + } + + return errors; + }, + [translate], + ); + + return ( + + + {translate('bankAccount.internationalBankAccountDetails')} + + + + + ); +} + +export default InternationalBankAccountDetails; diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index 3a15511e36b6..f6d932dafe5a 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -256,6 +256,18 @@ const createFilteredPoliciesInfoSelector = return {filteredPoliciesCount, firstPolicyID}; }; +/** + * Creates a selector returning whether international deposit details (IBAN/SWIFT) should be collected for the given + * bank country. + */ +const createShouldCollectInternationalDepositDetailsSelector = + (bankCountry: string) => + (policies: OnyxCollection): boolean => + Object.values(policies ?? {}).some((policy) => { + const countries = Object.keys(policy?.reimbursement?.countries ?? {}); + return countries.length > 0 && !countries.includes(bankCountry); + }); + const hasOnlyPersonalPoliciesSelector = (policies: OnyxCollection): boolean => { return !Object.values(policies ?? {}).some((policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); }; @@ -385,6 +397,7 @@ export { hasReusablePoliciesConnectedToSelector, lastWorkspaceNumberSelector, hasOnlyPersonalPoliciesSelector, + createShouldCollectInternationalDepositDetailsSelector, policyNameSelector, policyTypeSelector, areInvoicesEnabledSelector, diff --git a/src/types/form/PersonalBankAccountForm.ts b/src/types/form/PersonalBankAccountForm.ts index 126d61cf2ff0..4f2817ef2ab3 100644 --- a/src/types/form/PersonalBankAccountForm.ts +++ b/src/types/form/PersonalBankAccountForm.ts @@ -22,6 +22,8 @@ const INPUT_IDS = { COUNTRY: 'country', ZIP_CODE: 'addressZipCode', PHONE_NUMBER: 'phoneNumber', + IBAN: 'iban', + SWIFT_CODE: 'swiftCode', }, } as const; @@ -42,6 +44,8 @@ type BankAccountStepProps = { [INPUT_IDS.BANK_INFO_STEP.ZIP_CODE]: string; [INPUT_IDS.BANK_INFO_STEP.COUNTRY]: string; [INPUT_IDS.BANK_INFO_STEP.PHONE_NUMBER]: string; + [INPUT_IDS.BANK_INFO_STEP.IBAN]: string; + [INPUT_IDS.BANK_INFO_STEP.SWIFT_CODE]: string; }; type PlaidAccountProps = { diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index e5b538ecea21..3ddaa916056a 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -2505,6 +2505,15 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** The reimbursement choice for policy */ reimbursementChoice?: ValueOf; + /** Configuration for collecting employee deposit account details for reimbursement outside of Expensify */ + reimbursement?: { + /** Whether reimbursement is enabled for the policy */ + enabled?: boolean; + + /** Countries (keyed by ISO code) where the company has a withdrawal account it can reimburse from */ + countries?: Record; + }; + /** The set reimburser for the policy */ reimburser?: string; From d9602ca2458276f79871cee747a1857344d9f526 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 16 Jul 2026 00:14:55 +0200 Subject: [PATCH 067/466] we gotta do this --- config/eslint/eslint.seatbelt.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index bb50cfc8063d..26debce198be 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -2147,7 +2147,7 @@ "../../tests/unit/VideoRendererTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/ViolationUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 "../../tests/unit/WhisperContentMentionContextTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../tests/unit/WorkflowUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 +"../../tests/unit/WorkflowUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../tests/unit/WorkspaceReportFieldUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/WorkspacesSettingsUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 23 "../../tests/unit/awaitStagingDeploysTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 From 51d0af1fe98cb7898632922110fbc6b54447899d Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 16 Jul 2026 00:49:36 +0200 Subject: [PATCH 068/466] new rules project --- src/CONST/index.ts | 27 ++++++++++++++----------- src/libs/WorkflowUtils.ts | 18 ++++++++--------- src/libs/actions/Policy/Policy.ts | 2 +- src/libs/actions/Workflow.ts | 4 ++-- src/types/onyx/ApprovalWorkflowRules.ts | 4 ++-- src/types/onyx/Rule.ts | 2 +- tests/actions/WorkflowTest.ts | 27 ++++++++++++------------- tests/unit/WorkflowUtilsTest.ts | 16 +++++++-------- 8 files changed, 51 insertions(+), 49 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e2d90540fe33..ca828c7cf41a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7799,21 +7799,24 @@ const CONST = { }, }, - /** Vocabulary for the AST-based approval-workflow rules stored in the `rules` table / `ONYXKEYS.COLLECTION.RULE`. */ - APPROVAL_WORKFLOW_RULE: { - /** The kind of entity a rule is scoped to. Currently only workspace policies. */ + /** Vocabulary for the AST-based rules stored in the `rules` table / `ONYXKEYS.COLLECTION.RULE`. */ + RULES: { + /** The kind of entity a rule is scoped to. Currently only workspace policies. Shared across all rule types. */ SCOPE: { POLICY: 'policy', }, - /** The report lifecycle event that fires a rule. */ - TRIGGER: { - REPORT_SUBMIT: 'ReportSubmit', - REPORT_APPROVE: 'ReportApprove', - }, - /** The action taken when a rule matches. */ - ACTION: { - FORWARD_TO: 'ForwardTo', - APPROVE_REPORT: 'ApproveReport', + /** Vocabulary specific to approval-workflow rules. */ + APPROVAL_WORKFLOW: { + /** The report lifecycle event that fires a rule. */ + TRIGGER: { + REPORT_SUBMIT: 'ReportSubmit', + REPORT_APPROVE: 'ReportApprove', + }, + /** The action taken when a rule matches. */ + ACTION: { + FORWARD_TO: 'ForwardTo', + APPROVE_REPORT: 'ApproveReport', + }, }, }, diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 9f8ce6820260..b978f5fcc897 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -766,19 +766,19 @@ function toIndexMap(values: T[]): Record { } function buildSubmitTriggers(): ApprovalWorkflowTriggers { - return toIndexMap([CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT]); + return toIndexMap([CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT]); } function buildApproveTriggers(): ApprovalWorkflowTriggers { - return toIndexMap([CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE]); + return toIndexMap([CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE]); } function buildForwardActions(approver: string): ApprovalWorkflowActions { - return toIndexMap([{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver}]); + return toIndexMap([{name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO, approver}]); } function buildApproveActions(): ApprovalWorkflowActions { - return toIndexMap([{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}]); + return toIndexMap([{name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.APPROVE_REPORT}]); } /** @@ -1207,7 +1207,7 @@ function findComparisonByLeft(node: ApprovalWorkflowFilter | ApprovalWorkflowFil } /** The triggers of a rule as a flat list. */ -function getRuleTriggers(rule: ApprovalWorkflowRule): Array> { +function getRuleTriggers(rule: ApprovalWorkflowRule): Array> { return Object.values(rule.triggers ?? {}); } @@ -1218,17 +1218,17 @@ function getRuleActions(rule: ApprovalWorkflowRule): ApprovalWorkflowAction[] { /** True when the rule fires on report submission. */ function isSubmitRule(rule: ApprovalWorkflowRule): boolean { - return getRuleTriggers(rule).includes(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT); + return getRuleTriggers(rule).includes(CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT); } /** True when the rule approves (finalizes) the report. */ function isApproveReportRule(rule: ApprovalWorkflowRule): boolean { - return getRuleActions(rule).some((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT); + return getRuleActions(rule).some((action) => action.name === CONST.RULES.APPROVAL_WORKFLOW.ACTION.APPROVE_REPORT); } /** The approver a `ForwardTo` rule routes to, if any. */ function getForwardApprover(rule: ApprovalWorkflowRule): string | undefined { - return getRuleActions(rule).find((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO)?.approver; + return getRuleActions(rule).find((action) => action.name === CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO)?.approver; } /** @@ -1400,7 +1400,7 @@ function getApprovalWorkflowRulesForPolicy(rulesCollection: OnyxCollection } for (const [onyxKey, rule] of Object.entries(rulesCollection)) { - if (!rule || rule.scope !== CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY || rule.scopeID !== policyID) { + if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { continue; } if (rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index a7803b645de5..337dd14e3236 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1025,7 +1025,7 @@ function setWorkspaceApprovalMode( // so the workflows UI doesn't keep rebuilding stale workflows (e.g. after re-enabling). if (approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL && allRules) { for (const [ruleKey, rule] of Object.entries(allRules)) { - if (!rule || rule.scope !== CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY || rule.scopeID !== policyID) { + if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { continue; } const ruleOnyxKey: `${typeof ONYXKEYS.COLLECTION.RULE}${string}` = `${ONYXKEYS.COLLECTION.RULE}${ruleKey.slice(ONYXKEYS.COLLECTION.RULE.length)}`; diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 00d4d59b722d..3153421e9487 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -332,7 +332,7 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousRules}: SetAppro ? { onyxMethod: Onyx.METHOD.SET, key: ruleKey, - value: {...previousRule, scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, scopeID: policyID, pendingAction: null, errors: genericError}, + value: {...previousRule, scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, pendingAction: null, errors: genericError}, } : {onyxMethod: Onyx.METHOD.SET, key: ruleKey, value: null}; @@ -346,7 +346,7 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousRules}: SetAppro optimisticData.push({ onyxMethod: Onyx.METHOD.SET, key: ruleKey, - value: {...rule, scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, scopeID: policyID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: null}, + value: {...rule, scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: null}, }); successData.push({onyxMethod: Onyx.METHOD.MERGE, key: ruleKey, value: {pendingAction: null}}); failureData.push(restore); diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index 2f78e569e315..bd99b259e426 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -5,7 +5,7 @@ import type {ValueOf} from 'type-fest'; /** * A report lifecycle event that can fire an approval-workflow rule (`ReportSubmit` or `ReportApprove`). */ -type ApprovalWorkflowTrigger = ValueOf; +type ApprovalWorkflowTrigger = ValueOf; /** * The triggers of a rule, keyed by a string index (e.g. `{"0": "ReportSubmit"}`). A rule fires when @@ -16,7 +16,7 @@ type ApprovalWorkflowTriggers = Record; /** * The name of the action a rule performs when it matches (`ForwardTo` or `ApproveReport`). */ -type ApprovalWorkflowActionName = ValueOf; +type ApprovalWorkflowActionName = ValueOf; /** * A single action a rule performs when it matches. diff --git a/src/types/onyx/Rule.ts b/src/types/onyx/Rule.ts index e49d8c58e8ce..5348ecdcecd7 100644 --- a/src/types/onyx/Rule.ts +++ b/src/types/onyx/Rule.ts @@ -6,7 +6,7 @@ import type {ApprovalWorkflowRule} from './ApprovalWorkflowRules'; import type {Errors, OnyxValueWithOfflineFeedback} from './OnyxCommon'; /** The kind of entity a rule is scoped to (currently only workspace policies). */ -type RuleScope = ValueOf; +type RuleScope = ValueOf; /** * A rule as stored in the `ONYXKEYS.COLLECTION.RULE` collection under `rules_`. This mirrors a diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 15f84ba0bde1..660291d5dc18 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -69,8 +69,7 @@ async function getActivePolicyRules(policyID: string): Promise { }, }); return Object.values(collection).filter( - (rule): rule is Rule => - !!rule && rule.scope === CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY && rule.scopeID === policyID && rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + (rule): rule is Rule => !!rule && rule.scope === CONST.RULES.SCOPE.POLICY && rule.scopeID === policyID && rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); } @@ -82,22 +81,22 @@ function indexMap(...values: T[]): Record { /** Seed the two rules (submit -> forward, approve -> finalize) that describe a `submitters -> approver` workflow. */ async function seedForwardApproveRules(policyID: string, submitters: string[], approver: string) { await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule1`, { - scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, - triggers: indexMap(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT), + triggers: indexMap(CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT), filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: submitters}, - actions: indexMap({name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver}), + actions: indexMap({name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO, approver}), }); await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule2`, { - scope: CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY, + scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, - triggers: indexMap(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE), + triggers: indexMap(CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE), filters: { operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: submitters}, right: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: approver}, }, - actions: indexMap({name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}), + actions: indexMap({name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.APPROVE_REPORT}), }); } @@ -933,14 +932,14 @@ describe('actions/Workflow', () => { const rules = await getActivePolicyRules(policyID); expect(rules).toHaveLength(2); - const submitRule = rules.find((rule) => Object.values(rule.triggers).includes(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT)); - expect(submitRule?.scope).toBe(CONST.APPROVAL_WORKFLOW_RULE.SCOPE.POLICY); + const submitRule = rules.find((rule) => Object.values(rule.triggers).includes(CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT)); + expect(submitRule?.scope).toBe(CONST.RULES.SCOPE.POLICY); expect(submitRule?.scopeID).toBe(policyID); expect(submitRule?.filters).toEqual({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: [employee1Email]}); - expect(submitRule?.actions[0]).toEqual({name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver: ownerEmail}); + expect(submitRule?.actions[0]).toEqual({name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO, approver: ownerEmail}); - const approveRule = rules.find((rule) => Object.values(rule.triggers).includes(CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE)); - expect(approveRule?.actions[0]).toEqual({name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}); + const approveRule = rules.find((rule) => Object.values(rule.triggers).includes(CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE)); + expect(approveRule?.actions[0]).toEqual({name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.APPROVE_REPORT}); await mockFetch.resume(); await waitForBatchedUpdates(); @@ -1086,7 +1085,7 @@ describe('actions/Workflow', () => { const rules = await getActivePolicyRules(policyID); const forwardApprovers = rules .flatMap((rule) => Object.values(rule.actions)) - .filter((action) => action.name === CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO) + .filter((action) => action.name === CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO) .map((action) => action.approver); expect(forwardApprovers).toContain(employee2Email); expect(forwardApprovers).not.toContain(ownerEmail); diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 1ba3f56433cf..dce956aa8309 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1683,10 +1683,10 @@ describe('WorkflowUtils', () => { }); describe('rule-based approval workflows', () => { - const submitTriggers = {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT}; - const approveTriggers = {'0': CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE}; - const forwardActions = (approver: string) => ({'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO, approver}}); - const approveActions = {'0': {name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}}; + const submitTriggers = {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT}; + const approveTriggers = {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE}; + const forwardActions = (approver: string) => ({'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO, approver}}); + const approveActions = {'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.APPROVE_REPORT}}; const buildFromFilter = (emails: string[]) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: emails}); const buildToFilter = (email: string) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, right: email}); const and = (left: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison, right: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): ApprovalWorkflowFilter => ({ @@ -1796,14 +1796,14 @@ describe('WorkflowUtils', () => { // new submitter folds into the existing rule instead of minting a fresh pair. const existingRules: Record = { r1: { - actions: asServerShape([{approver: '1@example.com', name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.FORWARD_TO}]), + actions: asServerShape([{approver: '1@example.com', name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO}]), filters: buildFromFilter(['20@example.com']), - triggers: asServerShape([CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_SUBMIT]), + triggers: asServerShape([CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT]), }, r2: { - actions: asServerShape([{name: CONST.APPROVAL_WORKFLOW_RULE.ACTION.APPROVE_REPORT}]), + actions: asServerShape([{name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.APPROVE_REPORT}]), filters: and(buildFromFilter(['20@example.com']), buildToFilter('1@example.com')), - triggers: asServerShape([CONST.APPROVAL_WORKFLOW_RULE.TRIGGER.REPORT_APPROVE]), + triggers: asServerShape([CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE]), }, }; const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); From dfa0c1c966ee1622317ea455facd48e07fc327ef Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 16 Jul 2026 01:22:00 +0200 Subject: [PATCH 069/466] rule handling fix --- src/libs/actions/Policy/Policy.ts | 12 ++---- src/libs/actions/Workflow.ts | 27 ++++++------- .../workflows/WorkspaceWorkflowsPage.tsx | 39 +++++++++++++------ .../WorkspaceWorkflowsApprovalsCreatePage.tsx | 5 ++- .../WorkspaceWorkflowsApprovalsEditPage.tsx | 4 +- tests/actions/WorkflowTest.ts | 19 ++++++--- 6 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 337dd14e3236..4dc6aefb09b1 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -319,13 +319,6 @@ Onyx.connect({ callback: (val) => (deprecatedAllPersonalDetails = val), }); -let allRules: OnyxCollection; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.RULE, - waitForCollectionCallback: true, - callback: (val) => (allRules = val), -}); - /** * Stores in Onyx the policy ID of the last workspace that was accessed by the user */ @@ -896,6 +889,7 @@ function setWorkspaceApprovalMode( currentUserAccountID: number, currentUserEmail: string, additionalData?: SetWorkspaceApprovalModeAdditionalData, + rules?: OnyxCollection, ) { if (!policy) { return; @@ -1023,8 +1017,8 @@ function setWorkspaceApprovalMode( // Auth's DisablePolicyApprovals deletes the policy's rule rows server-side; clear them optimistically too // so the workflows UI doesn't keep rebuilding stale workflows (e.g. after re-enabling). - if (approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL && allRules) { - for (const [ruleKey, rule] of Object.entries(allRules)) { + if (approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL && rules) { + for (const [ruleKey, rule] of Object.entries(rules)) { if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { continue; } diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 3153421e9487..d3406342dbe7 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -36,16 +36,6 @@ import Onyx from 'react-native-onyx'; import {completeTask} from './Task'; -/** Cache of every approval-workflow rule (`ONYXKEYS.COLLECTION.RULE`) so actions can read the current rule set. */ -let allRules: OnyxCollection = {}; -Onyx.connectWithoutView({ - key: ONYXKEYS.COLLECTION.RULE, - waitForCollectionCallback: true, - callback: (value) => { - allRules = value ?? {}; - }, -}); - type CreateApprovalWorkflowParams = { approvalWorkflow: ApprovalWorkflow; policy: OnyxEntry; @@ -360,13 +350,17 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousRules}: SetAppro write(WRITE_COMMANDS.SET_APPROVAL_WORKFLOW, parameters, {optimisticData, successData, failureData}); } +type CreateApprovalWorkflowRulesParams = CreateApprovalWorkflowParams & { + rules: OnyxCollection; +}; + /** Create an approval workflow using the rules-based backend structure. */ -function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport}: CreateApprovalWorkflowParams) { +function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport, rules}: CreateApprovalWorkflowRulesParams) { if (!policy) { return; } - const existingRules = getApprovalWorkflowRulesForPolicy(allRules, policy.id); + const existingRules = getApprovalWorkflowRulesForPolicy(rules, policy.id); const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); // A submitter can only belong to one workflow, so first drop these members from any OTHER @@ -393,15 +387,16 @@ type UpdateApprovalWorkflowRulesParams = { approvalWorkflow: ApprovalWorkflow; initialApprovalWorkflow: ApprovalWorkflow; policy: OnyxEntry; + rules: OnyxCollection; }; /** Update an existing workflow using the rules-based backend structure. */ -function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy}: UpdateApprovalWorkflowRulesParams) { +function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy, rules}: UpdateApprovalWorkflowRulesParams) { if (!policy) { return; } - const existingRules = getApprovalWorkflowRulesForPolicy(allRules, policy.id); + const existingRules = getApprovalWorkflowRulesForPolicy(rules, policy.id); const previousMemberEmails = getWorkflowMemberEmails(initialApprovalWorkflow.members); const newMemberEmails = getWorkflowMemberEmails(approvalWorkflow.members); @@ -425,12 +420,12 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, } /** Delete an approval workflow using the rules-based backend structure. */ -function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry) { +function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry, rules: OnyxCollection) { if (!policy) { return; } - const existingRules = getApprovalWorkflowRulesForPolicy(allRules, policy.id); + const existingRules = getApprovalWorkflowRulesForPolicy(rules, policy.id); const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); const rulesDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 752764166d98..605b47a3f972 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -253,12 +253,20 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { }, []); const confirmDisableApprovals = useCallback(() => { - setWorkspaceApprovalMode(policy, policy?.owner ?? '', CONST.POLICY.APPROVAL_MODE.OPTIONAL, currentUserAccountID, currentUserEmail, { - reportNextSteps: allReportNextSteps, - transactionViolations, - betas, - }); - }, [allReportNextSteps, betas, policy, transactionViolations, currentUserAccountID, currentUserEmail]); + setWorkspaceApprovalMode( + policy, + policy?.owner ?? '', + CONST.POLICY.APPROVAL_MODE.OPTIONAL, + currentUserAccountID, + currentUserEmail, + { + reportNextSteps: allReportNextSteps, + transactionViolations, + betas, + }, + rulesCollection, + ); + }, [allReportNextSteps, betas, policy, transactionViolations, currentUserAccountID, currentUserEmail, rulesCollection]); const navigateToHRSettings = useCallback(() => { Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(route.params.policyID)); @@ -529,11 +537,19 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { }); return; } - setWorkspaceApprovalMode(policy, policy?.owner ?? '', isEnabled ? updateApprovalMode : CONST.POLICY.APPROVAL_MODE.OPTIONAL, currentUserAccountID, currentUserEmail, { - reportNextSteps: allReportNextSteps, - transactionViolations, - betas, - }); + setWorkspaceApprovalMode( + policy, + policy?.owner ?? '', + isEnabled ? updateApprovalMode : CONST.POLICY.APPROVAL_MODE.OPTIONAL, + currentUserAccountID, + currentUserEmail, + { + reportNextSteps: allReportNextSteps, + transactionViolations, + betas, + }, + rulesCollection, + ); }, subMenuItems: ( <> @@ -902,6 +918,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { withPaymentsReadOnlyFallback, withWorkflowsReadOnlyFallback, showReadOnlyModal, + rulesCollection, ]); const renderOptionItem = (item: ToggleSettingOptionRowProps, index: number) => ( diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx index c4acd020f59c..a0c437a6bca0 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx @@ -45,6 +45,7 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr const {translate} = useLocalize(); const {isBetaEnabled} = usePermissions(); const [approvalWorkflow] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); + const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const addExpenseApprovalsTaskReportID = introSelected?.addExpenseApprovals; const [addExpenseApprovalsTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${addExpenseApprovalsTaskReportID}`); @@ -66,13 +67,13 @@ function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = tr startWithLoading(() => { if (isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS)) { - createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport}); + createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport, rules: rulesCollection}); } else { createApprovalWorkflowAction({approvalWorkflow, policy, addExpenseApprovalsTaskReport}); } Navigation.dismissModal(); }); - }, [approvalWorkflow, policy, addExpenseApprovalsTaskReport, isBetaEnabled, startWithLoading]); + }, [approvalWorkflow, policy, addExpenseApprovalsTaskReport, rulesCollection, isBetaEnabled, startWithLoading]); const submitButtonContainerStyles = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: true, style: [styles.mb5, styles.mh5]}); diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index fd97efc37254..bd0f1b7e1c8f 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -80,7 +80,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true if (isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS)) { Navigation.dismissModal({ afterTransition: () => { - updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy}); + updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy, rules: rulesCollection}); }, }); return; @@ -109,7 +109,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true afterTransition: () => { // Remove the approval workflow using the initial data as it could be already edited if (useRulesBackend) { - removeApprovalWorkflowRules(initialApprovalWorkflow, policy); + removeApprovalWorkflowRules(initialApprovalWorkflow, policy, rulesCollection); } else { removeApprovalWorkflow(initialApprovalWorkflow, policy); } diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 660291d5dc18..46388818af33 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -59,7 +59,7 @@ const ownerEmail = 'owner@gmail.com'; * Reads every rule in the `ONYXKEYS.COLLECTION.RULE` collection that belongs to the given policy and is * not optimistically pending deletion. This mirrors what the app treats as the policy's live rule set. */ -async function getActivePolicyRules(policyID: string): Promise { +async function getRulesCollection(): Promise> { let collection: OnyxCollection = {}; await getOnyxData({ key: ONYXKEYS.COLLECTION.RULE, @@ -68,7 +68,12 @@ async function getActivePolicyRules(policyID: string): Promise { collection = value ?? {}; }, }); - return Object.values(collection).filter( + return collection; +} + +async function getActivePolicyRules(policyID: string): Promise { + const collection = await getRulesCollection(); + return Object.values(collection ?? {}).filter( (rule): rule is Rule => !!rule && rule.scope === CONST.RULES.SCOPE.POLICY && rule.scopeID === policyID && rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); } @@ -925,7 +930,7 @@ describe('actions/Workflow', () => { await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); await waitForBatchedUpdates(); - createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport: undefined}); + createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport: undefined, rules: await getRulesCollection()}); await waitForBatchedUpdates(); // A one-approver workflow produces a submit rule (forward to the approver) and a terminal approve rule. @@ -974,7 +979,7 @@ describe('actions/Workflow', () => { await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); await waitForBatchedUpdates(); - createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport: undefined}); + createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprovalsTaskReport: undefined, rules: await getRulesCollection()}); await waitForBatchedUpdates(); const rules = await getActivePolicyRules(policyID); @@ -1015,6 +1020,7 @@ describe('actions/Workflow', () => { }, policy, addExpenseApprovalsTaskReport: undefined, + rules: await getRulesCollection(), }); await waitForBatchedUpdates(); @@ -1030,6 +1036,7 @@ describe('actions/Workflow', () => { }, policy, addExpenseApprovalsTaskReport: undefined, + rules: await getRulesCollection(), }); await waitForBatchedUpdates(); @@ -1078,7 +1085,7 @@ describe('actions/Workflow', () => { await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); await waitForBatchedUpdates(); - updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy}); + updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy, rules: await getRulesCollection()}); await waitForBatchedUpdates(); // The old rules (forwarding to the owner) are removed and new rules forwarding to employee2 are created. @@ -1124,7 +1131,7 @@ describe('actions/Workflow', () => { await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); await waitForBatchedUpdates(); - removeApprovalWorkflowRules(approvalWorkflow, policy); + removeApprovalWorkflowRules(approvalWorkflow, policy, await getRulesCollection()); await waitForBatchedUpdates(); // Both rules belonged only to the removed member, so no live rules remain for the policy. From 18b337d799e96be63edebc8e345d069155e7bd20 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 24 Jul 2026 12:51:09 +0700 Subject: [PATCH 070/466] update logic to show international BA details page --- cspell.json | 1 + ...houldCollectInternationalDepositDetails.ts | 28 +++++++++++++++++++ .../InternationalDepositAccountContent.tsx | 15 ++++------ .../PersonalInfo/PersonalInfo.tsx | 16 +++-------- .../substeps/ConfirmationStep.tsx | 22 +++++++++++++-- .../subPages/Confirmation.tsx | 12 ++++++++ .../InternationalBankAccountDetails.tsx | 7 ++++- src/selectors/Policy.ts | 13 --------- 8 files changed, 75 insertions(+), 39 deletions(-) create mode 100644 src/hooks/useShouldCollectInternationalDepositDetails.ts diff --git a/cspell.json b/cspell.json index d5cf1d79cb0e..5ccd929ea98c 100644 --- a/cspell.json +++ b/cspell.json @@ -661,6 +661,7 @@ "hybridapp", "iOSQRCode", "iaco", + "iban", "idempotently", "idfa", "ifdef", diff --git a/src/hooks/useShouldCollectInternationalDepositDetails.ts b/src/hooks/useShouldCollectInternationalDepositDetails.ts new file mode 100644 index 000000000000..ffb2b235bd28 --- /dev/null +++ b/src/hooks/useShouldCollectInternationalDepositDetails.ts @@ -0,0 +1,28 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import {useCallback} from 'react'; + +import useOnyx from './useOnyx'; + +/** + * Returns whether international deposit details (IBAN/SWIFT) should be collected when adding a bank account in the + * given country. This is the case when any of the user's policies can reimburse from a country other than the one + * the bank account is being added in. + */ +function useShouldCollectInternationalDepositDetails(bankCountry: string): boolean { + const selector = useCallback( + (policies: OnyxCollection) => + Object.values(policies ?? {}).some((policy) => { + const countries = Object.keys(policy?.reimbursement?.countries ?? {}); + return countries.length > 0 && !countries.includes(bankCountry); + }), + [bankCountry], + ); + const [shouldCollectInternationalDepositDetails] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); + return shouldCollectInternationalDepositDetails ?? false; +} + +export default useShouldCollectInternationalDepositDetails; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx index 345b73d264a3..549bb7e02325 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -4,8 +4,8 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; import useRootNavigationState from '@hooks/useRootNavigationState'; +import useShouldCollectInternationalDepositDetails from '@hooks/useShouldCollectInternationalDepositDetails'; import useSubPage from '@hooks/useSubPage'; import {clearCorpayBankAccountFields} from '@libs/actions/BankAccounts'; @@ -23,15 +23,14 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {createShouldCollectInternationalDepositDetailsSelector} from '@src/selectors/Policy'; import type {InternationalBankAccountForm} from '@src/types/form'; -import type {BankAccountList, CorpayFields, Policy, PrivatePersonalDetails} from '@src/types/onyx'; +import type {BankAccountList, CorpayFields, PrivatePersonalDetails} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback} from 'react'; +import React from 'react'; import type CustomSubPageProps from './types'; @@ -95,11 +94,7 @@ function InternationalDepositAccountContent({ const values = getSubstepValues(privatePersonalDetails, corpayFields, bankAccountList, draftValues, country, fieldsMap); const bankCountry = values.bankCountry; - const shouldCollectInternationalDepositDetailsSelector = useCallback( - (policies: OnyxCollection) => createShouldCollectInternationalDepositDetailsSelector(bankCountry)(policies), - [bankCountry], - ); - const [shouldCollectInternationalDepositDetails] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: shouldCollectInternationalDepositDetailsSelector}); + const shouldCollectInternationalDepositDetails = useShouldCollectInternationalDepositDetails(bankCountry); const initialAccountHolderDetailsValues = getInitialPersonalDetailsValues(privatePersonalDetails); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx index cf264ac6cbaa..f63d26e5542a 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx @@ -2,6 +2,7 @@ import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useShouldCollectInternationalDepositDetails from '@hooks/useShouldCollectInternationalDepositDetails'; import useSubStep from '@hooks/useSubStep'; import type {SubStepProps} from '@hooks/useSubStep/types'; @@ -15,12 +16,8 @@ import {addPersonalBankAccount} from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {createShouldCollectInternationalDepositDetailsSelector} from '@src/selectors/Policy'; -import type {Policy} from '@src/types/onyx'; -import type {OnyxCollection} from 'react-native-onyx'; - -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import Address from './substeps/AddressStep'; import Confirmation from './substeps/ConfirmationStep'; @@ -51,11 +48,7 @@ function PersonalInfoPage() { const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA); - const shouldCollectInternationalDepositDetailsSelector = useCallback( - (policies: OnyxCollection) => createShouldCollectInternationalDepositDetailsSelector(CONST.COUNTRY.US)(policies), - [], - ); - const [shouldCollectInternationalDepositDetails] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: shouldCollectInternationalDepositDetailsSelector}); + const shouldCollectInternationalDepositDetails = useShouldCollectInternationalDepositDetails(CONST.COUNTRY.US); const submitBankAccountForm = () => { const bankAccounts = plaidData?.bankAccounts ?? []; @@ -96,8 +89,7 @@ function PersonalInfoPage() { addPersonalBankAccount(accountData, personalPolicyID); }; - const isAccountNumberIBAN = CONST.BANK_ACCOUNT.REGEX.IBAN.test((personalBankAccount?.accountNumber ?? '').trim()); - const shouldSkipInternationalBankAccountDetails = !shouldCollectInternationalDepositDetails || (isAccountNumberIBAN && !!personalBankAccount?.swiftCode); + const shouldSkipInternationalBankAccountDetails = !shouldCollectInternationalDepositDetails || (!!personalBankAccount?.iban && !!personalBankAccount?.swiftCode); const skipSteps = getSkippedStepsPersonalInfo(privatePersonalDetails, shouldSkipInternationalBankAccountDetails); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/ConfirmationStep.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/ConfirmationStep.tsx index e3b6de17bf0d..cb0cd14e104a 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/ConfirmationStep.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/ConfirmationStep.tsx @@ -91,15 +91,31 @@ function ConfirmationStep({onNext, onMove, isEditing}: SubStepProps) { }, ]; + const internationalBankAccountDetails = + bankAccountPersonalDetails?.iban && bankAccountPersonalDetails?.swiftCode + ? [ + { + id: 'international-bank-account-details', + description: `${translate('bankAccount.iban')} / ${translate('bankAccount.swiftBicCode')}`, + title: `${bankAccountPersonalDetails?.iban} / ${bankAccountPersonalDetails?.swiftCode}`, + shouldShowRightIcon: true, + onPress: () => { + moveToEditStep(1); + }, + }, + ] + : []; + return [ ...bankConnection, + ...internationalBankAccountDetails, { id: 'legal-name', description: translate('personalInfoStep.legalName'), title: `${personalDetails[PERSONAL_INFO_STEP_KEYS.FIRST_NAME]} ${personalDetails[PERSONAL_INFO_STEP_KEYS.LAST_NAME]}`, shouldShowRightIcon: true, onPress: () => { - moveToEditStep(1); + moveToEditStep(2); }, }, { @@ -108,7 +124,7 @@ function ConfirmationStep({onNext, onMove, isEditing}: SubStepProps) { title: `${personalDetails?.addressStreet}, ${personalDetails?.addressCity}, ${personalDetails?.addressState} ${personalDetails?.addressZip}`, shouldShowRightIcon: true, onPress: () => { - moveToEditStep(2); + moveToEditStep(3); }, }, { @@ -117,7 +133,7 @@ function ConfirmationStep({onNext, onMove, isEditing}: SubStepProps) { title: personalDetails[PERSONAL_INFO_STEP_KEYS.PHONE_NUMBER], shouldShowRightIcon: true, onPress: () => { - moveToEditStep(3); + moveToEditStep(4); }, }, ]; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index f23ca7b30031..2986a09dbbf8 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -125,6 +125,18 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp }); } + if (formValues.iban && formValues.swiftCode) { + summaryItems.push({ + id: 'international-bank-account-details', + description: `${translate('bankAccount.iban')} / ${translate('bankAccount.swiftBicCode')}`, + title: `${formValues.iban} / ${formValues.swiftCode}`, + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.INTERNATIONAL_BANK_ACCOUNT_DETAILS); + }, + }); + } + for (const [fieldName, field] of Object.entries(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE] ?? {})) { summaryItems.push({ id: `${CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE}-${fieldName}`, diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx index c02b0898efb5..65949f9a5456 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx @@ -32,6 +32,11 @@ function InternationalBankAccountDetails({isEditing, onNext, formValues}: Custom shouldSaveDraft: true, }); + // For IBAN countries the account number entered in the account details step is the IBAN, so pre-fill the IBAN + // field with it until the user edits this step directly. + const accountNumber = formValues.accountNumber; + const ibanDefaultValue = formValues[IBAN] || (accountNumber && CONST.BANK_ACCOUNT.REGEX.IBAN.test(String(accountNumber).trim()) ? accountNumber : ''); + const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); @@ -65,7 +70,7 @@ function InternationalBankAccountDetails({isEditing, onNext, formValues}: Custom label={translate('bankAccount.iban')} aria-label={translate('bankAccount.iban')} role={CONST.ROLE.PRESENTATION} - defaultValue={formValues[IBAN]} + defaultValue={ibanDefaultValue} containerStyles={[styles.pv2]} shouldSaveDraft={!isEditing} /> diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index f6d932dafe5a..3a15511e36b6 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -256,18 +256,6 @@ const createFilteredPoliciesInfoSelector = return {filteredPoliciesCount, firstPolicyID}; }; -/** - * Creates a selector returning whether international deposit details (IBAN/SWIFT) should be collected for the given - * bank country. - */ -const createShouldCollectInternationalDepositDetailsSelector = - (bankCountry: string) => - (policies: OnyxCollection): boolean => - Object.values(policies ?? {}).some((policy) => { - const countries = Object.keys(policy?.reimbursement?.countries ?? {}); - return countries.length > 0 && !countries.includes(bankCountry); - }); - const hasOnlyPersonalPoliciesSelector = (policies: OnyxCollection): boolean => { return !Object.values(policies ?? {}).some((policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); }; @@ -397,7 +385,6 @@ export { hasReusablePoliciesConnectedToSelector, lastWorkspaceNumberSelector, hasOnlyPersonalPoliciesSelector, - createShouldCollectInternationalDepositDetailsSelector, policyNameSelector, policyTypeSelector, areInvoicesEnabledSelector, From f082edb37b4e405a484a12715b3ef497f56e64d1 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 24 Jul 2026 15:51:44 +0700 Subject: [PATCH 071/466] prefill iban and swift code --- src/hooks/useShouldCollectInternationalDepositDetails.ts | 5 ++++- .../InternationalDepositAccountContent.tsx | 5 +++-- .../PersonalInfo/PersonalInfo.tsx | 4 +++- .../subPages/InternationalBankAccountDetails.tsx | 7 ++++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/hooks/useShouldCollectInternationalDepositDetails.ts b/src/hooks/useShouldCollectInternationalDepositDetails.ts index ffb2b235bd28..c29781f1e83a 100644 --- a/src/hooks/useShouldCollectInternationalDepositDetails.ts +++ b/src/hooks/useShouldCollectInternationalDepositDetails.ts @@ -16,7 +16,10 @@ function useShouldCollectInternationalDepositDetails(bankCountry: string): boole const selector = useCallback( (policies: OnyxCollection) => Object.values(policies ?? {}).some((policy) => { - const countries = Object.keys(policy?.reimbursement?.countries ?? {}); + if (!policy?.reimbursement?.enabled) { + return false; + } + const countries = Object.keys(policy.reimbursement.countries ?? {}); return countries.length > 0 && !countries.includes(bankCountry); }), [bankCountry], diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx index 549bb7e02325..c5fb1f2628dd 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -105,8 +105,9 @@ function InternationalDepositAccountContent({ const skipAccountHolderInformationStep = testValidation(initialAccountHolderDetailsValues, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS]); // Skip the international bank account details step when no collecting policy needs international details for the - // selected bank country, or once we already have both the IBAN and SWIFT/BIC code. - const skipInternationalBankAccountDetailsStep = !shouldCollectInternationalDepositDetails || (!!values.iban && !!values.swiftCode); + // selected bank country, or when we already have them: the account number is an IBAN and a SWIFT/BIC code is set. + const isAccountNumberIBAN = CONST.BANK_ACCOUNT.REGEX.IBAN.test((values.accountNumber ?? '').trim()); + const skipInternationalBankAccountDetailsStep = !shouldCollectInternationalDepositDetails || (isAccountNumberIBAN && (!!values.swiftCode || !!values.swiftBicCode)); const skippedPages = getSkippedPages(skipAccountTypeStep, skipAccountHolderInformationStep, skipInternationalBankAccountDetailsStep); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx index f63d26e5542a..5e822cb3fc03 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx @@ -89,7 +89,9 @@ function PersonalInfoPage() { addPersonalBankAccount(accountData, personalPolicyID); }; - const shouldSkipInternationalBankAccountDetails = !shouldCollectInternationalDepositDetails || (!!personalBankAccount?.iban && !!personalBankAccount?.swiftCode); + // Skip when the details are already available: the account number is an IBAN and a SWIFT/BIC code is set. + const isAccountNumberIBAN = CONST.BANK_ACCOUNT.REGEX.IBAN.test((personalBankAccount?.accountNumber ?? '').trim()); + const shouldSkipInternationalBankAccountDetails = !shouldCollectInternationalDepositDetails || (isAccountNumberIBAN && !!personalBankAccount?.swiftCode); const skipSteps = getSkippedStepsPersonalInfo(privatePersonalDetails, shouldSkipInternationalBankAccountDetails); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx index 65949f9a5456..9b9283833f8d 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx @@ -32,10 +32,11 @@ function InternationalBankAccountDetails({isEditing, onNext, formValues}: Custom shouldSaveDraft: true, }); - // For IBAN countries the account number entered in the account details step is the IBAN, so pre-fill the IBAN - // field with it until the user edits this step directly. + // Pre-fill from what the account details step already captured, until the user edits this step directly: the + // account number is the IBAN for IBAN countries, and Corpay's SWIFT/BIC field (swiftBicCode) is the SWIFT code. const accountNumber = formValues.accountNumber; const ibanDefaultValue = formValues[IBAN] || (accountNumber && CONST.BANK_ACCOUNT.REGEX.IBAN.test(String(accountNumber).trim()) ? accountNumber : ''); + const swiftCodeDefaultValue = formValues[SWIFT_CODE] || formValues.swiftBicCode || ''; const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { @@ -80,7 +81,7 @@ function InternationalBankAccountDetails({isEditing, onNext, formValues}: Custom label={translate('bankAccount.swiftBicCode')} aria-label={translate('bankAccount.swiftBicCode')} role={CONST.ROLE.PRESENTATION} - defaultValue={formValues[SWIFT_CODE]} + defaultValue={swiftCodeDefaultValue} containerStyles={[styles.pv2]} shouldSaveDraft={!isEditing} /> From 5f7b3f86950b4f9fa2cf7820fbd3ad6cd3262957 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Fri, 24 Jul 2026 17:42:31 +0500 Subject: [PATCH 072/466] fix: gate search results layout on debounced query to stop mid-typing list flip --- .../Search/SearchAutocompleteList.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 739dbcb304f9..888ab6eaacd4 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -198,6 +198,13 @@ function SearchAutocompleteList({ const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const effectiveInputQueryValue = inputQueryValue ?? autocompleteQueryValue; const hasEffectiveInputQuery = effectiveInputQueryValue.trim() !== ''; + // hasEffectiveInputQuery reflects the immediate input (used to hide recent searches the moment the user types). + // hasActiveSearchResults additionally requires the debounced autocompleteQueryValue to be non-empty, i.e. the + // filtered searchOptions/recentReportsOptions actually reflect the typed query. Gating the results layout on this + // (rather than the immediate value) keeps the sections in sync with the data they render: during the debounce + // window we keep showing recent chats instead of briefly rendering the previous/unfiltered rows under the search + // layout and then reflowing once the debounced query catches up. + const hasActiveSearchResults = hasEffectiveInputQuery && autocompleteQueryValue.trim() !== ''; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; @@ -405,13 +412,14 @@ function SearchAutocompleteList({ ]); const recentReportsOptions = useMemo(() => { - if (!hasEffectiveInputQuery) { + if (!hasActiveSearchResults) { return searchOptions.recentReports; } - // searchOptions/autocompleteQueryValue are debounced, so during the debounce window this still - // returns the previous query's matches instead of blanking the list. Keeping rows visible (rather - // than emptying them) is what preserves focus, Enter, and arrow-key navigation while typing. + // searchOptions/autocompleteQueryValue are debounced. For a query -> query change this still returns the + // previous query's matches during the debounce window (rows stay visible, preserving focus/Enter/arrow keys). + // For the empty -> query transition hasActiveSearchResults is false until the debounced query lands, so this + // returns recent chats instead of unfiltered rows, avoiding the stale-then-filtered reflow. const orderedOptions = combineOrderingOfReportsAndPersonalDetails(searchOptions, autocompleteQueryValue, { sortByReportTypeInSearch: true, preferChatRoomsOverThreads: true, @@ -423,7 +431,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, hasEffectiveInputQuery, searchOptions]); + }, [autocompleteQueryValue, hasActiveSearchResults, searchOptions]); // Locked rank map (stable key -> originalIndex) capturing the order of locally-known // results at the moment the query changes. Recomputed only when the query changes, so server @@ -544,8 +552,9 @@ function SearchAutocompleteList({ /> ); - if (!hasEffectiveInputQuery) { - // Empty query: single "Recent chats" section + if (!hasActiveSearchResults) { + // No active (debounced) query yet: single "Recent chats" section. This also covers the debounce window + // right after the user starts typing, so we keep recent chats visible instead of flashing search rows. if (!isLoadingOptions) { pushSection({title: translate('search.recentChats'), data: nextStyledRecentReports, sectionIndex: sectionIndex++}); } else { @@ -613,6 +622,7 @@ function SearchAutocompleteList({ return {sections: nextSections, styledRecentReports: nextStyledRecentReports, suggestionsCount: nextSuggestionsCount}; }, [ hasEffectiveInputQuery, + hasActiveSearchResults, autocompleteSuggestions, expensifyIcons, frozenLocalRank, From 2101d2e29aca8efb9b57fa865dddbffe97b7b65b Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Sat, 25 Jul 2026 02:19:32 +0500 Subject: [PATCH 073/466] Fixed regression --- src/components/Search/SearchAutocompleteList.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index fbf6490733b6..189575d2febd 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -734,11 +734,17 @@ function SearchAutocompleteList({ return; } - // Don't override focus if the user has already navigated away (e.g. via arrow keys) from the row we - // last set programmatically. Otherwise this settle-triggered auto-highlight would silently steal focus - // back from a manual selection made during the debounce window. + // Only suppress the auto-highlight when the user has manually moved focus to some *other* real row + // (e.g. arrow-keyed to Ask Concierge). Advancing is still allowed when focus is on the row we last set + // programmatically OR on the search-query row (index 0): after a fast clear + retype the query-change + // effect parks focus on the query row, and we must still be able to move it onto the first matching + // result once the debounced options settle. Without this, focus is stranded on the query row and the + // first recent-chat row is left unselected on a fast clear & search. const currentFocusedKey = innerListRef.current?.getFocusedOption?.()?.keyForList; - if (lastProgrammaticFocusKeyRef.current !== undefined && currentFocusedKey !== lastProgrammaticFocusKeyRef.current) { + const searchQueryRowKey = searchQueryItems?.at(0)?.keyForList; + const isOnProgrammaticTarget = lastProgrammaticFocusKeyRef.current === undefined || currentFocusedKey === lastProgrammaticFocusKeyRef.current; + const isOnSearchQueryRow = currentFocusedKey !== undefined && currentFocusedKey === searchQueryRowKey; + if (!isOnProgrammaticTarget && !isOnSearchQueryRow) { return; } @@ -746,7 +752,7 @@ function SearchAutocompleteList({ // lands on the "Recent chats" section header row after the two-section switcher was introduced. lastProgrammaticFocusKeyRef.current = firstRecentReportKey; innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, true); - }, [autocompleteQueryValue, firstRecentReportFlatIndex, firstRecentReportKey, normalizedReferenceText, shouldHighlightFirstItem]); + }, [autocompleteQueryValue, firstRecentReportFlatIndex, firstRecentReportKey, normalizedReferenceText, searchQueryItems, shouldHighlightFirstItem]); if (isLoading) { return ( From fc4995a2fe4acd82ee3a74c06f5ed66921d0a954 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Sat, 25 Jul 2026 03:10:03 +0500 Subject: [PATCH 074/466] Fixed more regression --- .../Search/SearchAutocompleteList.tsx | 95 +++++++++++++------ 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 189575d2febd..403261c2b97e 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -324,28 +324,9 @@ function SearchAutocompleteList({ } }; - // Reset focus when query changes to prevent stale focus on wrong items - useEffect(() => { - if (isInitialRender) { - return; - } - - const queryChanged = prevQueryRef.current !== effectiveInputQueryValue; - prevQueryRef.current = effectiveInputQueryValue; - - if (queryChanged) { - if (effectiveInputQueryValue === '') { - // When query is cleared, reset the initial focus guard so the initial focus - // effect can re-fire and correctly focus the first focusable item (skipping section headers). - hasSetInitialFocusRef.current = false; - } else { - // When query changes to a non-empty value, focus on the search query item (index 0) and scroll to top. - // The highlight effect below switches focus to the first result when there's a good match. - lastProgrammaticFocusKeyRef.current = searchQueryItems?.at(0)?.keyForList; - innerListRef.current?.updateAndScrollToFocusedIndex(0, true); - } - } - }, [effectiveInputQueryValue, isInitialRender, searchQueryItems]); + // The "reset focus when query changes" effect lives further down (after firstRecentReportFlatIndex/Key and + // normalizedReferenceText are computed) because it now needs those values to decide, synchronously, whether + // the debounced query has already settled for the freshly typed text. See the comment there for why. // Track external text input focus to prevent list items from stealing focus while typing useEffect(() => { @@ -714,6 +695,60 @@ function SearchAutocompleteList({ const normalizedReferenceText = firstRecentReportText.toLowerCase(); + // Stable across renders while the query is non-empty (searchQueryItems is a fresh array reference on every + // SearchRouter render since it isn't memoized there, but its first item's key is always this same constant). + // Depending on this value instead of the raw array keeps the effects below from re-running on every keystroke. + const searchQueryRowKey = searchQueryItems?.at(0)?.keyForList; + + // Reset focus when query changes to prevent stale focus on wrong items. + useEffect(() => { + if (isInitialRender) { + return; + } + + const queryChanged = prevQueryRef.current !== effectiveInputQueryValue; + prevQueryRef.current = effectiveInputQueryValue; + + if (!queryChanged) { + return; + } + + if (effectiveInputQueryValue === '') { + // When query is cleared, reset the initial focus guard so the initial focus + // effect can re-fire and correctly focus the first focusable item (skipping section headers). + hasSetInitialFocusRef.current = false; + return; + } + + // autocompleteQueryValue (debounced) can already equal the freshly typed text the moment the query + // changes, not just once it "catches up" later. This happens on a fast clear + retype of the exact same + // text: clearing doesn't reset the debounce hook's internal value, so retyping the same text is a no-op + // update and the debounced prop never changes again -- nothing would re-run the highlight effect below + // afterward. Deciding the correct focus target here, synchronously from this render's own props (rather + // than by reading the list's focus state back, which lags a render behind our own updates), is what + // prevents focus from being left stranded on the search-query row in that case. + const isDebounceSettled = autocompleteQueryValue.trim() === effectiveInputQueryValue.trim(); + if (isDebounceSettled && shouldHighlightFirstItem && firstRecentReportFlatIndex !== -1 && shouldHighlight(normalizedReferenceText, autocompleteQueryValue)) { + lastProgrammaticFocusKeyRef.current = firstRecentReportKey; + innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, true); + return; + } + + // Otherwise the debounce is still pending for this query: focus the search query item (index 0) and + // scroll to top. The highlight effect below switches focus to the first result once a good match settles. + lastProgrammaticFocusKeyRef.current = searchQueryRowKey; + innerListRef.current?.updateAndScrollToFocusedIndex(0, true); + }, [ + autocompleteQueryValue, + effectiveInputQueryValue, + firstRecentReportFlatIndex, + firstRecentReportKey, + isInitialRender, + normalizedReferenceText, + searchQueryRowKey, + shouldHighlightFirstItem, + ]); + // When options initialize after the list is already mounted, initiallyFocusedItemKey has no effect // because useState(initialFocusedIndex) in useArrowKeyFocusManager only reads the initial value. // Imperatively focus the first recent report once options become available (desktop only). @@ -735,16 +770,14 @@ function SearchAutocompleteList({ } // Only suppress the auto-highlight when the user has manually moved focus to some *other* real row - // (e.g. arrow-keyed to Ask Concierge). Advancing is still allowed when focus is on the row we last set - // programmatically OR on the search-query row (index 0): after a fast clear + retype the query-change - // effect parks focus on the query row, and we must still be able to move it onto the first matching - // result once the debounced options settle. Without this, focus is stranded on the query row and the - // first recent-chat row is left unselected on a fast clear & search. + // (e.g. arrow-keyed to Ask Concierge). The reset effect above always keeps lastProgrammaticFocusKeyRef in + // sync with wherever it last placed focus (the search-query row while unsettled, or the first result once + // settled), so comparing against it alone is enough to detect a real, user-initiated divergence -- this + // effect is what promotes focus onto the first result once the debounce genuinely settles *later*, the + // common, non-coalesced typing flow. const currentFocusedKey = innerListRef.current?.getFocusedOption?.()?.keyForList; - const searchQueryRowKey = searchQueryItems?.at(0)?.keyForList; const isOnProgrammaticTarget = lastProgrammaticFocusKeyRef.current === undefined || currentFocusedKey === lastProgrammaticFocusKeyRef.current; - const isOnSearchQueryRow = currentFocusedKey !== undefined && currentFocusedKey === searchQueryRowKey; - if (!isOnProgrammaticTarget && !isOnSearchQueryRow) { + if (!isOnProgrammaticTarget) { return; } @@ -752,7 +785,7 @@ function SearchAutocompleteList({ // lands on the "Recent chats" section header row after the two-section switcher was introduced. lastProgrammaticFocusKeyRef.current = firstRecentReportKey; innerListRef.current?.updateAndScrollToFocusedIndex(firstRecentReportFlatIndex, true); - }, [autocompleteQueryValue, firstRecentReportFlatIndex, firstRecentReportKey, normalizedReferenceText, searchQueryItems, shouldHighlightFirstItem]); + }, [autocompleteQueryValue, firstRecentReportFlatIndex, firstRecentReportKey, normalizedReferenceText, shouldHighlightFirstItem]); if (isLoading) { return ( From b574b5dd369910103b90122e787637d80dffc5a6 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Sat, 25 Jul 2026 03:38:58 +0500 Subject: [PATCH 075/466] fixed Eslint issue --- tests/unit/OptionsListUtilsTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 6558f38efb3d..4438adbeddc8 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -82,7 +82,7 @@ import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Policy, Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; -import type {Attendee, Participant} from '@src/types/onyx/IOU'; +import type {Participant} from '@src/types/onyx/IOU'; import type Login from '@src/types/onyx/Login'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; From 296224096a24b018426729e7ebb366aaa5da02bc Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Sat, 25 Jul 2026 04:11:57 +0500 Subject: [PATCH 076/466] Fix: prevent search-router focus from flickering when a match is already highlighted and typing continues --- .../Search/SearchAutocompleteList.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 403261c2b97e..fabdef5b3abb 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -734,8 +734,23 @@ function SearchAutocompleteList({ return; } - // Otherwise the debounce is still pending for this query: focus the search query item (index 0) and - // scroll to top. The highlight effect below switches focus to the first result once a good match settles. + // The debounce hasn't caught up yet for this keystroke. If focus is already resting on the match from a + // previous, already-settled keystroke, and the freshly typed text still matches that same row, keep focus + // there instead of bouncing to the query row and waiting out a fresh debounce window -- this is what + // prevents a visible flicker when continuing to type past an already-highlighted match (production has no + // debounce gap here, so it never loses the highlight in this case either). + if ( + shouldHighlightFirstItem && + firstRecentReportFlatIndex !== -1 && + lastProgrammaticFocusKeyRef.current === firstRecentReportKey && + shouldHighlight(normalizedReferenceText, effectiveInputQueryValue) + ) { + return; + } + + // Otherwise the debounce is still pending and the match is no longer valid for this keystroke: focus the + // search query item (index 0) and scroll to top. The highlight effect below switches focus to the first + // result once a good match settles. lastProgrammaticFocusKeyRef.current = searchQueryRowKey; innerListRef.current?.updateAndScrollToFocusedIndex(0, true); }, [ @@ -763,9 +778,7 @@ function SearchAutocompleteList({ }, [isLoadingOptions, firstRecentReportFlatIndex, firstRecentReportKey, shouldUseNarrowLayout]); useEffect(() => { - const targetText = autocompleteQueryValue; - - if (!shouldHighlightFirstItem || firstRecentReportFlatIndex === -1 || !shouldHighlight(normalizedReferenceText, targetText)) { + if (!shouldHighlightFirstItem || firstRecentReportFlatIndex === -1 || !shouldHighlight(normalizedReferenceText, autocompleteQueryValue)) { return; } From 49846f731288e8a0e95120153c3b6e9dc8fe98ab Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:56:31 +0700 Subject: [PATCH 077/466] Extract shared context --- .../context/FeatureTrainingContext.ts | 35 +++++++++++++++ .../FeatureTraining/context/index.ts | 2 + .../FeatureTraining/context/types.ts | 44 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 src/components/FeatureTraining/context/FeatureTrainingContext.ts create mode 100644 src/components/FeatureTraining/context/index.ts create mode 100644 src/components/FeatureTraining/context/types.ts diff --git a/src/components/FeatureTraining/context/FeatureTrainingContext.ts b/src/components/FeatureTraining/context/FeatureTrainingContext.ts new file mode 100644 index 000000000000..a8a567a86111 --- /dev/null +++ b/src/components/FeatureTraining/context/FeatureTrainingContext.ts @@ -0,0 +1,35 @@ +import {createContext, useContext} from 'react'; + +import type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './types'; + +const defaultState: FeatureTrainingStateValue = { + willShowAgain: true, + shouldShowLoadingImmediatelyOnPress: undefined, + isCarousel: false, + confirmSentryLabel: undefined, + currentPage: undefined, + pageCount: undefined, + isLastPage: undefined, + contentMinHeight: undefined, +}; + +const defaultActions: FeatureTrainingActionsValue = { + toggleWillShowAgain: () => {}, + handleConfirm: () => {}, + handleClose: () => {}, + advance: undefined, + goBack: undefined, +}; + +const FeatureTrainingStateContext = createContext(defaultState); +const FeatureTrainingActionsContext = createContext(defaultActions); + +function useFeatureTrainingState(): FeatureTrainingStateValue { + return useContext(FeatureTrainingStateContext); +} + +function useFeatureTrainingActions(): FeatureTrainingActionsValue { + return useContext(FeatureTrainingActionsContext); +} + +export {FeatureTrainingStateContext, FeatureTrainingActionsContext, useFeatureTrainingState, useFeatureTrainingActions}; diff --git a/src/components/FeatureTraining/context/index.ts b/src/components/FeatureTraining/context/index.ts new file mode 100644 index 000000000000..068102501430 --- /dev/null +++ b/src/components/FeatureTraining/context/index.ts @@ -0,0 +1,2 @@ +export {FeatureTrainingActionsContext, FeatureTrainingStateContext, useFeatureTrainingActions, useFeatureTrainingState} from './FeatureTrainingContext'; +export type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './types'; diff --git a/src/components/FeatureTraining/context/types.ts b/src/components/FeatureTraining/context/types.ts new file mode 100644 index 000000000000..b2265b91ee75 --- /dev/null +++ b/src/components/FeatureTraining/context/types.ts @@ -0,0 +1,44 @@ +type FeatureTrainingStateValue = { + /** Whether the "don't show me this again" option is currently unchecked (i.e. modal will show again) */ + willShowAgain: boolean; + + /** Whether the confirm action should show a spinner immediately on press */ + shouldShowLoadingImmediatelyOnPress: boolean | undefined; + + /** Whether this content is being rendered inside a carousel */ + isCarousel: boolean; + + /** Sentry label for the confirm button */ + confirmSentryLabel: string | undefined; + + /** Carousel-only: current 0-based page index (undefined in single-page mode) */ + currentPage: number | undefined; + + /** Carousel-only: total number of pages (undefined in single-page mode) */ + pageCount: number | undefined; + + /** Carousel-only: true when currentPage is the last one (undefined in single-page mode) */ + isLastPage: boolean | undefined; + + /** Carousel-only: locked minimum content height once all page heights are measured */ + contentMinHeight: number | undefined; +}; + +type FeatureTrainingActionsValue = { + /** Toggles the "don't show again" state */ + toggleWillShowAgain: () => void; + + /** Fires the consumer's onConfirm callback with the current willShowAgain value; also closes when configured */ + handleConfirm: () => void; + + /** Closes the modal */ + handleClose: () => void; + + /** Carousel-only: advances to the next page (undefined in single-page mode) */ + advance: (() => void) | undefined; + + /** Carousel-only: returns to the previous page (undefined in single-page mode) */ + goBack: (() => void) | undefined; +}; + +export type {FeatureTrainingStateValue, FeatureTrainingActionsValue}; From 15f2b4d4712b916fece8ead533299fadeba3a741 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:57:11 +0700 Subject: [PATCH 078/466] Extract button row components --- .../FeatureTraining/primitives/BackButton.tsx | 35 +++++++++++++++ .../FeatureTraining/primitives/ButtonRow.tsx | 19 ++++++++ .../primitives/ConfirmButton.tsx | 45 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/components/FeatureTraining/primitives/BackButton.tsx create mode 100644 src/components/FeatureTraining/primitives/ButtonRow.tsx create mode 100644 src/components/FeatureTraining/primitives/ConfirmButton.tsx diff --git a/src/components/FeatureTraining/primitives/BackButton.tsx b/src/components/FeatureTraining/primitives/BackButton.tsx new file mode 100644 index 000000000000..206fdaca70d9 --- /dev/null +++ b/src/components/FeatureTraining/primitives/BackButton.tsx @@ -0,0 +1,35 @@ +import Button from '@components/ButtonComposed'; +import {useFeatureTrainingActions, useFeatureTrainingState} from '@components/FeatureTraining/context'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +function BackButton() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isCarousel, currentPage} = useFeatureTrainingState(); + const {goBack} = useFeatureTrainingActions(); + + if (!isCarousel || currentPage === 0 || !goBack) { + return null; + } + + return ( + + ); +} + +BackButton.displayName = 'FeatureTraining.BackButton'; + +export default BackButton; diff --git a/src/components/FeatureTraining/primitives/ButtonRow.tsx b/src/components/FeatureTraining/primitives/ButtonRow.tsx new file mode 100644 index 000000000000..5998fb2b2139 --- /dev/null +++ b/src/components/FeatureTraining/primitives/ButtonRow.tsx @@ -0,0 +1,19 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; + +import React from 'react'; +import {View} from 'react-native'; + +type ButtonRowProps = { + children?: ReactNode; +}; + +function ButtonRow({children}: ButtonRowProps) { + const styles = useThemeStyles(); + return {children}; +} + +ButtonRow.displayName = 'FeatureTraining.ButtonRow'; + +export default ButtonRow; diff --git a/src/components/FeatureTraining/primitives/ConfirmButton.tsx b/src/components/FeatureTraining/primitives/ConfirmButton.tsx new file mode 100644 index 000000000000..4c7300280928 --- /dev/null +++ b/src/components/FeatureTraining/primitives/ConfirmButton.tsx @@ -0,0 +1,45 @@ +import {useFeatureTrainingActions, useFeatureTrainingState} from '@components/FeatureTraining/context'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +type ConfirmButtonProps = { + children: string; + sentryLabel?: string; +}; + +function ConfirmButton({children, sentryLabel}: ConfirmButtonProps) { + const styles = useThemeStyles(); + const {confirmSentryLabel, shouldShowLoadingImmediatelyOnPress, isCarousel, isLastPage} = useFeatureTrainingState(); + const {handleConfirm, advance} = useFeatureTrainingActions(); + + const isNextPageAction = isCarousel && !isLastPage; + + const onSubmit = () => { + if (isNextPageAction && advance) { + advance(); + return; + } + handleConfirm(); + }; + + const loading = isNextPageAction ? false : shouldShowLoadingImmediatelyOnPress; + + return ( + + ); +} + +ConfirmButton.displayName = 'FeatureTraining.ConfirmButton'; + +export default ConfirmButton; +export type {ConfirmButtonProps}; From e5eea08cb42b54f310ce87058cdc64bacd5f3aef Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:57:50 +0700 Subject: [PATCH 079/466] Extract text content components --- .../primitives/Description.tsx | 35 ++++++++++++++++++ .../FeatureTraining/primitives/Subtitle.tsx | 26 ++++++++++++++ .../FeatureTraining/primitives/Title.tsx | 36 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 src/components/FeatureTraining/primitives/Description.tsx create mode 100644 src/components/FeatureTraining/primitives/Subtitle.tsx create mode 100644 src/components/FeatureTraining/primitives/Title.tsx diff --git a/src/components/FeatureTraining/primitives/Description.tsx b/src/components/FeatureTraining/primitives/Description.tsx new file mode 100644 index 000000000000..823155e34a7d --- /dev/null +++ b/src/components/FeatureTraining/primitives/Description.tsx @@ -0,0 +1,35 @@ +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +type DescriptionProps = { + children?: string; + shouldRenderHTML?: boolean; +}; + +function Description({children, shouldRenderHTML = false}: DescriptionProps) { + const styles = useThemeStyles(); + + if (!children) { + return null; + } + + if (shouldRenderHTML) { + return ( + + + + ); + } + + return {children}; +} + +Description.displayName = 'FeatureTraining.Description'; + +export default Description; +export type {DescriptionProps}; diff --git a/src/components/FeatureTraining/primitives/Subtitle.tsx b/src/components/FeatureTraining/primitives/Subtitle.tsx new file mode 100644 index 000000000000..2878ab822415 --- /dev/null +++ b/src/components/FeatureTraining/primitives/Subtitle.tsx @@ -0,0 +1,26 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; + +import React from 'react'; + +type SubtitleProps = { + children?: ReactNode; +}; + +function Subtitle({children}: SubtitleProps) { + const styles = useThemeStyles(); + + if (children == null || children === '') { + return null; + } + + return {children}; +} + +Subtitle.displayName = 'FeatureTraining.Subtitle'; + +export default Subtitle; +export type {SubtitleProps}; diff --git a/src/components/FeatureTraining/primitives/Title.tsx b/src/components/FeatureTraining/primitives/Title.tsx new file mode 100644 index 000000000000..712ae0ad2944 --- /dev/null +++ b/src/components/FeatureTraining/primitives/Title.tsx @@ -0,0 +1,36 @@ +import Text from '@components/Text'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; + +import React, {isValidElement} from 'react'; + +type TitleProps = { + children?: ReactNode; + style?: StyleProp; +}; + +function Title({children, style}: TitleProps) { + const styles = useThemeStyles(); + + if (children == null || children === '') { + return null; + } + + if (typeof children === 'string') { + return {children}; + } + + if (isValidElement(children)) { + return children; + } + + return null; +} + +Title.displayName = 'FeatureTraining.Title'; + +export default Title; +export type {TitleProps}; From 72b51359173952470860406c4e680ff92bf1be5f Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:58:51 +0700 Subject: [PATCH 080/466] Extract util buttons --- .../primitives/DismissOption.tsx | 28 ++++++++++++++++ .../FeatureTraining/primitives/HelpButton.tsx | 33 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/components/FeatureTraining/primitives/DismissOption.tsx create mode 100644 src/components/FeatureTraining/primitives/HelpButton.tsx diff --git a/src/components/FeatureTraining/primitives/DismissOption.tsx b/src/components/FeatureTraining/primitives/DismissOption.tsx new file mode 100644 index 000000000000..1bbd5cd1ad15 --- /dev/null +++ b/src/components/FeatureTraining/primitives/DismissOption.tsx @@ -0,0 +1,28 @@ +import CheckboxWithLabel from '@components/CheckboxWithLabel'; +import {useFeatureTrainingActions, useFeatureTrainingState} from '@components/FeatureTraining/context'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +function DismissOption() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {willShowAgain} = useFeatureTrainingState(); + const {toggleWillShowAgain} = useFeatureTrainingActions(); + + return ( + + ); +} + +DismissOption.displayName = 'FeatureTraining.DismissOption'; + +export default DismissOption; diff --git a/src/components/FeatureTraining/primitives/HelpButton.tsx b/src/components/FeatureTraining/primitives/HelpButton.tsx new file mode 100644 index 000000000000..6c4bcb0d575a --- /dev/null +++ b/src/components/FeatureTraining/primitives/HelpButton.tsx @@ -0,0 +1,33 @@ +import Button from '@components/ButtonComposed'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type HelpButtonProps = { + children: string; + onPress?: () => void; + sentryLabel?: string; +}; + +function HelpButton({children, onPress, sentryLabel}: HelpButtonProps) { + const styles = useThemeStyles(); + + return ( + + ); +} + +HelpButton.displayName = 'FeatureTraining.HelpButton'; + +export default HelpButton; +export type {HelpButtonProps}; From c92cedc2adccedf7c4151b147c69c7d02b2198d2 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:59:16 +0700 Subject: [PATCH 081/466] Extract illustration component --- .../primitives/Illustration.tsx | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/components/FeatureTraining/primitives/Illustration.tsx diff --git a/src/components/FeatureTraining/primitives/Illustration.tsx b/src/components/FeatureTraining/primitives/Illustration.tsx new file mode 100644 index 000000000000..a8b69f655fc9 --- /dev/null +++ b/src/components/FeatureTraining/primitives/Illustration.tsx @@ -0,0 +1,177 @@ +import ImageSVG from '@components/ImageSVG'; +import type ImageSVGProps from '@components/ImageSVG/types'; +import Lottie from '@components/Lottie'; +import LottieAnimations from '@components/LottieAnimations'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; +import VideoPlayer from '@components/VideoPlayer'; + +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import Accessibility from '@libs/Accessibility'; +import {isMobile} from '@libs/Browser'; +import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; +import {getIsOffline} from '@libs/NetworkState'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; + +import type {ImageContentFit} from 'expo-image'; +import type {SourceLoadEventPayload} from 'expo-video'; +import type LottieView from 'lottie-react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; +import type {MergeExclusive} from 'type-fest'; + +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; +import {GestureHandlerRootView} from 'react-native-gesture-handler'; + +const VIDEO_ASPECT_RATIO = 1280 / 960; +const CONTENT_PADDING = variables.spacing2; +const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7; + +type VideoStatus = 'video' | 'animation'; + +type VideoOrAnimationProps = { + animation?: DotLottieAnimation; + animationStyle?: StyleProp; + videoURL?: string; +}; + +type ImageProps = { + image: IconAsset; + contentFitImage?: ImageContentFit; + imageWidth?: ImageSVGProps['width']; + imageHeight?: ImageSVGProps['height']; +}; + +type IllustrationMediaProps = MergeExclusive; + +type IllustrationProps = IllustrationMediaProps & { + aspectRatio?: number; + innerContainerStyle?: StyleProp; + outerContainerStyle?: StyleProp; + + /** + * Whether this illustration is the currently-focused page in a carousel. Injected by FeatureTraining.Carousel + * via cloneElement — consumers should not set this directly. Defaults to true (always play) for single-page use. + */ + isFocused?: boolean; +}; + +function useVideoStatus(): VideoStatus { + const [isLockedToVideo, setIsLockedToVideo] = useState(() => !getIsOffline()); + const {isOffline} = useNetwork({ + onReconnect: () => setIsLockedToVideo(true), + }); + return isLockedToVideo || !isOffline ? 'video' : 'animation'; +} + +function Illustration({ + animation, + animationStyle, + videoURL, + image, + contentFitImage, + imageWidth, + imageHeight, + aspectRatio: aspectRatioProp, + innerContainerStyle, + outerContainerStyle, + isFocused = true, +}: IllustrationProps) { + const styles = useThemeStyles(); + const isReduceMotionEnabled = Accessibility.useReducedMotion(); + const illustrations = useMemoizedLazyIllustrations(['Hands']); + const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const videoStatus = useVideoStatus(); + const {windowHeight, windowWidth} = useWindowDimensions(); + const [measuredAspectRatio, setMeasuredAspectRatio] = useState(aspectRatioProp ?? VIDEO_ASPECT_RATIO); + const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); + + const animationRef = useRef(null); + useEffect(() => { + if (isMobile() || !animationRef.current || isReduceMotionEnabled) { + return; + } + if (isFocused) { + animationRef.current.play(); + } else { + animationRef.current.pause(); + } + }, [isFocused, isReduceMotionEnabled]); + + const setAspectRatio = (event: SourceLoadEventPayload) => { + const track = event.availableVideoTracks.at(0); + if (!track) { + return; + } + setMeasuredAspectRatio(track.size.width / track.size.height); + }; + + const aspectRatio = measuredAspectRatio || VIDEO_ASPECT_RATIO; + + return ( + + + {!!image && ( + + )} + {!!videoURL && videoStatus === 'video' && ( + + + + )} + {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && ( + + {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? ( + + ) : ( + + )} + + )} + + + ); +} + +Illustration.displayName = 'FeatureTraining.Illustration'; + +export default Illustration; +export type {IllustrationProps}; From de6bf5f41c4c7ed63b178f330d513116024aa002 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:59:29 +0700 Subject: [PATCH 082/466] Extract wrappers --- .../FeatureTraining/primitives/Body.tsx | 43 +++++++++++++++++++ .../FeatureTraining/primitives/Page.tsx | 26 +++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/components/FeatureTraining/primitives/Body.tsx create mode 100644 src/components/FeatureTraining/primitives/Page.tsx diff --git a/src/components/FeatureTraining/primitives/Body.tsx b/src/components/FeatureTraining/primitives/Body.tsx new file mode 100644 index 000000000000..f13c592080f9 --- /dev/null +++ b/src/components/FeatureTraining/primitives/Body.tsx @@ -0,0 +1,43 @@ +import {useFeatureTrainingState} from '@components/FeatureTraining/context'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +type BodyProps = { + children?: ReactNode; + + /** Style applied to the outer body container */ + outerStyle?: StyleProp; + + /** Style applied to the inner text container */ + innerStyle?: StyleProp; + + /** onLayout used by the carousel probe layer to measure page heights */ + onLayout?: (event: LayoutChangeEvent) => void; +}; + +function Body({children, outerStyle, innerStyle, onLayout}: BodyProps) { + const styles = useThemeStyles(); + const {contentMinHeight} = useFeatureTrainingState(); + + return ( + + + {children} + + + ); +} + +Body.displayName = 'FeatureTraining.Body'; + +export default Body; +export type {BodyProps}; diff --git a/src/components/FeatureTraining/primitives/Page.tsx b/src/components/FeatureTraining/primitives/Page.tsx new file mode 100644 index 000000000000..259257578512 --- /dev/null +++ b/src/components/FeatureTraining/primitives/Page.tsx @@ -0,0 +1,26 @@ +import type {ReactNode} from 'react'; + +import {isValidElement} from 'react'; + +type PageProps = { + children?: ReactNode; +}; + +/** + * Marker component. When rendered directly outside a Carousel it returns its children as-is. + * The Carousel root filters children by this component type to enumerate pages. + */ +function Page({children}: PageProps) { + if (children == null) { + return null; + } + if (isValidElement(children)) { + return children; + } + return null; +} + +Page.displayName = 'FeatureTraining.Page'; + +export default Page; +export type {PageProps}; From 50275bfb6e125479924d82e786aab67509f41d76 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:59:59 +0700 Subject: [PATCH 083/466] Extract pagination components --- .../primitives/CloseButton.tsx | 51 +++++++++++++++++++ .../primitives/PaginationDots.tsx | 51 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/components/FeatureTraining/primitives/CloseButton.tsx create mode 100644 src/components/FeatureTraining/primitives/PaginationDots.tsx diff --git a/src/components/FeatureTraining/primitives/CloseButton.tsx b/src/components/FeatureTraining/primitives/CloseButton.tsx new file mode 100644 index 000000000000..bdaf3ca33524 --- /dev/null +++ b/src/components/FeatureTraining/primitives/CloseButton.tsx @@ -0,0 +1,51 @@ +import {useFeatureTrainingActions} from '@components/FeatureTraining/context'; +import Icon from '@components/Icon'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Tooltip from '@components/Tooltip'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +const CONTENT_PADDING = variables.spacing2; + +function CloseButton() { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const {translate} = useLocalize(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); + const {handleClose} = useFeatureTrainingActions(); + + return ( + + + + + + + + ); +} + +CloseButton.displayName = 'FeatureTraining.CloseButton'; + +export default CloseButton; diff --git a/src/components/FeatureTraining/primitives/PaginationDots.tsx b/src/components/FeatureTraining/primitives/PaginationDots.tsx new file mode 100644 index 000000000000..c179c34d2f7b --- /dev/null +++ b/src/components/FeatureTraining/primitives/PaginationDots.tsx @@ -0,0 +1,51 @@ +import {useFeatureTrainingState} from '@components/FeatureTraining/context'; + +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import React from 'react'; +import {View} from 'react-native'; + +const CAROUSEL_DOT_SIZE = 6; +const PAGINATION_DOTS_BOTTOM_OFFSET = 5; +const CONTENT_PADDING = variables.spacing2; + +function PaginationDots() { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const theme = useTheme(); + const {isCarousel, currentPage, pageCount} = useFeatureTrainingState(); + + if (!isCarousel || currentPage === undefined || pageCount === undefined) { + return null; + } + + return ( + + {Array.from({length: pageCount}, (_v, index) => ( + + ))} + + ); +} + +PaginationDots.displayName = 'FeatureTraining.PaginationDots'; + +export default PaginationDots; From 377ab2e7d1423e81e9c63d1b13226a9dfdeb70aa Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:00:26 +0700 Subject: [PATCH 084/466] Extract scrollable wrapper hook --- .../hooks/useScrollableWrapper.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/components/FeatureTraining/hooks/useScrollableWrapper.ts diff --git a/src/components/FeatureTraining/hooks/useScrollableWrapper.ts b/src/components/FeatureTraining/hooks/useScrollableWrapper.ts new file mode 100644 index 000000000000..bfff2ca6ac05 --- /dev/null +++ b/src/components/FeatureTraining/hooks/useScrollableWrapper.ts @@ -0,0 +1,80 @@ +import ScrollView from '@components/ScrollView'; + +import useKeyboardState from '@hooks/useKeyboardState'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; + +import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; + +import CONST from '@src/CONST'; + +// eslint-disable-next-line no-restricted-imports -- type-only import from react-native +import type {LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, ViewStyle} from 'react-native'; + +import {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +type UseScrollableWrapperOptions = { + shouldUseScrollView?: boolean; + width?: number; +}; + +function useScrollableWrapper({shouldUseScrollView: shouldUseScrollViewProp = false, width}: UseScrollableWrapperOptions) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); + const {windowHeight, windowWidth} = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const {isKeyboardActive} = useKeyboardState(); + + const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); + const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; + + const scrollViewRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(0); + const [contentHeight, setContentHeight] = useState(0); + + useEffect(() => { + if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) { + return; + } + scrollViewRef.current?.scrollToEnd({animated: false}); + }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]); + + const Wrapper = shouldUseScrollView ? ScrollView : View; + const wrapperStyles = shouldUseScrollView + ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) + : ({} as {style?: StyleProp; containerStyle?: StyleProp}); + + const style: StyleProp = [ + onboardingIsMediumOrLargerScreenWidth && width !== undefined && StyleUtils.getWidthStyle(width), + wrapperStyles.style, + isInLandscapeMode ? {maxHeight: windowHeight * CONST.MODAL_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO_LANDSCAPE_MODE} : styles.mh100, + ]; + + const onLayout = shouldUseScrollView ? (e: LayoutChangeEvent) => setContainerHeight(e.nativeEvent.layout.height) : undefined; + const onContentSizeChange = shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined; + + return { + Wrapper, + wrapperProps: { + scrollsToTop: false, + style, + contentContainerStyle: wrapperStyles.containerStyle, + keyboardShouldPersistTaps: shouldUseScrollView ? ('handled' as const) : undefined, + ref: shouldUseScrollView ? scrollViewRef : undefined, + onLayout, + onContentSizeChange, + fsClass: CONST.FULLSTORY.CLASS.UNMASK, + }, + setContainerHeight, + setContentHeight, + shouldUseScrollView, + isInLandscapeMode, + }; +} + +export default useScrollableWrapper; From c44aea76a40b6c4f2e8b754853d324d7f8fe67bf Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:00:47 +0700 Subject: [PATCH 085/466] Extract base component --- .../FeatureTraining/FeatureTraining.tsx | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/components/FeatureTraining/FeatureTraining.tsx diff --git a/src/components/FeatureTraining/FeatureTraining.tsx b/src/components/FeatureTraining/FeatureTraining.tsx new file mode 100644 index 000000000000..2a58f144b1a3 --- /dev/null +++ b/src/components/FeatureTraining/FeatureTraining.tsx @@ -0,0 +1,74 @@ +import variables from '@styles/variables'; + +import React, {useCallback, useMemo, useState} from 'react'; + +import type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './context'; +import type {FeatureTrainingProps} from './types'; + +import {FeatureTrainingActionsContext, FeatureTrainingStateContext} from './context'; +import useScrollableWrapper from './hooks/useScrollableWrapper'; + +function FeatureTraining({ + onConfirm, + onClose, + onWillShowAgainChange, + shouldCloseOnConfirm = true, + shouldUseScrollView = false, + width = variables.featureTrainingModalWidth, + confirmSentryLabel, + children, +}: FeatureTrainingProps) { + const [willShowAgain, setWillShowAgain] = useState(true); + const {Wrapper, wrapperProps} = useScrollableWrapper({shouldUseScrollView, width}); + + const toggleWillShowAgain = useCallback(() => { + onWillShowAgainChange?.(!willShowAgain); + setWillShowAgain((prev) => !prev); + }, [onWillShowAgainChange, willShowAgain]); + + const handleConfirm = useCallback(() => { + onConfirm?.(willShowAgain); + if (shouldCloseOnConfirm) { + onClose?.(); + } + }, [onConfirm, onClose, shouldCloseOnConfirm, willShowAgain]); + + const handleClose = useCallback(() => onClose?.(), [onClose]); + + const stateValue = useMemo( + () => ({ + willShowAgain, + shouldShowLoadingImmediatelyOnPress: undefined, + isCarousel: false, + confirmSentryLabel, + currentPage: undefined, + pageCount: undefined, + isLastPage: undefined, + contentMinHeight: undefined, + }), + [willShowAgain, confirmSentryLabel], + ); + + const actionsValue = useMemo( + () => ({ + toggleWillShowAgain, + handleConfirm, + handleClose, + advance: undefined, + goBack: undefined, + }), + [toggleWillShowAgain, handleConfirm, handleClose], + ); + + return ( + + + {children} + + + ); +} + +FeatureTraining.displayName = 'FeatureTraining'; + +export default FeatureTraining; From 8db304421ef744eaaa9e248945fe126da239c5aa Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:01:00 +0700 Subject: [PATCH 086/466] Extract carousel component --- .../FeatureTrainingCarousel.tsx | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 src/components/FeatureTraining/FeatureTrainingCarousel.tsx diff --git a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx new file mode 100644 index 000000000000..556c346825df --- /dev/null +++ b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx @@ -0,0 +1,267 @@ +import variables from '@styles/variables'; + +import type {ReactElement, ReactNode} from 'react'; +import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native'; + +import React, {cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {FlatList, Platform, View} from 'react-native'; + +import type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './context'; +import type {IllustrationProps} from './primitives/Illustration'; +import type {FeatureTrainingCarouselProps} from './types'; + +import {FeatureTrainingActionsContext, FeatureTrainingStateContext} from './context'; +import useScrollableWrapper from './hooks/useScrollableWrapper'; +import Body from './primitives/Body'; +import CloseButton from './primitives/CloseButton'; +import Illustration from './primitives/Illustration'; +import Page from './primitives/Page'; +import PaginationDots from './primitives/PaginationDots'; + +const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThreshold: 95}; + +const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start', scrollSnapStop: 'always'} as ViewStyle) : {}; + +type BodyElement = ReactElement>; +type IllustrationElement = ReactElement; +type PageElement = ReactElement>; + +function isBodyElement(child: ReactElement): child is BodyElement { + return child.type === Body; +} +function isIllustrationElement(child: ReactElement): child is IllustrationElement { + return child.type === Illustration; +} +function isPageElement(child: ReactElement): child is PageElement { + return child.type === Page; +} + +type SplitPage = { + illustration: IllustrationElement | null; + body: BodyElement | null; +}; + +function splitPageChildren(pageChildren: ReactNode): SplitPage { + let illustration: IllustrationElement | null = null; + let body: BodyElement | null = null; + React.Children.forEach(pageChildren, (child) => { + if (!isValidElement(child)) { + return; + } + if (isIllustrationElement(child)) { + illustration = child; + return; + } + if (isBodyElement(child)) { + body = child; + } + }); + return {illustration, body}; +} + +function FeatureTrainingCarousel({ + onConfirm, + onClose, + onPageChange, + shouldCloseOnConfirm = true, + shouldUseScrollView = false, + width = variables.featureTrainingModalWidth, + confirmSentryLabel, + children, +}: FeatureTrainingCarouselProps) { + const [currentPage, setCurrentPage] = useState(0); + const [carouselViewportWidth, setCarouselViewportWidth] = useState(0); + const horizontalListRef = useRef>(null); + const lastReportedPage = useRef(0); + + const [contentMinHeight, setContentMinHeight] = useState(undefined); + const measuredHeightsRef = useRef>({}); + + const {Wrapper, wrapperProps, setContainerHeight, shouldUseScrollView: usingScrollView} = useScrollableWrapper({shouldUseScrollView, width}); + + const pages = useMemo(() => { + const pageList: SplitPage[] = []; + React.Children.forEach(children, (child) => { + if (!isValidElement(child) || !isPageElement(child)) { + return; + } + pageList.push(splitPageChildren(child.props.children)); + }); + return pageList; + }, [children]); + + const onPageChangeRef = useRef(onPageChange); + useEffect(() => { + onPageChangeRef.current = onPageChange; + }, [onPageChange]); + + const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]}) => { + const entry = viewableItems.at(0); + if (entry?.index == null || entry.index === lastReportedPage.current) { + return; + } + lastReportedPage.current = entry.index; + setCurrentPage(entry.index); + onPageChangeRef.current?.(entry.index); + }, []); + + const advance = useCallback(() => { + horizontalListRef.current?.scrollToIndex({index: Math.min(currentPage + 1, pages.length - 1), animated: true}); + }, [currentPage, pages.length]); + + const goBack = useCallback(() => { + if (currentPage <= 0) { + return; + } + horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true}); + }, [currentPage]); + + const pageCountRef = useRef(pages.length); + useEffect(() => { + pageCountRef.current = pages.length; + }, [pages.length]); + + const recordPageHeight = useCallback((index: number, measured: number) => { + if (measuredHeightsRef.current[index] === measured) { + return; + } + measuredHeightsRef.current[index] = measured; + if (Object.keys(measuredHeightsRef.current).length < pageCountRef.current) { + return; + } + setContentMinHeight(Math.max(...Object.values(measuredHeightsRef.current))); + }, []); + + const isLastPage = pages.length === 0 || currentPage >= pages.length - 1; + + const handleConfirm = useCallback(() => { + onConfirm?.(false); + if (shouldCloseOnConfirm) { + onClose?.(); + } + }, [onConfirm, onClose, shouldCloseOnConfirm]); + + const handleClose = useCallback(() => onClose?.(), [onClose]); + + const stateValue = useMemo( + () => ({ + willShowAgain: true, + shouldShowLoadingImmediatelyOnPress: isLastPage ? undefined : false, + isCarousel: true, + confirmSentryLabel, + currentPage, + pageCount: pages.length, + isLastPage, + contentMinHeight, + }), + [isLastPage, confirmSentryLabel, currentPage, pages.length, contentMinHeight], + ); + + const actionsValue = useMemo( + () => ({ + toggleWillShowAgain: () => {}, + handleConfirm, + handleClose, + advance, + goBack, + }), + [handleConfirm, handleClose, advance, goBack], + ); + + const currentPageBody = pages.at(currentPage)?.body ?? null; + + const onWrapperLayout = useCallback( + (e: LayoutChangeEvent) => { + const newWidth = e.nativeEvent.layout.width; + if (newWidth === carouselViewportWidth || newWidth <= 0) { + return; + } + setCarouselViewportWidth(newWidth); + if (!usingScrollView) { + return; + } + setContainerHeight(e.nativeEvent.layout.height); + }, + [carouselViewportWidth, usingScrollView, setContainerHeight], + ); + + const probeStyle = useMemo(() => ({position: 'absolute', left: 0, top: 0, width: carouselViewportWidth, opacity: 0}), [carouselViewportWidth]); + + return ( + + + + {carouselViewportWidth > 0 && contentMinHeight === undefined && ( + + {pages.map((probePage, index) => + probePage.body == null ? null : ( + + ), + )} + + )} + {carouselViewportWidth > 0 && ( + <> + + `FeatureTrainingCarousel-page-${index}`} + horizontal + pagingEnabled + disableIntervalMomentum + snapToInterval={carouselViewportWidth} + decelerationRate="fast" + bounces={false} + showsHorizontalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG} + onViewableItemsChanged={onViewableItemsChanged} + getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})} + renderItem={({item, index}) => ( + + {item.illustration == null ? null : cloneElement(item.illustration, {isFocused: index === currentPage})} + + )} + /> + + + {currentPageBody} + + + )} + + + + ); +} + +FeatureTrainingCarousel.displayName = 'FeatureTraining.Carousel'; + +type ProbePageProps = { + index: number; + body: BodyElement; + onMeasure: (index: number, height: number) => void; +}; + +function ProbePage({index, body, onMeasure}: ProbePageProps) { + const onLayout = useCallback((event: LayoutChangeEvent) => onMeasure(index, event.nativeEvent.layout.height), [index, onMeasure]); + return cloneElement(body, {onLayout}); +} + +export default FeatureTrainingCarousel; From 9006953a25dd188d9ade8eb59d66319cf85a8789 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:01:29 +0700 Subject: [PATCH 087/466] Extract types and export components --- src/components/FeatureTraining/index.tsx | 31 ++++++++++++++++++ src/components/FeatureTraining/types.ts | 40 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/components/FeatureTraining/index.tsx create mode 100644 src/components/FeatureTraining/types.ts diff --git a/src/components/FeatureTraining/index.tsx b/src/components/FeatureTraining/index.tsx new file mode 100644 index 000000000000..5b6865dc9e93 --- /dev/null +++ b/src/components/FeatureTraining/index.tsx @@ -0,0 +1,31 @@ +import FeatureTrainingBase from './FeatureTraining'; +import FeatureTrainingCarousel from './FeatureTrainingCarousel'; +import BackButton from './primitives/BackButton'; +import Body from './primitives/Body'; +import ButtonRow from './primitives/ButtonRow'; +import ConfirmButton from './primitives/ConfirmButton'; +import Description from './primitives/Description'; +import DismissOption from './primitives/DismissOption'; +import HelpButton from './primitives/HelpButton'; +import Illustration from './primitives/Illustration'; +import Page from './primitives/Page'; +import Subtitle from './primitives/Subtitle'; +import Title from './primitives/Title'; + +const FeatureTraining = Object.assign(FeatureTrainingBase, { + Carousel: FeatureTrainingCarousel, + Page, + Illustration, + Body, + Title, + Subtitle, + Description, + DismissOption, + HelpButton, + ConfirmButton, + BackButton, + ButtonRow, +}); + +export default FeatureTraining; +export type {FeatureTrainingProps, FeatureTrainingCarouselProps, FeatureTrainingModalProps} from './types'; diff --git a/src/components/FeatureTraining/types.ts b/src/components/FeatureTraining/types.ts new file mode 100644 index 000000000000..da24e4d68618 --- /dev/null +++ b/src/components/FeatureTraining/types.ts @@ -0,0 +1,40 @@ +import type {ReactNode} from 'react'; +import type {ViewStyle} from 'react-native'; + +type FeatureTrainingProps = { + /** Called when the user confirms the tutorial. `willShowAgain` reflects the DismissOption checkbox (true by default). */ + onConfirm?: (willShowAgain: boolean) => void; + + /** Called when the modal closes */ + onClose?: () => void; + + /** Called when the DismissOption checkbox is toggled */ + onWillShowAgainChange?: (willShowAgain: boolean) => void; + + /** Whether the modal should close after confirm (defaults to true) */ + shouldCloseOnConfirm?: boolean; + + /** Whether the content should render inside a ScrollView (auto-enabled in landscape) */ + shouldUseScrollView?: boolean; + + /** Modal content width on medium-or-larger screens */ + width?: number; + + /** Sentry label for the confirm button; can also be set per-primitive on */ + confirmSentryLabel?: string; + + /** Composed content — Illustration, Body, DismissOption, HelpButton, ConfirmButton, etc. */ + children?: ReactNode; +}; + +type FeatureTrainingCarouselProps = FeatureTrainingProps & { + /** Called when the visible page changes */ + onPageChange?: (index: number) => void; +}; + +type FeatureTrainingModalProps = FeatureTrainingProps & { + /** Style for the outer Modal inner container */ + modalInnerContainerStyle?: ViewStyle; +}; + +export type {FeatureTrainingProps, FeatureTrainingCarouselProps, FeatureTrainingModalProps}; From 898d33d07a8cf0918ac72cc7ddfef93c2ab4cb90 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:02:00 +0700 Subject: [PATCH 088/466] Use the new feature training carousel in AI features promo modal --- src/components/AIFeaturesPromoModal.tsx | 98 ++++++++++++++----------- 1 file changed, 57 insertions(+), 41 deletions(-) diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx index a3b9dca157d8..8d8cb7c8c93b 100644 --- a/src/components/AIFeaturesPromoModal.tsx +++ b/src/components/AIFeaturesPromoModal.tsx @@ -12,11 +12,9 @@ import CONST from '@src/CONST'; import React from 'react'; import {View} from 'react-native'; -import type {FeatureTrainingContentDataProps} from './FeatureTrainingContent'; - import Badge from './Badge'; import CenteredModalLayout from './CenteredModalLayout'; -import {FeatureTrainingCarousel} from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import LottieAnimations from './LottieAnimations'; import Text from './Text'; @@ -38,34 +36,6 @@ function AIFeaturesPromoModal() { ); - const pages: FeatureTrainingContentDataProps[] = [ - { - animation: LottieAnimations.SpendAnalysis, - title: translate('aiFeaturesPromoModal.spendAnalysis.title'), - subtitle: translate('aiFeaturesPromoModal.subtitle'), - description: translate('aiFeaturesPromoModal.spendAnalysis.description'), - confirmText: translate('common.next'), - }, - { - animation: LottieAnimations.ExpenseAssistant, - title: translate('aiFeaturesPromoModal.expenseAssistant.title'), - subtitle: translate('aiFeaturesPromoModal.subtitle'), - description: translate('aiFeaturesPromoModal.expenseAssistant.description'), - confirmText: canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText'), - }, - ...(canUseCustomAgent - ? [ - { - animation: LottieAnimations.CustomAgents, - title: customAgentPromoTitle, - subtitle: translate('aiFeaturesPromoModal.subtitle'), - description: translate('aiFeaturesPromoModal.customAgents.description'), - confirmText: translate('aiFeaturesPromoModal.confirmText'), - }, - ] - : []), - ]; - const dismissNVP = (isDismissedUsingCloseButton: boolean) => { dismissProductTraining(CONST.AI_FEATURES_PROMO_MODAL, isDismissedUsingCloseButton); }; @@ -80,26 +50,72 @@ function AIFeaturesPromoModal() { dismissNVP(true); }; + const illustrationAspectRatio = LottieAnimations.SpendAnalysis.w / LottieAnimations.SpendAnalysis.h; + const subtitle = translate('aiFeaturesPromoModal.subtitle'); + return ( - + > + + + + {subtitle} + {translate('aiFeaturesPromoModal.spendAnalysis.title')} + {translate('aiFeaturesPromoModal.spendAnalysis.description')} + + + {translate('common.next')} + + + + + + + {subtitle} + {translate('aiFeaturesPromoModal.expenseAssistant.title')} + {translate('aiFeaturesPromoModal.expenseAssistant.description')} + + + {canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText')} + + + + {canUseCustomAgent && ( + + + + {subtitle} + {customAgentPromoTitle} + {translate('aiFeaturesPromoModal.customAgents.description')} + + + {translate('aiFeaturesPromoModal.confirmText')} + + + + )} + ); } From 5a906840e11a5d5119e58ee9c806d690c224e89a Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:02:33 +0700 Subject: [PATCH 089/466] Use the new feature training composition in feature training modal --- src/components/FeatureTrainingModal.tsx | 46 ++++++++++++++++++++----- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx index 3349c186f689..d6180292f84e 100644 --- a/src/components/FeatureTrainingModal.tsx +++ b/src/components/FeatureTrainingModal.tsx @@ -10,23 +10,48 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {ReactNode} from 'react'; import type {ViewStyle} from 'react-native'; import React, {useEffect, useRef, useState} from 'react'; -import type {FeatureTrainingContentProps} from './FeatureTrainingContent'; - -import FeatureTrainingContent from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import Modal from './Modal'; const MODAL_PADDING = variables.spacing2; -type FeatureTrainingModalProps = FeatureTrainingContentProps & { - /** Styles for the modal inner container */ +type FeatureTrainingModalProps = { + /** Called when the user confirms the tutorial */ + onConfirm?: (willShowAgain: boolean) => void; + + /** Called when the modal closes */ + onClose?: () => void; + + /** Whether the modal should close after confirm */ + shouldCloseOnConfirm?: boolean; + + /** Whether the modal content should render inside a ScrollView */ + shouldUseScrollView?: boolean; + + /** Modal content width */ + width?: number; + + /** Style for the modal inner container */ modalInnerContainerStyle?: ViewStyle; + + /** Composed feature training content */ + children?: ReactNode; }; -function FeatureTrainingModal({modalInnerContainerStyle, onConfirm, onClose, shouldUseScrollView: shouldUseScrollViewProp = false, ...contentProps}: FeatureTrainingModalProps) { +function FeatureTrainingModal({ + modalInnerContainerStyle, + onConfirm, + onClose, + shouldCloseOnConfirm = true, + shouldUseScrollView: shouldUseScrollViewProp = false, + width, + children, +}: FeatureTrainingModalProps) { const styles = useThemeStyles(); const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); const {windowWidth, windowHeight} = useWindowDimensions(); @@ -90,12 +115,15 @@ function FeatureTrainingModal({modalInnerContainerStyle, onConfirm, onClose, sho shouldDisableBottomSafeAreaPadding={shouldUseScrollView} shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={!shouldUseScrollView} > - + width={width} + > + {children} + ); } From d025b951080cb2200a26374da41989e40dc4c5f0 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:03:53 +0700 Subject: [PATCH 090/466] Update FeatureTrainingModal usages --- .../HoldOrRejectEducationalModal.tsx | 24 +++++++++++-------- .../HoldSubmitterEducationalModal.tsx | 24 ++++++++++++------- .../components/MultiScanEducationalModal.tsx | 24 ++++++++++++------- 3 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/components/HoldOrRejectEducationalModal.tsx b/src/components/HoldOrRejectEducationalModal.tsx index 78e8918dd125..deb7c331c76a 100644 --- a/src/components/HoldOrRejectEducationalModal.tsx +++ b/src/components/HoldOrRejectEducationalModal.tsx @@ -12,6 +12,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; import React from 'react'; import {View} from 'react-native'; +import FeatureTraining from './FeatureTraining'; import FeatureTrainingModal from './FeatureTrainingModal'; import Icon from './Icon'; import Text from './Text'; @@ -60,22 +61,22 @@ function HoldOrRejectEducationalModal({onClose, onConfirm}: HoldOrRejectEducatio return ( - <> + + + {translate('iou.reject.educationalTitle')} + {translate('iou.reject.educationalText')} {menuSections.map((section) => ( ))} - + + {translate('common.buttonConfirm')} + + ); } diff --git a/src/components/HoldSubmitterEducationalModal.tsx b/src/components/HoldSubmitterEducationalModal.tsx index 5e80d6cc20d6..a6041492242f 100644 --- a/src/components/HoldSubmitterEducationalModal.tsx +++ b/src/components/HoldSubmitterEducationalModal.tsx @@ -9,6 +9,7 @@ import CONST from '@src/CONST'; import React from 'react'; +import FeatureTraining from './FeatureTraining'; import FeatureTrainingModal from './FeatureTrainingModal'; import HoldMenuSectionList from './HoldMenuSectionList'; @@ -29,22 +30,27 @@ function HoldSubmitterEducationalModal({onClose, onConfirm}: HoldSubmitterEducat return ( - + + + {translate('iou.holdEducationalTitle')} + {translate('iou.whatIsHoldExplain')} + + + {translate('common.buttonConfirm')} + + ); } diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx index 8672b8bee7de..d45db7b65a7d 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx @@ -1,3 +1,4 @@ +import FeatureTraining from '@components/FeatureTraining'; import FeatureTrainingModal from '@components/FeatureTrainingModal'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; @@ -25,17 +26,22 @@ function MultiScanEducationalModal() { return ( + > + + + {translate('iou.scanMultipleReceipts')} + {translate('iou.scanMultipleReceiptsDescription')} + + {translate('common.buttonConfirm')} + + + ); } From 40eb832eaf75dc6ffddf38c9770101b79212f011 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:04:33 +0700 Subject: [PATCH 091/466] Update FeatureTraining composition usages --- src/components/AutoSubmitModal.tsx | 73 +++++++++-------- src/components/MigratedUserWelcomeModal.tsx | 86 +++++++++++---------- src/components/SubmitPlanWelcomeModal.tsx | 65 ++++++++-------- src/pages/ChangePolicyEducationalModal.tsx | 37 +++++---- src/pages/TrackTrainingPage.tsx | 24 +++--- 5 files changed, 154 insertions(+), 131 deletions(-) diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx index b9a7a8e7a01a..c18d37a79351 100644 --- a/src/components/AutoSubmitModal.tsx +++ b/src/components/AutoSubmitModal.tsx @@ -21,7 +21,7 @@ import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import CenteredModalLayout from './CenteredModalLayout'; -import FeatureTrainingContent from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import Icon from './Icon'; import Text from './Text'; @@ -57,7 +57,6 @@ function AutoSubmitModal() { const shouldDismiss = !willShowAgainRef.current; willShowAgainRef.current = null; - // Defer the Onyx write until after the close transition finishes. This prevents potential checkbox flicker. TransitionTracker.runAfterTransitions({ callback: () => dismissASAPSubmitExplanation(shouldDismiss), waitForUpcomingTransition: true, @@ -72,49 +71,55 @@ function AutoSubmitModal() { willShowAgainRef.current = willShowAgain; }; + const shouldShowDismissOption = dismissedASAPSubmitExplanation === false; + return ( - - {menuSections.map((section) => ( - - - - {translate(section.titleTranslationKey as TranslationPaths)} - {translate(section.descriptionTranslationKey as TranslationPaths)} + + + {translate('autoSubmitModal.title')} + {translate('autoSubmitModal.description')} + {menuSections.map((section) => ( + + + + {translate(section.titleTranslationKey as TranslationPaths)} + {translate(section.descriptionTranslationKey as TranslationPaths)} + - - ))} - + ))} + {shouldShowDismissOption && } + + {translate('common.buttonConfirm')} + + + ); } diff --git a/src/components/MigratedUserWelcomeModal.tsx b/src/components/MigratedUserWelcomeModal.tsx index f00c73334c4d..40bdba7710c9 100644 --- a/src/components/MigratedUserWelcomeModal.tsx +++ b/src/components/MigratedUserWelcomeModal.tsx @@ -25,7 +25,7 @@ import {View} from 'react-native'; import type {FeatureListItem} from './FeatureList'; import CenteredModalLayout from './CenteredModalLayout'; -import FeatureTrainingContent from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import Icon from './Icon'; import LottieAnimations from './LottieAnimations'; import RenderHTML from './RenderHTML'; @@ -71,14 +71,6 @@ function MigratedUserWelcomeModal() { }); }; - const illustrationProps = isReduceMotionEnabled - ? {image: illustrations.PlanetWithMobileApp} - : { - videoURL: '', - animation: LottieAnimations.WorkspacePlanet, - animationStyle: [styles.emptyWorkspaceIllustrationStyle], - }; - const onHelp = () => { Log.info('[MigratedUserWelcomeModal] onHelp called, opening help URL based on admin status and device type'); const adminUrl = shouldUseNarrowLayout ? CONST.STORYLANE.ADMIN_MIGRATED_MOBILE : CONST.STORYLANE.ADMIN_MIGRATED; @@ -93,41 +85,57 @@ function MigratedUserWelcomeModal() { onBackdropPress={handleClose} contentStyle={[styles.pt0, styles.pb0]} > - - + ) : ( + + )} + - {ExpensifyFeatures.map(({translationKey, icon}) => ( - - - - ${convertToLTR(translate(translationKey))}`} /> + {translate('migratedUserWelcomeModal.title')} + {translate('migratedUserWelcomeModal.subtitle')} + + {ExpensifyFeatures.map(({translationKey, icon}) => ( + + + + ${convertToLTR(translate(translationKey))}`} /> + - - ))} - - + ))} + + {translate('migratedUserWelcomeModal.helpText')} + + {translate('migratedUserWelcomeModal.confirmText')} + + + ); } diff --git a/src/components/SubmitPlanWelcomeModal.tsx b/src/components/SubmitPlanWelcomeModal.tsx index 2a9c62237503..637e1b7f4b11 100644 --- a/src/components/SubmitPlanWelcomeModal.tsx +++ b/src/components/SubmitPlanWelcomeModal.tsx @@ -20,7 +20,7 @@ import React from 'react'; import {View} from 'react-native'; import CenteredModalLayout from './CenteredModalLayout'; -import FeatureTrainingContent from './FeatureTrainingContent'; +import FeatureTraining from './FeatureTraining'; import Text from './Text'; const FEATURE_TRANSLATION_KEYS: TranslationPaths[] = [ @@ -39,8 +39,6 @@ function SubmitPlanWelcomeModal() { const {firstName, lastName} = useCurrentUserPersonalDetails(); const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); - // Whenever this modal is removed from the navigation stack (confirmed, dismissed, or closed), - // persist that the user has seen it so it never triggers again. useBeforeRemove(() => { setSubmitMigrationModalShown(); }); @@ -48,9 +46,6 @@ function SubmitPlanWelcomeModal() { const handleClose = () => Navigation.goBack(); const handleConfirm = () => { - // The user has already completed onboarding, so we skip CompleteGuidedSetup and just create the - // Submit workspace. autoCreateSubmitWorkspace then dismisses this modal and navigates to Categories - // with #admins in the RHP, which triggers the useBeforeRemove persistence above. autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); }; @@ -58,40 +53,44 @@ function SubmitPlanWelcomeModal() { - - - {FEATURE_TRANSLATION_KEYS.map((translationKey) => ( - - {'\u2022 '} - {translate(translationKey)} - - ))} - - + + + {translate('submitPlanWelcomeModal.title')} + {translate('submitPlanWelcomeModal.description')} + + {FEATURE_TRANSLATION_KEYS.map((translationKey) => ( + + {'• '} + {translate(translationKey)} + + ))} + + {translate('submitPlanWelcomeModal.dismissText')} + + {translate('submitPlanWelcomeModal.confirmText')} + + + ); } diff --git a/src/pages/ChangePolicyEducationalModal.tsx b/src/pages/ChangePolicyEducationalModal.tsx index 58b8ab9f7b63..988e29355120 100644 --- a/src/pages/ChangePolicyEducationalModal.tsx +++ b/src/pages/ChangePolicyEducationalModal.tsx @@ -1,6 +1,6 @@ import CenteredModalLayout from '@components/CenteredModalLayout'; import ChangeWorkspaceMenuSectionList from '@components/ChangeWorkspaceMenuSectionList'; -import FeatureTrainingContent from '@components/FeatureTrainingContent'; +import FeatureTraining from '@components/FeatureTraining'; import useBeforeRemove from '@hooks/useBeforeRemove'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; @@ -40,24 +40,29 @@ function ChangePolicyEducationalModal() { width={variables.changePolicyEducationModalWidth} contentStyle={[styles.pt0, styles.pb0]} > - - - + + + {translate('iou.changePolicyEducational.title')} + {translate('iou.changePolicyEducational.description')} + + + {translate('common.buttonConfirm')} + + + ); } diff --git a/src/pages/TrackTrainingPage.tsx b/src/pages/TrackTrainingPage.tsx index 5a0c4ddacf77..bb86b554b87c 100644 --- a/src/pages/TrackTrainingPage.tsx +++ b/src/pages/TrackTrainingPage.tsx @@ -1,5 +1,5 @@ import CenteredModalLayout from '@components/CenteredModalLayout'; -import FeatureTrainingContent from '@components/FeatureTrainingContent'; +import FeatureTraining from '@components/FeatureTraining'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useLocalize from '@hooks/useLocalize'; @@ -38,18 +38,24 @@ function TrackTrainingPage() { return ( - { willShowAgainRef.current = willShowAgain; }} - videoURL={CONST.FEATURE_TRAINING[CONST.FEATURE_TRAINING.CONTENT_TYPES.TRACK_EXPENSE]?.VIDEO_URL} - illustrationAspectRatio={VIDEO_ASPECT_RATIO} - /> + > + + + + {translate('common.learnMore')} + + {translate('common.buttonConfirm')} + + + ); } From a75cf5b4df48d7a3225caacbee022d1ee5a62463 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:04:59 +0700 Subject: [PATCH 092/466] Update comment --- src/components/CenteredModalLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CenteredModalLayout.tsx b/src/components/CenteredModalLayout.tsx index 4cf4d5ad1241..2cf9882cf8ec 100644 --- a/src/components/CenteredModalLayout.tsx +++ b/src/components/CenteredModalLayout.tsx @@ -33,7 +33,7 @@ type CenteredModalLayoutProps = { /** * Whether the content wrapper should apply the bottom safe-area inset. Disable it when the children - * already handle the inset themselves (e.g. FeatureTrainingContent with `shouldUseScrollView`, whose + * already handle the inset themselves (e.g. FeatureTraining with `shouldUseScrollView`, whose * ScrollView content padding includes it), otherwise the inset is applied twice and renders as an * empty band under the content on devices with a bottom inset. */ From a3a014c344a221e0c4fad0a218cb79b6b008d97c Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:05:15 +0700 Subject: [PATCH 093/466] Update UI test usage --- .../components/FeatureTrainingModalTest.tsx | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/tests/ui/components/FeatureTrainingModalTest.tsx b/tests/ui/components/FeatureTrainingModalTest.tsx index 4b661ba44455..b79c072c7734 100644 --- a/tests/ui/components/FeatureTrainingModalTest.tsx +++ b/tests/ui/components/FeatureTrainingModalTest.tsx @@ -1,6 +1,7 @@ import {render, screen} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; +import FeatureTraining from '@components/FeatureTraining'; import FeatureTrainingModal from '@components/FeatureTrainingModal'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import FullScreenContextProvider from '@components/VideoPlayerContexts/FullScreenContextProvider'; @@ -59,10 +60,14 @@ describe('FeatureTrainingModal', () => { render( withNavigation( - + + + + + {CONFIRM_TEXT} + + + , ), ); @@ -73,10 +78,14 @@ describe('FeatureTrainingModal', () => { function Component() { const illustrations = useMemoizedLazyIllustrations(['HoldExpense']); return ( - + + + + + {CONFIRM_TEXT} + + + ); } @@ -84,7 +93,18 @@ describe('FeatureTrainingModal', () => { expect(screen.getByTestId(CONST.IMAGE_SVG_TEST_ID)).toBeOnTheScreen(); }); it('renders animation', () => { - render(withNavigation()); + render( + withNavigation( + + + + + {CONFIRM_TEXT} + + + , + ), + ); expect(screen.getByTestId(CONST.LOTTIE_VIEW_TEST_ID)).toBeOnTheScreen(); }); From 7fc4b4a726c6b44acf523790ec402b1674a15706 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:05:32 +0700 Subject: [PATCH 094/466] Remove deprecated components --- .../FeatureTrainingCarousel.tsx | 300 ------------------ .../FeatureTrainingContent.tsx | 136 -------- .../FeatureTrainingContentBody.tsx | 128 -------- .../FeatureTrainingContentBodyText.tsx | 74 ----- .../FeatureTrainingContentIllustration.tsx | 170 ---------- .../FeatureTrainingContent/index.tsx | 8 - .../FeatureTrainingContent/types.ts | 148 --------- 7 files changed, 964 deletions(-) delete mode 100644 src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx delete mode 100644 src/components/FeatureTrainingContent/FeatureTrainingContent.tsx delete mode 100644 src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx delete mode 100644 src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx delete mode 100644 src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx delete mode 100644 src/components/FeatureTrainingContent/index.tsx delete mode 100644 src/components/FeatureTrainingContent/types.ts diff --git a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx b/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx deleted file mode 100644 index e07ed12ea63f..000000000000 --- a/src/components/FeatureTrainingContent/FeatureTrainingCarousel.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import Icon from '@components/Icon'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import ScrollView from '@components/ScrollView'; -import Tooltip from '@components/Tooltip'; - -import useKeyboardState from '@hooks/useKeyboardState'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; - -// eslint-disable-next-line no-restricted-imports -- Type import needed for ref typing; no wrapper available -import type {LayoutChangeEvent, FlatList as RNFlatList, ScrollView as RNScrollView, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native'; - -import React, {useEffect, useRef, useState} from 'react'; -import {FlatList, Platform, View} from 'react-native'; - -import type {FeatureTrainingCarouselProps, FeatureTrainingContentDataProps} from './types'; - -import FeatureTrainingContentBody from './FeatureTrainingContentBody'; -import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText'; -import FeatureTrainingContentIllustration from './FeatureTrainingContentIllustration'; - -const CONTENT_PADDING = variables.spacing2; - -// A page is considered "viewable" — and `currentPage` updates — only once it occupies at least -// 95% of the viewport. The viewability event fires for both user swipes and programmatic -// scrollToIndex once the scroll has practically settled on a new page. -const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThreshold: 95}; -const CAROUSEL_DOT_SIZE = 6; -const PAGINATION_DOTS_BOTTOM_OFFSET = 5; - -const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start', scrollSnapStop: 'always'} as ViewStyle) : {}; - -function FeatureTrainingCarousel({ - pages, - width = variables.featureTrainingModalWidth, - titleStyles, - illustrationAspectRatio, - illustrationInnerContainerStyle, - illustrationOuterContainerStyle, - shouldRenderHTMLDescription = false, - shouldUseScrollView: shouldUseScrollViewProp = false, - helpText = '', - onHelp = () => {}, - helpSentryLabel, - confirmSentryLabel, - contentInnerContainerStyles, - contentOuterContainerStyles, - onConfirm, - onClose, - onPageChange, -}: FeatureTrainingCarouselProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const theme = useTheme(); - const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); - const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - const {windowHeight, windowWidth} = useWindowDimensions(); - const insets = useSafeAreaInsets(); - const {isKeyboardActive} = useKeyboardState(); - const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); - - const [currentPage, setCurrentPage] = useState(0); - const [carouselViewportWidth, setCarouselViewportWidth] = useState(0); - const horizontalListRef = useRef>(null); - const lastReportedPage = useRef(0); - - const scrollViewRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(0); - const [contentHeight, setContentHeight] = useState(0); - const [contentMinHeight, setContentMinHeight] = useState(undefined); - const measuredHeightsRef = useRef>({}); - const handleProbeLayout = (index: number) => (event: LayoutChangeEvent) => { - const measured = event.nativeEvent.layout.height; - if (measuredHeightsRef.current[index] === measured) { - return; - } - measuredHeightsRef.current[index] = measured; - if (Object.keys(measuredHeightsRef.current).length < pages.length) { - return; - } - setContentMinHeight(Math.max(...Object.values(measuredHeightsRef.current))); - }; - - const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; - - // FlatList's `onViewableItemsChanged` must keep a stable identity (it errors otherwise). - // The handler reads the latest `onPageChange` via a ref so the callback identity never changes. - const onPageChangeRef = useRef(onPageChange); - useEffect(() => { - onPageChangeRef.current = onPageChange; - }, [onPageChange]); - - const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]}) => { - const entry = viewableItems.at(0); - if (entry?.index == null || entry.index === lastReportedPage.current) { - return; - } - lastReportedPage.current = entry.index; - setCurrentPage(entry.index); - onPageChangeRef.current?.(entry.index); - }; - - const advanceCarousel = () => { - horizontalListRef.current?.scrollToIndex({index: Math.min(currentPage + 1, pages.length - 1), animated: true}); - }; - - const goBack = () => { - if (currentPage <= 0) { - return; - } - horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true}); - }; - - const confirmPage = () => { - if (currentPage < pages.length - 1) { - advanceCarousel(); - return; - } - onConfirm?.(false); - }; - - useEffect(() => { - if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) { - return; - } - scrollViewRef.current?.scrollToEnd({animated: false}); - }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]); - - const Wrapper = shouldUseScrollView ? ScrollView : View; - - const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {}; - - const carouselPaginationDots = pages.map((_page, index) => ( - - )); - - const currentPageData = pages.at(currentPage); - - return ( - { - const newWidth = e.nativeEvent.layout.width; - if (newWidth === carouselViewportWidth || newWidth <= 0) { - return; - } - setCarouselViewportWidth(newWidth); - if (!shouldUseScrollView) { - return; - } - setContainerHeight(e.nativeEvent.layout.height); - }} - onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined} - // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking - fsClass={CONST.FULLSTORY.CLASS.UNMASK} - > - {carouselViewportWidth > 0 && - contentMinHeight === undefined && ( - // Probe layer is used to measure the tallest page to lock the modal height - // when moving between pages with different content lengths. - - {pages.map((page, index) => ( - - - - ))} - - )} - {carouselViewportWidth > 0 && ( - <> - - `FeatureTrainingContentIllustration-${index}`} - horizontal - pagingEnabled - disableIntervalMomentum - snapToInterval={carouselViewportWidth} - decelerationRate="fast" - bounces={false} - showsHorizontalScrollIndicator={false} - keyboardShouldPersistTaps="handled" - viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG} - onViewableItemsChanged={onViewableItemsChanged} - getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})} - renderItem={({item: page, index}) => ( - - - - )} - /> - - {carouselPaginationDots} - - - 0} - onBack={goBack} - titleStyles={titleStyles} - contentInnerContainerStyles={[contentInnerContainerStyles, contentMinHeight !== undefined && {minHeight: contentMinHeight}]} - contentOuterContainerStyles={contentOuterContainerStyles} - shouldRenderHTMLDescription={shouldRenderHTMLDescription} - /> - - - - - - - - - )} - - ); -} - -export default FeatureTrainingCarousel; diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx deleted file mode 100644 index 792e4e070372..000000000000 --- a/src/components/FeatureTrainingContent/FeatureTrainingContent.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import ScrollView from '@components/ScrollView'; - -import useKeyboardState from '@hooks/useKeyboardState'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; - -// eslint-disable-next-line no-restricted-imports -- type-only import from react-native -import type {LayoutChangeEvent, ScrollView as RNScrollView} from 'react-native'; - -import React, {useEffect, useRef, useState} from 'react'; -import {View} from 'react-native'; - -import type {FeatureTrainingContentProps} from './types'; - -import FeatureTrainingContentBody from './FeatureTrainingContentBody'; -import FeatureTrainingContentIllustration from './FeatureTrainingContentIllustration'; - -function FeatureTrainingContent({ - illustrationInnerContainerStyle, - illustrationOuterContainerStyle, - illustrationAspectRatio: illustrationAspectRatioProp, - width = variables.featureTrainingModalWidth, - title = '', - subtitle = '', - description = '', - titleStyles, - shouldShowDismissModalOption = false, - confirmText = '', - onConfirm, - onClose, - onWillShowAgainChange, - helpText = '', - onHelp, - children, - contentInnerContainerStyles, - contentOuterContainerStyles, - shouldRenderHTMLDescription = false, - shouldCloseOnConfirm = true, - shouldUseScrollView: shouldUseScrollViewProp = false, - helpSentryLabel, - confirmSentryLabel, - ...props -}: FeatureTrainingContentProps) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - const {windowHeight, windowWidth} = useWindowDimensions(); - const [willShowAgain, setWillShowAgain] = useState(true); - const scrollViewRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(0); - const [contentHeight, setContentHeight] = useState(0); - const insets = useSafeAreaInsets(); - const {isKeyboardActive} = useKeyboardState(); - const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); - - const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode; - - const toggleWillShowAgain = () => { - onWillShowAgainChange?.(!willShowAgain); - setWillShowAgain((prev) => !prev); - }; - - const handleConfirm = () => { - onConfirm?.(willShowAgain); - if (shouldCloseOnConfirm) { - onClose?.(); - } - }; - - useEffect(() => { - if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) { - return; - } - scrollViewRef.current?.scrollToEnd({animated: false}); - }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]); - - const Wrapper = shouldUseScrollView ? ScrollView : View; - - const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {}; - - return ( - setContainerHeight(e.nativeEvent.layout.height) : undefined} - onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined} - // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking - fsClass={CONST.FULLSTORY.CLASS.UNMASK} - > - - - {children} - - - ); -} - -export default FeatureTrainingContent; diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx deleted file mode 100644 index 912bc7f3339e..000000000000 --- a/src/components/FeatureTrainingContent/FeatureTrainingContentBody.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import Button from '@components/ButtonComposed'; -import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; - -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import CONST from '@src/CONST'; - -import React from 'react'; -import {View} from 'react-native'; - -import type {FeatureTrainingContentBodyProps as BaseFeatureTrainingContentBodyProps, BaseFeatureTrainingContentProps} from './types'; - -import FeatureTrainingContentBodyText from './FeatureTrainingContentBodyText'; - -type FeatureTrainingModalContentProps = Pick< - BaseFeatureTrainingContentProps, - | 'helpText' - | 'onConfirm' - | 'onHelp' - | 'helpSentryLabel' - | 'confirmSentryLabel' - | 'shouldShowDismissModalOption' - | 'titleStyles' - | 'contentInnerContainerStyles' - | 'contentOuterContainerStyles' - | 'shouldRenderHTMLDescription' - | 'children' -> & - BaseFeatureTrainingContentBodyProps & { - /** Whether the modal should be shown again (drives the dismiss checkbox state) */ - willShowAgain?: boolean; - - /** Callback when the "Don't show me this again" option is toggled */ - toggleWillShowAgain?: () => void; - - /** Whether to render a Back button (carousel mode, non-first pages) */ - shouldShowBackButton?: boolean; - - /** Callback when the Back button is pressed */ - onBack?: () => void; - - /** Whether the confirm button should show a spinner immediately on press */ - shouldShowLoadingImmediatelyOnPress?: boolean; - }; - -function FeatureTrainingContentBody({ - title = '', - subtitle = '', - description = '', - confirmText, - onConfirm, - helpText = '', - onHelp, - helpSentryLabel, - confirmSentryLabel, - shouldShowDismissModalOption = false, - willShowAgain = false, - toggleWillShowAgain, - shouldShowBackButton = false, - onBack, - shouldShowLoadingImmediatelyOnPress, - titleStyles, - contentInnerContainerStyles, - contentOuterContainerStyles, - shouldRenderHTMLDescription = false, - children, -}: FeatureTrainingModalContentProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - return ( - - - {children} - - {shouldShowDismissModalOption && ( - - )} - {!!helpText && ( - - )} - - {shouldShowBackButton && ( - - )} - onConfirm?.(willShowAgain)} - buttonText={confirmText} - enabledWhenOffline - shouldShowLoadingImmediatelyOnPress={shouldShowLoadingImmediatelyOnPress} - sentryLabel={confirmSentryLabel} - containerStyles={styles.flex1} - /> - - - ); -} - -export default FeatureTrainingContentBody; diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx deleted file mode 100644 index 757fd4f0db7f..000000000000 --- a/src/components/FeatureTrainingContent/FeatureTrainingContentBodyText.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import RenderHTML from '@components/RenderHTML'; -import Text from '@components/Text'; - -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import type {LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; - -import React from 'react'; -import {View} from 'react-native'; - -type FeatureTrainingModalTextContentProps = { - /** Title for the modal */ - title?: string | React.ReactNode; - - /** Subtitle for the modal */ - subtitle?: string; - - /** Describe what is showing */ - description?: string; - - /** Style for the title */ - titleStyles?: StyleProp; - - /** Styles applied to the inner text container */ - contentInnerContainerStyles?: StyleProp; - - /** Whether description is HTML markup */ - shouldRenderHTMLDescription?: boolean; - - /** Children rendered below the description (single-page mode only) */ - children?: React.ReactNode; - - /** onLayout hook — used by the carousel probe to measure the tallest page */ - onLayout?: (event: LayoutChangeEvent) => void; -}; - -function FeatureTrainingContentBodyText({ - title = '', - subtitle = '', - description = '', - titleStyles, - contentInnerContainerStyles, - shouldRenderHTMLDescription = false, - children, - onLayout, -}: FeatureTrainingModalTextContentProps) { - const styles = useThemeStyles(); - const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); - - if (!title || !description) { - return null; - } - - return ( - - {!!subtitle && {subtitle}} - {typeof title === 'string' ? {title} : title} - {shouldRenderHTMLDescription ? ( - - - - ) : ( - {description} - )} - {children} - - ); -} - -export default FeatureTrainingContentBodyText; diff --git a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx b/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx deleted file mode 100644 index 0716dca3987f..000000000000 --- a/src/components/FeatureTrainingContent/FeatureTrainingContentIllustration.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import ImageSVG from '@components/ImageSVG'; -import Lottie from '@components/Lottie'; -import LottieAnimations from '@components/LottieAnimations'; -import VideoPlayer from '@components/VideoPlayer'; - -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; -import useNetwork from '@hooks/useNetwork'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -import Accessibility from '@libs/Accessibility'; -import {isMobile} from '@libs/Browser'; -import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; -import {getIsOffline} from '@libs/NetworkState'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; - -import type {SourceLoadEventPayload} from 'expo-video'; -import type LottieView from 'lottie-react-native'; - -import React, {useEffect, useRef, useState} from 'react'; -import {View} from 'react-native'; -import {GestureHandlerRootView} from 'react-native-gesture-handler'; - -import type {FeatureTrainingContentIllustrationProps as BaseFeatureTrainingContentIllustrationProps, BaseFeatureTrainingContentProps} from './types'; - -// Aspect ratio and height of the video. -// Useful before video loads to reserve space. -const VIDEO_ASPECT_RATIO = 1280 / 960; - -const CONTENT_PADDING = variables.spacing2; - -const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7; - -type VideoStatus = 'video' | 'animation'; - -type FeatureTrainingContentIllustrationProps = Pick & - BaseFeatureTrainingContentIllustrationProps & { - /** Whether this illustration belongs to the currently-visible carousel page */ - isFocused?: boolean; - - /** Whether this illustration is part of a carousel */ - isCarousel?: boolean; - }; - -/** - * Once the device has been online, lock to 'video' permanently. - * While it has never been online, show 'animation' as a fallback. - */ -function useVideoStatus(): VideoStatus { - const [isLockedToVideo, setIsLockedToVideo] = useState(() => !getIsOffline()); - const {isOffline} = useNetwork({ - onReconnect: () => setIsLockedToVideo(true), - }); - - return isLockedToVideo || !isOffline ? 'video' : 'animation'; -} - -function FeatureTrainingContentIllustration({ - animation, - animationStyle, - videoURL, - image, - contentFitImage, - imageWidth, - imageHeight, - illustrationAspectRatio: illustrationAspectRatioProp, - illustrationInnerContainerStyle, - illustrationOuterContainerStyle, - isFocused = true, - isCarousel = false, -}: FeatureTrainingContentIllustrationProps) { - const styles = useThemeStyles(); - const isReduceMotionEnabled = Accessibility.useReducedMotion(); - const illustrations = useMemoizedLazyIllustrations(['Hands']); - const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); - const videoStatus = useVideoStatus(); - const {windowHeight, windowWidth} = useWindowDimensions(); - const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO); - const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight); - - const animationRef = useRef(null); - useEffect(() => { - if (isMobile() || !isCarousel || !animationRef.current || isReduceMotionEnabled) { - return; - } - if (isFocused) { - animationRef.current.play(); - } else { - animationRef.current.pause(); - } - }, [isFocused, isCarousel, isReduceMotionEnabled]); - - const setAspectRatio = (event: SourceLoadEventPayload) => { - const track = event.availableVideoTracks.at(0); - if (!track) { - return; - } - setIllustrationAspectRatio(track.size.width / track.size.height); - }; - - const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO; - - return ( - - - {!!image && ( - - )} - {!!videoURL && videoStatus === 'video' && ( - - - - )} - {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && ( - - {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? ( - - ) : ( - - )} - - )} - - - ); -} - -export default FeatureTrainingContentIllustration; diff --git a/src/components/FeatureTrainingContent/index.tsx b/src/components/FeatureTrainingContent/index.tsx deleted file mode 100644 index ce07f8f6eb2c..000000000000 --- a/src/components/FeatureTrainingContent/index.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {FeatureTrainingContentDataProps, FeatureTrainingContentProps} from './types'; - -import FeatureTrainingCarousel from './FeatureTrainingCarousel'; -import FeatureTrainingContent from './FeatureTrainingContent'; - -export default FeatureTrainingContent; -export {FeatureTrainingCarousel}; -export type {FeatureTrainingContentProps, FeatureTrainingContentDataProps}; diff --git a/src/components/FeatureTrainingContent/types.ts b/src/components/FeatureTrainingContent/types.ts deleted file mode 100644 index 4ec58b1fb41b..000000000000 --- a/src/components/FeatureTrainingContent/types.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type ImageSVGProps from '@components/ImageSVG/types'; -import type DotLottieAnimation from '@components/LottieAnimations/types'; - -import type IconAsset from '@src/types/utils/IconAsset'; - -import type {ImageContentFit} from 'expo-image'; -import type {ReactNode} from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import type {MergeExclusive} from 'type-fest'; - -type BaseFeatureTrainingContentProps = { - /** The aspect ratio to preserve for the icon, video or animation */ - illustrationAspectRatio?: number; - - /** Style for the inner container of the animation */ - illustrationInnerContainerStyle?: StyleProp; - - /** Style for the outer container of the animation */ - illustrationOuterContainerStyle?: StyleProp; - - /** Style for the title */ - titleStyles?: StyleProp; - - /** Whether to show `Don't show me this again` option */ - shouldShowDismissModalOption?: boolean; - - /** A callback to call when user confirms the tutorial */ - onConfirm?: (willShowAgain: boolean) => void; - - /** A callback to call when modal closes */ - onClose?: () => void; - - /** Called whenever the "don't show again" checkbox value changes */ - onWillShowAgainChange?: (willShowAgain: boolean) => void; - - /** Text to show on secondary button */ - helpText?: string; - - /** Link to navigate to when user wants to learn more */ - onHelp?: () => void; - - /** Styles for the content container */ - contentInnerContainerStyles?: StyleProp; - - /** Styles for the content outer container */ - contentOuterContainerStyles?: StyleProp; - - /** Styles for the modal inner container */ - modalInnerContainerStyle?: ViewStyle; - - /** Children to show below title and description and above buttons (single-page mode only) */ - children?: ReactNode; - - /** Modal width */ - width?: number; - - /** Whether to disable the modal */ - isModalDisabled?: boolean; - - /** Whether the modal description is written in HTML */ - shouldRenderHTMLDescription?: boolean; - - /** Whether the modal will be closed on confirm */ - shouldCloseOnConfirm?: boolean; - - /** Whether the modal should avoid the keyboard */ - avoidKeyboard?: boolean; - - /** Whether the modal content is scrollable */ - shouldUseScrollView?: boolean; - - /** Whether to navigate back when closing the modal */ - shouldGoBack?: boolean; - - /** Whether to call onHelp when modal is hidden completely */ - shouldCallOnHelpWhenModalHidden?: boolean; - - /** Sentry label for the help/skip button */ - helpSentryLabel?: string; - - /** Sentry label for the confirm/submit button */ - confirmSentryLabel?: string; -}; - -type FeatureTrainingContentBodyProps = { - /** Title for the modal */ - title?: string | React.ReactNode; - - /** Subtitle for the modal */ - subtitle?: string; - - /** Describe what is showing */ - description?: string; - - /** Text to show on primary button */ - confirmText: string; -}; - -type FeatureTrainingContentIllustrationVideoProps = { - /** Animation to show when video is unavailable. Useful when app is offline */ - animation?: DotLottieAnimation; - - /** Additional styles for the animation */ - animationStyle?: StyleProp; - - /** URL for the video */ - videoURL?: string; -}; - -type FeatureTrainingContentIllustrationSVGProps = { - /** Expensicon for the page */ - image: IconAsset; - - /** Determines how the image should be resized to fit its container */ - contentFitImage?: ImageContentFit; - - /** The width of the image */ - imageWidth?: ImageSVGProps['width']; - - /** The height of the image */ - imageHeight?: ImageSVGProps['height']; -}; - -// Illustration requires either an icon or a video/animation, but not both. -type FeatureTrainingContentIllustrationProps = MergeExclusive; - -type FeatureTrainingContentDataProps = FeatureTrainingContentIllustrationProps & FeatureTrainingContentBodyProps; - -// Single-page mode -type FeatureTrainingContentProps = BaseFeatureTrainingContentProps & FeatureTrainingContentDataProps; - -// Carousel mode -type FeatureTrainingCarouselProps = BaseFeatureTrainingContentProps & { - /** Renders a horizontal paging carousel */ - pages: FeatureTrainingContentDataProps[]; - - /** Called when the user swipes to a different page. */ - onPageChange?: (index: number) => void; -}; - -export type { - BaseFeatureTrainingContentProps, - FeatureTrainingContentBodyProps, - FeatureTrainingContentIllustrationProps, - FeatureTrainingContentDataProps, - FeatureTrainingContentProps, - FeatureTrainingCarouselProps, -}; From 7e60725ae78ef5e1ff175e78cf07052fe1de9448 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 28 Jul 2026 16:27:32 +0200 Subject: [PATCH 095/466] better message --- src/pages/inbox/report/actionContents/ActionContentRouter.tsx | 4 ++++ src/types/onyx/OriginalMessage.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index b1ee345f63d6..1427efa00067 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -64,6 +64,7 @@ import ChatTransactionPreview from './ChatTransactionPreview'; import ConciergeAutoMatchVendorContent from './ConciergeAutoMatchVendorContent'; import ConfirmWhisperContent from './ConfirmWhisperContent'; import FraudAlertContent from './FraudAlertContent'; +import HomeAddressRequiredContent from './HomeAddressRequiredContent'; import IntegrationSyncFailedMessage from './IntegrationSyncFailedMessage'; import JoinRequestContent from './JoinRequestContent'; import MemberChangeContent from './MemberChangeContent'; @@ -494,6 +495,9 @@ function ActionContentRouter({ ); } + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.HOME_ADDRESS_REQUIRED)) { + return ; + } if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED)) { return ( diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 178e5a0b4403..e1831c0dd743 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1632,6 +1632,9 @@ type IssueNewCardOriginalMessage = OriginalMessage< type OriginalMessageHomeAddressRequired = { /** ID of the policy whose commuter-exclusion change triggered the prompt */ policyID: string; + + /** Set once the member saves a home address, marking the prompt as satisfied */ + resolution?: string | null; }; /** From 5508e7bcdc615d4952e289b47165f80b29820f08 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 28 Jul 2026 22:27:11 +0700 Subject: [PATCH 096/466] update bot comments --- .../InternationalDepositAccountContent.tsx | 4 +-- .../InternationalBankAccountDetailsStep.tsx | 21 +++++------ .../InternationalBankAccountDetails.tsx | 25 +++++-------- .../InternationalDepositAccount/utils.ts | 36 +++++++++++++++++-- 4 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx index c5fb1f2628dd..38ea84c48ba5 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -98,8 +98,6 @@ function InternationalDepositAccountContent({ const initialAccountHolderDetailsValues = getInitialPersonalDetailsValues(privatePersonalDetails); - const startFrom = getInitialSubstep(values, fieldsMap); - const skipAccountTypeStep = isEmptyObject(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE]); const skipAccountHolderInformationStep = testValidation(initialAccountHolderDetailsValues, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS]); @@ -109,6 +107,8 @@ function InternationalDepositAccountContent({ const isAccountNumberIBAN = CONST.BANK_ACCOUNT.REGEX.IBAN.test((values.accountNumber ?? '').trim()); const skipInternationalBankAccountDetailsStep = !shouldCollectInternationalDepositDetails || (isAccountNumberIBAN && (!!values.swiftCode || !!values.swiftBicCode)); + const startFrom = getInitialSubstep(values, fieldsMap, skipInternationalBankAccountDetailsStep); + const skippedPages = getSkippedPages(skipAccountTypeStep, skipAccountHolderInformationStep, skipInternationalBankAccountDetailsStep); const route = useRoute>(); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx index 581aed8ca6f7..cca4d788992b 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx @@ -1,3 +1,6 @@ +/** + * Personal-info substep for collecting international bank account details (IBAN + SWIFT/BIC) when adding a personal bank account. + */ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -13,6 +16,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; +import {getInternationalBankAccountDetailsErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalBankAccountForm'; @@ -31,18 +36,10 @@ function InternationalBankAccountDetailsStep({onNext, isEditing}: InternationalB const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - - if (values.iban && !CONST.BANK_ACCOUNT.REGEX.IBAN.test(values.iban.trim())) { - errors.iban = translate('bankAccount.error.swiftCodeOrIban'); - } - if (values.swiftCode && !CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(values.swiftCode.trim())) { - errors.swiftCode = translate('bankAccount.error.swiftCodeOrIban'); - } - - return errors; - }; + const validate = (values: FormOnyxValues): FormInputErrors => ({ + ...getFieldRequiredErrors(values, STEP_FIELDS, translate), + ...getInternationalBankAccountDetailsErrors(values.iban, values.swiftCode, translate), + }); const handleSubmit = usePersonalBankAccountDetailsFormSubmit({ fieldIds: STEP_FIELDS, diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx index 9b9283833f8d..ef934c81bd28 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx @@ -1,3 +1,6 @@ +/** + * Sub-page for collecting international bank account details (IBAN + SWIFT/BIC) in the Corpay deposit-account flow. + */ import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; @@ -11,11 +14,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; +import {getInternationalBankAccountDetailsErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useCallback} from 'react'; +import React from 'react'; import {View} from 'react-native'; const IBAN = 'iban'; @@ -38,21 +42,10 @@ function InternationalBankAccountDetails({isEditing, onNext, formValues}: Custom const ibanDefaultValue = formValues[IBAN] || (accountNumber && CONST.BANK_ACCOUNT.REGEX.IBAN.test(String(accountNumber).trim()) ? accountNumber : ''); const swiftCodeDefaultValue = formValues[SWIFT_CODE] || formValues.swiftBicCode || ''; - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, STEP_FIELDS, translate); - - if (values[IBAN] && !CONST.BANK_ACCOUNT.REGEX.IBAN.test(String(values[IBAN]).trim())) { - errors[IBAN] = translate('bankAccount.error.swiftCodeOrIban'); - } - if (values[SWIFT_CODE] && !CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(String(values[SWIFT_CODE]).trim())) { - errors[SWIFT_CODE] = translate('bankAccount.error.swiftCodeOrIban'); - } - - return errors; - }, - [translate], - ); + const validate = (values: FormOnyxValues): FormInputErrors => ({ + ...getFieldRequiredErrors(values, STEP_FIELDS, translate), + ...getInternationalBankAccountDetailsErrors(values[IBAN], values[SWIFT_CODE], translate), + }); return ( , CorpayFieldsMap>) { +function hasValidInternationalBankAccountDetails(values: InternationalBankAccountForm) { + return !!values.iban && CONST.BANK_ACCOUNT.REGEX.IBAN.test(values.iban.trim()) && !!values.swiftCode && CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(values.swiftCode.trim()); +} + +function getInitialSubstep( + values: InternationalBankAccountForm, + fieldsMap: Record, CorpayFieldsMap>, + skipInternationalBankAccountDetailsStep: boolean, +) { if (values.bankCountry === '' || isEmptyObject(fieldsMap)) { return CONST.CORPAY_FIELDS.INDEXES.MAPPING.COUNTRY_SELECTOR; } if (values.bankCurrency === '' || !testValidation(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS])) { return CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_ACCOUNT_DETAILS; } + // When the international bank account details step is required but not yet satisfied, start there so a resumed or + // deep-linked flow can't land past it and submit without the IBAN/SWIFT. + if (!skipInternationalBankAccountDetailsStep && !hasValidInternationalBankAccountDetails(values)) { + return CONST.CORPAY_FIELDS.INDEXES.MAPPING.INTERNATIONAL_BANK_ACCOUNT_DETAILS; + } if (!testValidation(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE])) { return CONST.CORPAY_FIELDS.INDEXES.MAPPING.ACCOUNT_TYPE; } @@ -150,4 +163,23 @@ function getValidationErrors(values: FormOnyxValues> { + const errors: Partial> = {}; + if (iban && !CONST.BANK_ACCOUNT.REGEX.IBAN.test(iban.trim())) { + errors.iban = translate('bankAccount.error.swiftCodeOrIban'); + } + if (swiftCode && !CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(swiftCode.trim())) { + errors.swiftCode = translate('bankAccount.error.swiftCodeOrIban'); + } + return errors; +} + +export {getFieldsMap, getSubstepValues, getInitialPersonalDetailsValues, getInitialSubstep, testValidation, getValidationErrors, getInternationalBankAccountDetailsErrors}; From 2cfed71d5121514261ebc7e609662247d0e90f37 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 30 Jul 2026 00:01:34 +0700 Subject: [PATCH 097/466] clean code --- ...houldCollectInternationalDepositDetails.ts | 23 +++---- src/languages/de.ts | 5 +- src/languages/en.ts | 5 +- src/languages/es.ts | 5 +- src/languages/fr.ts | 5 +- src/languages/it.ts | 5 +- src/languages/ja.ts | 5 +- src/languages/nl.ts | 5 +- src/languages/pl.ts | 5 +- src/languages/pt-BR.ts | 5 +- src/languages/zh-hans.ts | 5 +- .../InternationalBankAccountFields.tsx | 69 +++++++++++++++++++ .../InternationalDepositAccountContent.tsx | 6 +- .../PersonalInfo/PersonalInfo.tsx | 8 ++- .../InternationalBankAccountDetailsStep.tsx | 31 +++------ .../InternationalDepositAccount/index.tsx | 1 + .../subPages/Confirmation.tsx | 16 +++-- .../InternationalBankAccountDetails.tsx | 42 ++++------- .../InternationalDepositAccount/utils.ts | 42 +++++++++-- 19 files changed, 196 insertions(+), 92 deletions(-) create mode 100644 src/pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields.tsx diff --git a/src/hooks/useShouldCollectInternationalDepositDetails.ts b/src/hooks/useShouldCollectInternationalDepositDetails.ts index c29781f1e83a..2b850db8ae8e 100644 --- a/src/hooks/useShouldCollectInternationalDepositDetails.ts +++ b/src/hooks/useShouldCollectInternationalDepositDetails.ts @@ -3,8 +3,6 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; -import {useCallback} from 'react'; - import useOnyx from './useOnyx'; /** @@ -13,19 +11,16 @@ import useOnyx from './useOnyx'; * the bank account is being added in. */ function useShouldCollectInternationalDepositDetails(bankCountry: string): boolean { - const selector = useCallback( - (policies: OnyxCollection) => - Object.values(policies ?? {}).some((policy) => { - if (!policy?.reimbursement?.enabled) { - return false; - } - const countries = Object.keys(policy.reimbursement.countries ?? {}); - return countries.length > 0 && !countries.includes(bankCountry); - }), - [bankCountry], - ); + const selector = (policies: OnyxCollection) => + Object.values(policies ?? {}).some((policy) => { + if (!policy?.reimbursement?.enabled) { + return false; + } + const countries = Object.keys(policy.reimbursement.countries ?? {}); + return countries.length > 0 && !countries.includes(bankCountry); + }); const [shouldCollectInternationalDepositDetails] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector}); - return shouldCollectInternationalDepositDetails ?? false; + return !!shouldCollectInternationalDepositDetails; } export default useShouldCollectInternationalDepositDetails; diff --git a/src/languages/de.ts b/src/languages/de.ts index 61acc60f9acb..9f2e8be0f071 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3693,6 +3693,8 @@ ${amount} für ${merchant} – ${date}`, accountNumber: 'Kontonummer', routingNumber: 'Routing-Nummer', internationalBankAccountDetails: 'Internationale Bankkontodaten', + internationalBankAccountDetailsTitle: 'Wie lauten deine internationalen Kontodaten?', + internationalBankAccountDetailsSubtitle: 'Einer deiner Arbeitsbereiche benötigt internationale Kontodaten, um Erstattungen zu verarbeiten', iban: 'IBAN', swiftBicCode: 'SWIFT/BIC-Code', chooseAnAccountBelow: 'Wähle unten ein Konto aus', @@ -3745,7 +3747,8 @@ ${amount} für ${merchant} – ${date}`, restrictedBusiness: 'Bitte bestätigen Sie, dass das Unternehmen nicht auf der Liste der eingeschränkten Unternehmen steht.', routingNumber: 'Bitte geben Sie eine gültige Bankleitzahl ein', accountNumber: 'Bitte eine gültige Kontonummer eingeben', - swiftCodeOrIban: 'Bitte gib einen gültigen Swift-Code/IBAN ein', + iban: 'Bitte gib eine gültige IBAN ein', + swiftCode: 'Bitte gib einen gültigen SWIFT/BIC-Code ein', routingAndAccountNumberCannotBeSame: 'Bankleitzahl und Kontonummer dürfen nicht identisch sein', companyType: 'Bitte wähle einen gültigen Unternehmenstyp aus', tooManyAttempts: diff --git a/src/languages/en.ts b/src/languages/en.ts index b72370f739e5..e4d5cc8e6bf2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3794,6 +3794,8 @@ const translations = { accountNumber: 'Account number', routingNumber: 'Routing number', internationalBankAccountDetails: 'International bank account details', + internationalBankAccountDetailsTitle: 'What are your international account details?', + internationalBankAccountDetailsSubtitle: 'One of your workspaces needs international account details to process reimbursements', iban: 'IBAN', swiftBicCode: 'SWIFT/BIC code', chooseAnAccountBelow: 'Choose an account below', @@ -3845,7 +3847,8 @@ const translations = { restrictedBusiness: "Please confirm the business isn't on the list of restricted businesses", routingNumber: 'Please enter a valid routing number', accountNumber: 'Please enter a valid account number', - swiftCodeOrIban: 'Please enter a valid Swift code/IBAN', + iban: 'Please enter a valid IBAN', + swiftCode: 'Please enter a valid SWIFT/BIC code', routingAndAccountNumberCannotBeSame: "Routing and account numbers can't match", companyType: 'Please select a valid company type', tooManyAttempts: 'Due to a high number of login attempts, this option has been disabled for 24 hours. Please try again later or enter details manually instead.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 9f7f82bfaca9..900c4ee8d5f2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3574,6 +3574,8 @@ ${amount} para ${merchant} - ${date}`, accountNumber: 'Número de cuenta', routingNumber: 'Número de ruta', internationalBankAccountDetails: 'Datos de la cuenta bancaria internacional', + internationalBankAccountDetailsTitle: '¿Cuáles son los datos de tu cuenta internacional?', + internationalBankAccountDetailsSubtitle: 'Uno de tus espacios de trabajo necesita datos de cuenta internacional para procesar reembolsos', iban: 'IBAN', swiftBicCode: 'Código SWIFT/BIC', chooseAnAccountBelow: 'Elige una cuenta a continuación', @@ -3625,7 +3627,8 @@ ${amount} para ${merchant} - ${date}`, restrictedBusiness: 'Por favor, confirma que la empresa no está en la lista de negocios restringidos', routingNumber: 'Por favor, introduce un número de ruta válido', accountNumber: 'Por favor, introduce un número de cuenta válido', - swiftCodeOrIban: 'Por favor, introduce un código Swift/IBAN válido', + iban: 'Por favor, introduce un IBAN válido', + swiftCode: 'Por favor, introduce un código SWIFT/BIC válido', routingAndAccountNumberCannotBeSame: 'Los números de ruta y de cuenta no pueden ser iguales', companyType: 'Por favor, selecciona un tipo de compañía válido', tooManyAttempts: diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7552da4c64f5..afe717a0bead 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3702,6 +3702,8 @@ ${amount} pour ${merchant} - ${date}`, accountNumber: 'Numéro de compte', routingNumber: 'Numéro de routage', internationalBankAccountDetails: 'Coordonnées bancaires internationales', + internationalBankAccountDetailsTitle: 'Quelles sont vos coordonnées bancaires internationales ?', + internationalBankAccountDetailsSubtitle: "L'un de vos espaces de travail a besoin de coordonnées bancaires internationales pour traiter les remboursements", iban: 'IBAN', swiftBicCode: 'Code SWIFT/BIC', chooseAnAccountBelow: 'Choisissez un compte ci-dessous', @@ -3754,7 +3756,8 @@ ${amount} pour ${merchant} - ${date}`, restrictedBusiness: 'Veuillez confirmer que l’entreprise ne figure pas sur la liste des entreprises restreintes', routingNumber: 'Veuillez saisir un numéro d’acheminement valide', accountNumber: 'Veuillez saisir un numéro de compte valide', - swiftCodeOrIban: 'Veuillez saisir un code Swift/IBAN valide', + iban: 'Veuillez saisir un IBAN valide', + swiftCode: 'Veuillez saisir un code SWIFT/BIC valide', routingAndAccountNumberCannotBeSame: 'Le code de routage et le numéro de compte ne peuvent pas être identiques', companyType: 'Veuillez sélectionner un type d’entreprise valide', tooManyAttempts: diff --git a/src/languages/it.ts b/src/languages/it.ts index fbe9369e7d60..45bd5fdc423e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3679,6 +3679,8 @@ ${amount} per ${merchant} - ${date}`, accountNumber: 'Numero di conto', routingNumber: 'Numero di instradamento', internationalBankAccountDetails: 'Dati del conto bancario internazionale', + internationalBankAccountDetailsTitle: 'Quali sono i dati del tuo conto internazionale?', + internationalBankAccountDetailsSubtitle: 'Uno dei tuoi spazi di lavoro richiede i dati del conto internazionale per elaborare i rimborsi', iban: 'IBAN', swiftBicCode: 'Codice SWIFT/BIC', chooseAnAccountBelow: 'Scegli un account qui sotto', @@ -3730,7 +3732,8 @@ ${amount} per ${merchant} - ${date}`, restrictedBusiness: 'Conferma che l’azienda non sia nell’elenco delle attività soggette a restrizioni', routingNumber: 'Inserisci un numero di instradamento valido', accountNumber: 'Inserisci un numero di conto valido', - swiftCodeOrIban: 'Inserisci un codice Swift/IBAN valido', + iban: 'Inserisci un IBAN valido', + swiftCode: 'Inserisci un codice SWIFT/BIC valido', routingAndAccountNumberCannotBeSame: 'I numeri di routing e di conto non possono coincidere', companyType: 'Seleziona un tipo di azienda valido', tooManyAttempts: 'A causa di un numero elevato di tentativi di accesso, questa opzione è stata disabilitata per 24 ore. Riprova più tardi o inserisci invece i dati manualmente.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 44efe3b6d06e..791c3dbabe1d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3650,6 +3650,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' accountNumber: '口座番号', routingNumber: 'ルーティング番号', internationalBankAccountDetails: '海外銀行口座情報', + internationalBankAccountDetailsTitle: 'あなたの海外口座情報を教えてください', + internationalBankAccountDetailsSubtitle: 'あなたのワークスペースの1つが、払い戻しを処理するために海外口座情報を必要としています', iban: 'IBAN', swiftBicCode: 'SWIFT/BICコード', chooseAnAccountBelow: '下のアカウントを選択してください', @@ -3701,7 +3703,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' restrictedBusiness: 'ビジネスが制限対象の事業一覧に含まれていないことを確認してください', routingNumber: '有効なルーティング番号を入力してください', accountNumber: '有効な口座番号を入力してください', - swiftCodeOrIban: '有効なSwiftコード/IBANを入力してください', + iban: '有効なIBANを入力してください', + swiftCode: '有効なSWIFT/BICコードを入力してください', routingAndAccountNumberCannotBeSame: 'ルーティング番号と口座番号を同じにすることはできません', companyType: '有効な会社の種類を選択してください', tooManyAttempts: 'ログイン試行回数が多すぎるため、このオプションは24時間無効になっています。時間をおいてから再度お試しいただくか、代わりに詳細情報を手動で入力してください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 06683ab3ba60..e6d6eb97a1d8 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3679,6 +3679,8 @@ ${amount} voor ${merchant} - ${date}`, accountNumber: 'Rekeningnummer', routingNumber: 'Routingnummer', internationalBankAccountDetails: 'Internationale bankrekeninggegevens', + internationalBankAccountDetailsTitle: 'Wat zijn je internationale rekeninggegevens?', + internationalBankAccountDetailsSubtitle: 'Een van je werkruimtes heeft internationale rekeninggegevens nodig om onkostenvergoedingen te verwerken', iban: 'IBAN', swiftBicCode: 'SWIFT/BIC-code', chooseAnAccountBelow: 'Kies een account hieronder', @@ -3730,7 +3732,8 @@ ${amount} voor ${merchant} - ${date}`, restrictedBusiness: 'Bevestig dat het bedrijf niet op de lijst met beperkte bedrijven staat', routingNumber: 'Voer een geldig routingnummer in', accountNumber: 'Voer een geldig rekeningnummer in', - swiftCodeOrIban: 'Voer een geldige Swift-code/IBAN in', + iban: 'Voer een geldige IBAN in', + swiftCode: 'Voer een geldige SWIFT/BIC-code in', routingAndAccountNumberCannotBeSame: 'Routing- en rekeningnummers mogen niet hetzelfde zijn', companyType: 'Selecteer een geldig bedrijfstype', tooManyAttempts: 'Vanwege een groot aantal inlogpogingen is deze optie voor 24 uur uitgeschakeld. Probeer het later opnieuw of voer de gegevens in plaats daarvan handmatig in.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 793e9134ce60..8168dbde1d71 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3664,6 +3664,8 @@ ${amount} dla ${merchant} - ${date}`, accountNumber: 'Numer konta', routingNumber: 'Numer rozliczeniowy', internationalBankAccountDetails: 'Dane międzynarodowego konta bankowego', + internationalBankAccountDetailsTitle: 'Jakie są dane Twojego konta międzynarodowego?', + internationalBankAccountDetailsSubtitle: 'Jedna z Twoich przestrzeni roboczych potrzebuje danych konta międzynarodowego do przetwarzania zwrotów kosztów', iban: 'IBAN', swiftBicCode: 'Kod SWIFT/BIC', chooseAnAccountBelow: 'Wybierz konto poniżej', @@ -3715,7 +3717,8 @@ ${amount} dla ${merchant} - ${date}`, restrictedBusiness: 'Potwierdź, że firma nie znajduje się na liście działalności objętych ograniczeniami', routingNumber: 'Wprowadź prawidłowy numer rozliczeniowy', accountNumber: 'Wprowadź prawidłowy numer konta', - swiftCodeOrIban: 'Wprowadź prawidłowy kod Swift/IBAN', + iban: 'Wprowadź prawidłowy numer IBAN', + swiftCode: 'Wprowadź prawidłowy kod SWIFT/BIC', routingAndAccountNumberCannotBeSame: 'Numer rozliczeniowy i numer konta nie mogą być takie same', companyType: 'Wybierz prawidłowy typ firmy', tooManyAttempts: 'Ze względu na dużą liczbę prób logowania ta opcja została wyłączona na 24 godziny. Spróbuj ponownie później lub wprowadź dane ręcznie.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 1d94334b12f2..6c7955d9461d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3668,6 +3668,8 @@ ${amount} para ${merchant} - ${date}`, accountNumber: 'Número da conta', routingNumber: 'Número de roteamento', internationalBankAccountDetails: 'Dados da conta bancária internacional', + internationalBankAccountDetailsTitle: 'Quais são os dados da sua conta internacional?', + internationalBankAccountDetailsSubtitle: 'Um dos seus espaços de trabalho precisa de dados de conta internacional para processar reembolsos', iban: 'IBAN', swiftBicCode: 'Código SWIFT/BIC', chooseAnAccountBelow: 'Escolha uma conta abaixo', @@ -3719,7 +3721,8 @@ ${amount} para ${merchant} - ${date}`, restrictedBusiness: 'Confirme que a empresa não está na lista de empresas restritas', routingNumber: 'Insira um número de roteamento válido', accountNumber: 'Insira um número de conta válido', - swiftCodeOrIban: 'Por favor, insira um código Swift/IBAN válido', + iban: 'Insira um IBAN válido', + swiftCode: 'Insira um código SWIFT/BIC válido', routingAndAccountNumberCannotBeSame: 'Os números de roteamento e da conta não podem ser iguais', companyType: 'Selecione um tipo de empresa válido', tooManyAttempts: 'Devido ao alto número de tentativas de login, esta opção foi desativada por 24 horas. Tente novamente mais tarde ou insira os dados manualmente.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 249e8c325829..e59493b207f1 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3566,6 +3566,8 @@ ${amount},商户:${merchant} - 日期:${date}`, accountNumber: '账号编号', routingNumber: '路由号码', internationalBankAccountDetails: '国际银行账户信息', + internationalBankAccountDetailsTitle: '您的国际账户信息是什么?', + internationalBankAccountDetailsSubtitle: '您的其中一个工作区需要国际账户信息来处理报销', iban: 'IBAN', swiftBicCode: 'SWIFT/BIC 代码', chooseAnAccountBelow: '在下方选择一个账户', @@ -3616,7 +3618,8 @@ ${amount},商户:${merchant} - 日期:${date}`, restrictedBusiness: '请确认该企业不在受限制业务列表中', routingNumber: '请输入有效的路由号码', accountNumber: '请输入有效的账户号码', - swiftCodeOrIban: '请输入有效的 Swift 代码/IBAN', + iban: '请输入有效的 IBAN', + swiftCode: '请输入有效的 SWIFT/BIC 代码', routingAndAccountNumberCannotBeSame: '路由号码和账号不能相同', companyType: '请选择一个有效的公司类型', tooManyAttempts: '由于登录尝试次数过多,此选项已被禁用 24 小时。请稍后重试,或改为手动输入详细信息。', diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields.tsx new file mode 100644 index 000000000000..bebc71bb630e --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields.tsx @@ -0,0 +1,69 @@ +/** + * Shared IBAN + SWIFT/BIC input fields, used by both the personal and Corpay international bank account flows. + */ +import InputWrapper from '@components/Form/InputWrapper'; +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import type {TextInput as RNTextInput} from 'react-native'; + +import React from 'react'; + +const IBAN_INPUT_ID = 'iban'; +const SWIFT_CODE_INPUT_ID = 'swiftCode'; + +type InternationalBankAccountFieldsProps = { + /** Pre-filled IBAN value, if one is already known */ + ibanDefaultValue?: string; + + /** Pre-filled SWIFT/BIC code value, if one is already known */ + swiftCodeDefaultValue?: string; + + /** + * Whether keystrokes are persisted to the form draft immediately. This must be false while editing so the + * change is only committed to the draft when the user presses confirm, not on every keystroke. + */ + shouldSaveDraft: boolean; + + /** Ref callback used to auto-focus the IBAN input, if the consumer needs it */ + inputCallbackRef?: (ref: RNTextInput | null) => void; +}; + +function InternationalBankAccountFields({ibanDefaultValue = '', swiftCodeDefaultValue = '', shouldSaveDraft, inputCallbackRef}: InternationalBankAccountFieldsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + return ( + <> + + + + ); +} + +InternationalBankAccountFields.displayName = 'InternationalBankAccountFields'; + +export default InternationalBankAccountFields; +export {IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID}; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx index 38ea84c48ba5..a7a3f27ec004 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -42,7 +42,7 @@ import Confirmation from './subPages/Confirmation'; import CountrySelection from './subPages/CountrySelection'; import InternationalBankAccountDetails from './subPages/InternationalBankAccountDetails'; import Success from './subPages/Success'; -import {getFieldsMap, getInitialPersonalDetailsValues, getInitialSubstep, getSubstepValues, testValidation} from './utils'; +import {getFieldsMap, getInitialPersonalDetailsValues, getInitialSubstep, getSubstepValues, hasValidInternationalBankAccountDetails, testValidation} from './utils'; type InternationalDepositAccountContentProps = { privatePersonalDetails: OnyxEntry; @@ -104,8 +104,8 @@ function InternationalDepositAccountContent({ // Skip the international bank account details step when no collecting policy needs international details for the // selected bank country, or when we already have them: the account number is an IBAN and a SWIFT/BIC code is set. - const isAccountNumberIBAN = CONST.BANK_ACCOUNT.REGEX.IBAN.test((values.accountNumber ?? '').trim()); - const skipInternationalBankAccountDetailsStep = !shouldCollectInternationalDepositDetails || (isAccountNumberIBAN && (!!values.swiftCode || !!values.swiftBicCode)); + const skipInternationalBankAccountDetailsStep = + !shouldCollectInternationalDepositDetails || hasValidInternationalBankAccountDetails(values.iban, values.swiftCode, values.accountNumber, values.swiftBicCode); const startFrom = getInitialSubstep(values, fieldsMap, skipInternationalBankAccountDetailsStep); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx index 5e822cb3fc03..bbb3855b6c9d 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx @@ -12,6 +12,8 @@ import {getCurrentAddress, getStreetLines} from '@libs/PersonalDetailsUtils'; import Navigation from '@navigation/Navigation'; +import {hasValidInternationalBankAccountDetails} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; + import {addPersonalBankAccount} from '@userActions/BankAccounts'; import CONST from '@src/CONST'; @@ -89,9 +91,9 @@ function PersonalInfoPage() { addPersonalBankAccount(accountData, personalPolicyID); }; - // Skip when the details are already available: the account number is an IBAN and a SWIFT/BIC code is set. - const isAccountNumberIBAN = CONST.BANK_ACCOUNT.REGEX.IBAN.test((personalBankAccount?.accountNumber ?? '').trim()); - const shouldSkipInternationalBankAccountDetails = !shouldCollectInternationalDepositDetails || (isAccountNumberIBAN && !!personalBankAccount?.swiftCode); + // Skip when the details are already available: the IBAN and SWIFT/BIC code are both valid. + const shouldSkipInternationalBankAccountDetails = + !shouldCollectInternationalDepositDetails || hasValidInternationalBankAccountDetails(personalBankAccount?.iban, personalBankAccount?.swiftCode); const skipSteps = getSkippedStepsPersonalInfo(privatePersonalDetails, shouldSkipInternationalBankAccountDetails); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx index cca4d788992b..caf6809c8d9d 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/InternationalBankAccountDetailsStep.tsx @@ -2,10 +2,8 @@ * Personal-info substep for collecting international bank account details (IBAN + SWIFT/BIC) when adding a personal bank account. */ import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import Text from '@components/Text'; -import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; @@ -16,9 +14,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; +import InternationalBankAccountFields from '@pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields'; import {getInternationalBankAccountDetailsErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalBankAccountForm'; @@ -55,26 +53,13 @@ function InternationalBankAccountDetailsStep({onNext, isEditing}: InternationalB submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')} style={[styles.mh5, styles.flexGrow1]} > - {translate('bankAccount.internationalBankAccountDetails')} - - {translate('bankAccount.internationalBankAccountDetailsTitle')} + {translate('bankAccount.internationalBankAccountDetailsSubtitle')} + ); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx index a63a8fb3efd7..e16d9c7f7a58 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx @@ -31,6 +31,7 @@ function InternationalDepositAccount({route}: InternationalDepositAccountProps) const [isAccountLoading, isLoadingMetadata] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {selector: isLoadingPersonalBankAccountSelector}); const backTo = route.params?.backTo; + console.log('InternationalDepositAccount'); const isLoading = isLoadingOnyxValue(privatePersonalDetailsMetadata, corpayFieldsMetadata, bankAccountListMetadata, draftValuesMetadata, countryMetadata, isLoadingMetadata); if (isLoading) { diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index 2986a09dbbf8..56cdf92996aa 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -17,6 +17,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; +import {getInternationalBankAccountDetailsValues, hasValidInternationalBankAccountDetails} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; import {clearReimbursementAccountBankCreation, createCorpayBankAccountForWalletFlow, hideBankAccountErrors} from '@userActions/BankAccounts'; @@ -65,7 +66,13 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp }; const getDataAndGoToNextStep = (values: FormOnyxValues) => { - createCorpayBankAccountForWalletFlow({...formValues, ...values}, corpayFields?.classification ?? '', corpayFields?.destinationCountry ?? '', corpayFields?.preferredMethod ?? ''); + const dataToSubmit = {...formValues, ...values}; + // The international bank account details step may have been skipped, in which case iban/swiftCode are only + // available via accountNumber/swiftBicCode. Resolve them explicitly so the request always includes them. + const {iban, swiftCode} = getInternationalBankAccountDetailsValues(dataToSubmit.iban, dataToSubmit.swiftCode, dataToSubmit.accountNumber, dataToSubmit.swiftBicCode); + dataToSubmit.iban = iban; + dataToSubmit.swiftCode = swiftCode; + createCorpayBankAccountForWalletFlow(dataToSubmit, corpayFields?.classification ?? '', corpayFields?.destinationCountry ?? '', corpayFields?.preferredMethod ?? ''); }; useEffect(() => { @@ -125,11 +132,12 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp }); } - if (formValues.iban && formValues.swiftCode) { + if (hasValidInternationalBankAccountDetails(formValues.iban, formValues.swiftCode, formValues.accountNumber, formValues.swiftBicCode)) { + const {iban, swiftCode} = getInternationalBankAccountDetailsValues(formValues.iban, formValues.swiftCode, formValues.accountNumber, formValues.swiftBicCode); summaryItems.push({ - id: 'international-bank-account-details', + id: CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS, description: `${translate('bankAccount.iban')} / ${translate('bankAccount.swiftBicCode')}`, - title: `${formValues.iban} / ${formValues.swiftCode}`, + title: `${iban} / ${swiftCode}`, shouldShowRightIcon: true, onPress: () => { onMove(STEP_INDEXES.INTERNATIONAL_BANK_ACCOUNT_DETAILS); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx index ef934c81bd28..2a1423b1285a 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx @@ -2,10 +2,8 @@ * Sub-page for collecting international bank account details (IBAN + SWIFT/BIC) in the Corpay deposit-account flow. */ import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import Text from '@components/Text'; -import TextInput from '@components/TextInput'; import useInternationalBankAccountFormSubmit from '@hooks/useInternationalBankAccountFormSubmit'; import useLocalize from '@hooks/useLocalize'; @@ -13,18 +11,16 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; +import InternationalBankAccountFields, {IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID} from '@pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields'; import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; -import {getInternationalBankAccountDetailsErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; +import {getInternationalBankAccountDetailsErrors, getInternationalBankAccountDetailsValues} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; import {View} from 'react-native'; -const IBAN = 'iban'; -const SWIFT_CODE = 'swiftCode'; -const STEP_FIELDS = [IBAN, SWIFT_CODE]; +const STEP_FIELDS = [IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID]; function InternationalBankAccountDetails({isEditing, onNext, formValues}: CustomSubPageProps) { const {translate} = useLocalize(); @@ -38,13 +34,16 @@ function InternationalBankAccountDetails({isEditing, onNext, formValues}: Custom // Pre-fill from what the account details step already captured, until the user edits this step directly: the // account number is the IBAN for IBAN countries, and Corpay's SWIFT/BIC field (swiftBicCode) is the SWIFT code. - const accountNumber = formValues.accountNumber; - const ibanDefaultValue = formValues[IBAN] || (accountNumber && CONST.BANK_ACCOUNT.REGEX.IBAN.test(String(accountNumber).trim()) ? accountNumber : ''); - const swiftCodeDefaultValue = formValues[SWIFT_CODE] || formValues.swiftBicCode || ''; + const {iban: ibanDefaultValue, swiftCode: swiftCodeDefaultValue} = getInternationalBankAccountDetailsValues( + formValues[IBAN_INPUT_ID], + formValues[SWIFT_CODE_INPUT_ID], + formValues.accountNumber, + formValues.swiftBicCode, + ); const validate = (values: FormOnyxValues): FormInputErrors => ({ ...getFieldRequiredErrors(values, STEP_FIELDS, translate), - ...getInternationalBankAccountDetailsErrors(values[IBAN], values[SWIFT_CODE], translate), + ...getInternationalBankAccountDetailsErrors(values[IBAN_INPUT_ID], values[SWIFT_CODE_INPUT_ID], translate), }); return ( @@ -58,24 +57,9 @@ function InternationalBankAccountDetails({isEditing, onNext, formValues}: Custom > {translate('bankAccount.internationalBankAccountDetails')} - - diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts index 6047573a5693..fc51f691af32 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts +++ b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts @@ -114,8 +114,28 @@ function testValidation(values: InternationalBankAccountForm, fieldsMap: CorpayF return true; } -function hasValidInternationalBankAccountDetails(values: InternationalBankAccountForm) { - return !!values.iban && CONST.BANK_ACCOUNT.REGEX.IBAN.test(values.iban.trim()) && !!values.swiftCode && CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(values.swiftCode.trim()); +/** + * IBAN/SWIFT/BIC can come from either the dedicated `iban`/`swiftCode` fields filled in on the international bank + * account details step, or from `accountNumber`/`swiftBicCode`, which the Corpay bank-details step already collects + * for some countries. Either pairing satisfies the requirement, so both are checked. + */ +function hasValidInternationalBankAccountDetails(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string) { + const isIBANValid = CONST.BANK_ACCOUNT.REGEX.IBAN.test((iban ?? '').trim()) || CONST.BANK_ACCOUNT.REGEX.IBAN.test((accountNumber ?? '').trim()); + const isSwiftCodeValid = (!!swiftCode && CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(swiftCode.trim())) || !!swiftBicCode; + return isIBANValid && isSwiftCodeValid; +} + +/** + * Resolves the IBAN/SWIFT values to display or submit, falling back to `accountNumber`/`swiftBicCode` when the + * dedicated `iban`/`swiftCode` fields weren't collected (e.g. the international bank account details step was + * skipped because the Corpay bank-details step already gathered equivalent values). + */ +function getInternationalBankAccountDetailsValues(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string): {iban: string; swiftCode: string} { + const resolvedIBAN = iban || (accountNumber && CONST.BANK_ACCOUNT.REGEX.IBAN.test(accountNumber.trim()) ? accountNumber : ''); + return { + iban: resolvedIBAN, + swiftCode: swiftCode || swiftBicCode || '', + }; } function getInitialSubstep( @@ -131,7 +151,7 @@ function getInitialSubstep( } // When the international bank account details step is required but not yet satisfied, start there so a resumed or // deep-linked flow can't land past it and submit without the IBAN/SWIFT. - if (!skipInternationalBankAccountDetailsStep && !hasValidInternationalBankAccountDetails(values)) { + if (!skipInternationalBankAccountDetailsStep && !hasValidInternationalBankAccountDetails(values.iban, values.swiftCode, values.accountNumber, values.swiftBicCode)) { return CONST.CORPAY_FIELDS.INDEXES.MAPPING.INTERNATIONAL_BANK_ACCOUNT_DETAILS; } if (!testValidation(values, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE])) { @@ -174,12 +194,22 @@ function getInternationalBankAccountDetailsErrors( ): Partial> { const errors: Partial> = {}; if (iban && !CONST.BANK_ACCOUNT.REGEX.IBAN.test(iban.trim())) { - errors.iban = translate('bankAccount.error.swiftCodeOrIban'); + errors.iban = translate('bankAccount.error.iban'); } if (swiftCode && !CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(swiftCode.trim())) { - errors.swiftCode = translate('bankAccount.error.swiftCodeOrIban'); + errors.swiftCode = translate('bankAccount.error.swiftCode'); } return errors; } -export {getFieldsMap, getSubstepValues, getInitialPersonalDetailsValues, getInitialSubstep, testValidation, getValidationErrors, getInternationalBankAccountDetailsErrors}; +export { + getFieldsMap, + getSubstepValues, + getInitialPersonalDetailsValues, + getInitialSubstep, + testValidation, + getValidationErrors, + getInternationalBankAccountDetailsErrors, + hasValidInternationalBankAccountDetails, + getInternationalBankAccountDetailsValues, +}; From ecf978a7511b6319809a657e57d6074108c0ad49 Mon Sep 17 00:00:00 2001 From: "Situ Chandra Shil (via MelvinBot)" Date: Wed, 29 Jul 2026 22:28:07 +0000 Subject: [PATCH 098/466] Add confirmation modal before settling Expensify Card balance Co-authored-by: Situ Chandra Shil --- src/languages/en.ts | 2 ++ .../expensifyCard/WorkspaceCardsListLabel.tsx | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 4b367beeacd1..d02c4e2c0fed 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6030,6 +6030,8 @@ const translations = { currentBalanceDescription: 'Current balance is the sum of all posted Expensify Card transactions that have occurred since the last settlement date.', balanceWillBeSettledOn: (settlementDate: string) => `Balance will be settled on ${settlementDate}`, settleBalance: 'Settle balance', + settleBalanceConfirmationTitle: 'Settle balance?', + settleBalanceConfirmationPrompt: 'This will settle your current balance in the next business day. Once successful, the amount will be added back to your remaining limit.', cardLimit: 'Card limit', remainingLimit: 'Remaining limit', requestLimitIncrease: 'Request limit increase', diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx index 54ef3cd5fde1..e32a13215369 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -1,10 +1,12 @@ import Button from '@components/ButtonComposed'; import Icon from '@components/Icon'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import Popover from '@components/Popover'; import {PressableWithFeedback} from '@components/Pressable'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrencyForExpensifyCard from '@hooks/useCurrencyForExpensifyCard'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -65,6 +67,7 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const theme = useTheme(); const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); @@ -122,7 +125,18 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr const settlementDate = isSettleDateTextDisplayed ? format(addDays(new Date(), 1), CONST.DATE.FNS_FORMAT_STRING) : ''; const handleSettleBalanceButtonClick = () => { - queueExpensifyCardForBilling(CONST.COUNTRY.US, defaultFundID); + showConfirmModal({ + danger: true, + title: translate('workspace.expensifyCard.settleBalanceConfirmationTitle'), + prompt: translate('workspace.expensifyCard.settleBalanceConfirmationPrompt'), + confirmText: translate('workspace.expensifyCard.settleBalance'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + queueExpensifyCardForBilling(CONST.COUNTRY.US, defaultFundID); + }); }; const handleViewTransactionsPress = () => { From caa6931dad895a0dbb19709c232d87a2582a167f Mon Sep 17 00:00:00 2001 From: "Situ Chandra Shil (via MelvinBot)" Date: Wed, 29 Jul 2026 22:32:15 +0000 Subject: [PATCH 099/466] Add settle balance confirmation translations for all locales Co-authored-by: Situ Chandra Shil --- src/languages/de.ts | 3 +++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 3 +++ src/languages/it.ts | 3 +++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 3 +++ src/languages/pl.ts | 3 +++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 2 ++ 9 files changed, 23 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index fd6eedb15f78..b74a241fb075 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5942,6 +5942,9 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU currentBalanceDescription: 'Der aktuelle Saldo ist die Summe aller verbuchten Expensify Karte-Transaktionen, die seit dem letzten Abrechnungsdatum erfolgt sind.', balanceWillBeSettledOn: (settlementDate: string) => `Der Saldo wird am ${settlementDate} ausgeglichen.`, settleBalance: 'Saldo ausgleichen', + settleBalanceConfirmationTitle: 'Saldo ausgleichen?', + settleBalanceConfirmationPrompt: + 'Dadurch wird dein aktueller Saldo am nächsten Werktag ausgeglichen. Nach erfolgreicher Abwicklung wird der Betrag deinem verbleibenden Limit wieder gutgeschrieben.', cardLimit: 'Kartenlimit', remainingLimit: 'Verbleibendes Limit', requestLimitIncrease: 'Anhebung des Anfragelimits', diff --git a/src/languages/es.ts b/src/languages/es.ts index 39acb17bf1f2..5e0b971003f2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5779,6 +5779,8 @@ ${amount} para ${merchant} - ${date}`, 'El saldo actual es la suma de todas las transacciones contabilizadas con la Tarjeta Expensify que se han producido desde la última fecha de liquidación.', balanceWillBeSettledOn: (settlementDate) => `El saldo se liquidará el ${settlementDate}.`, settleBalance: 'Liquidar saldo', + settleBalanceConfirmationTitle: '¿Liquidar saldo?', + settleBalanceConfirmationPrompt: 'Esto liquidará tu saldo actual el próximo día hábil. Una vez completado, el importe se sumará de nuevo a tu límite restante.', cardLimit: 'Límite de la tarjeta', remainingLimit: 'Límite restante', requestLimitIncrease: 'Solicitar aumento de límite', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 29a0ead045e5..336b330632e1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5954,6 +5954,9 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. currentBalanceDescription: 'Le solde actuel est la somme de toutes les transactions Carte Expensify comptabilisées depuis la dernière date de règlement.', balanceWillBeSettledOn: (settlementDate: string) => `Le solde sera réglé le ${settlementDate}`, settleBalance: 'Régler le solde', + settleBalanceConfirmationTitle: 'Régler le solde ?', + settleBalanceConfirmationPrompt: + 'Cela réglera votre solde actuel le prochain jour ouvrable. Une fois l’opération réussie, le montant sera de nouveau ajouté à votre limite restante.', cardLimit: 'Plafond de carte', remainingLimit: 'Plafond restant', requestLimitIncrease: 'Demander une augmentation de la limite', diff --git a/src/languages/it.ts b/src/languages/it.ts index 346750c232da..862b3a19f552 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5911,6 +5911,9 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. currentBalanceDescription: 'Il saldo attuale è la somma di tutte le transazioni contabilizzate della Carta Expensify che si sono verificate dalla data dell’ultima liquidazione.', balanceWillBeSettledOn: (settlementDate: string) => `Il saldo sarà regolato il ${settlementDate}`, settleBalance: 'Saldo da saldare', + settleBalanceConfirmationTitle: 'Saldare il saldo?', + settleBalanceConfirmationPrompt: + 'Questa operazione salderà il tuo saldo attuale il giorno lavorativo successivo. Una volta completata, l’importo verrà aggiunto nuovamente al tuo limite rimanente.', cardLimit: 'Limite carta', remainingLimit: 'Limite rimanente', requestLimitIncrease: 'Richiedi aumento limite', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index afd837779ffa..4c1cfd067a3a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5850,6 +5850,8 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO currentBalanceDescription: '現在残高は、前回の精算日以降に発生し記帳されたすべての Expensify カード取引の合計です。', balanceWillBeSettledOn: (settlementDate: string) => `残高は${settlementDate}に精算されます`, settleBalance: '残高を清算', + settleBalanceConfirmationTitle: '残高を清算しますか?', + settleBalanceConfirmationPrompt: '現在の残高が翌営業日に清算されます。清算が完了すると、その金額は残りの利用限度額に戻されます。', cardLimit: 'カード上限', remainingLimit: '残りの上限', requestLimitIncrease: 'リクエスト上限の引き上げ', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2f372c7d6454..1cb786302076 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5905,6 +5905,9 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ currentBalanceDescription: 'Het huidige saldo is de som van alle geboekte Expensify Kaart-transacties die hebben plaatsgevonden sinds de laatste afwikkelingsdatum.', balanceWillBeSettledOn: (settlementDate: string) => `Saldo wordt vereffend op ${settlementDate}`, settleBalance: 'Saldo vereffenen', + settleBalanceConfirmationTitle: 'Saldo vereffenen?', + settleBalanceConfirmationPrompt: + 'Hiermee wordt je huidige saldo op de volgende werkdag vereffend. Zodra dit is gelukt, wordt het bedrag weer aan je resterende limiet toegevoegd.', cardLimit: 'Kaartlimiet', remainingLimit: 'Resterende limiet', requestLimitIncrease: 'Verhoging van limiet aanvragen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ac56a223d41e..03bc01d575e4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5885,6 +5885,9 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy currentBalanceDescription: 'Bieżące saldo to suma wszystkich zaksięgowanych transakcji Kartą Expensify, które miały miejsce od ostatniej daty rozliczenia.', balanceWillBeSettledOn: (settlementDate: string) => `Saldo zostanie rozliczone w dniu ${settlementDate}`, settleBalance: 'Ureguluj saldo', + settleBalanceConfirmationTitle: 'Uregulować saldo?', + settleBalanceConfirmationPrompt: + 'Spowoduje to uregulowanie bieżącego salda w następnym dniu roboczym. Po pomyślnym zakończeniu kwota zostanie ponownie dodana do pozostałego limitu.', cardLimit: 'Limit karty', remainingLimit: 'Pozostały limit', requestLimitIncrease: 'Zwiększ limit żądań', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index df7421970e4b..a0656426d50c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5899,6 +5899,8 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS currentBalanceDescription: 'O saldo atual é a soma de todas as transações lançadas do Cartão Expensify que ocorreram desde a última data de liquidação.', balanceWillBeSettledOn: (settlementDate: string) => `O saldo será liquidado em ${settlementDate}`, settleBalance: 'Quitar saldo', + settleBalanceConfirmationTitle: 'Quitar saldo?', + settleBalanceConfirmationPrompt: 'Isso quitará seu saldo atual no próximo dia útil. Após a conclusão, o valor será adicionado novamente ao seu limite restante.', cardLimit: 'Limite do cartão', remainingLimit: 'Limite restante', requestLimitIncrease: 'Solicitar aumento de limite', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d62d9d0b7477..bcbaea1e7f07 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5727,6 +5727,8 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM currentBalanceDescription: '当前余额是上次结算日期以来已入账的所有 Expensify 卡交易的总和。', balanceWillBeSettledOn: (settlementDate: string) => `余额将于 ${settlementDate} 结清`, settleBalance: '结清余额', + settleBalanceConfirmationTitle: '结清余额?', + settleBalanceConfirmationPrompt: '这将在下一个工作日结清您的当前余额。成功后,该金额将重新计入您的剩余额度。', cardLimit: '卡片限额', remainingLimit: '剩余额度', requestLimitIncrease: '请求提高限额', From e98956e0ead1574f3c863de870b4258b6a805562 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 30 Jul 2026 10:34:53 +0700 Subject: [PATCH 100/466] call OpenDepositAccountSetup --- src/ONYXKEYS.ts | 4 +++ src/hooks/useLoadDepositAccountSetup.ts | 27 +++++++++++++++++++ ...houldCollectInternationalDepositDetails.ts | 3 +++ src/libs/API/types.ts | 2 ++ src/libs/actions/BankAccounts.ts | 27 +++++++++++++++++++ src/pages/AddPersonalBankAccountPage.tsx | 11 ++++++++ .../InternationalDepositAccount/index.tsx | 10 +++++-- 7 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useLoadDepositAccountSetup.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e4bf31a3ce21..87c2844752e8 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -444,6 +444,9 @@ const ONYXKEYS = { /** Set when we are loading the bulk change approver page */ IS_LOADING_BULK_CHANGE_APPROVER_PAGE: 'isLoadingBulkChangeApproverPage', + /** Set when we are loading the reimbursement countries used to decide whether to collect international deposit details */ + IS_LOADING_DEPOSIT_ACCOUNT_SETUP: 'isLoadingDepositAccountSetup', + /** Is report data loading? */ IS_LOADING_APP: 'isLoadingApp', @@ -1632,6 +1635,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_PAYMENT_METHODS]: boolean; [ONYXKEYS.IS_LOADING_SHARE_BANK_ACCOUNTS]: boolean; [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; + [ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; diff --git a/src/hooks/useLoadDepositAccountSetup.ts b/src/hooks/useLoadDepositAccountSetup.ts new file mode 100644 index 000000000000..62c58f8f872c --- /dev/null +++ b/src/hooks/useLoadDepositAccountSetup.ts @@ -0,0 +1,27 @@ +import {openDepositAccountSetup} from '@userActions/BankAccounts'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import {useEffect} from 'react'; + +import useOnyx from './useOnyx'; + +/** + * Fetches the reimbursement countries for the user's policies (OpenDepositAccountSetup) when the personal bank + * account setup flow is opened and reports whether that fetch is in flight. + * + * useShouldCollectInternationalDepositDetails decides whether to collect international deposit details from this data, + * and that decision determines which steps the flow shows and where it starts. Because the substep hooks capture their + * starting position on the first render, callers must gate the flow on this loading state so the decision is made with + * the reimbursement data present rather than a stale/empty value. + */ +function useLoadDepositAccountSetup(): boolean { + useEffect(() => { + openDepositAccountSetup(); + }, []); + + const [isLoadingDepositAccountSetup = true] = useOnyx(ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP); + return isLoadingDepositAccountSetup; +} + +export default useLoadDepositAccountSetup; diff --git a/src/hooks/useShouldCollectInternationalDepositDetails.ts b/src/hooks/useShouldCollectInternationalDepositDetails.ts index 2b850db8ae8e..37fe6fb77205 100644 --- a/src/hooks/useShouldCollectInternationalDepositDetails.ts +++ b/src/hooks/useShouldCollectInternationalDepositDetails.ts @@ -9,6 +9,9 @@ import useOnyx from './useOnyx'; * Returns whether international deposit details (IBAN/SWIFT) should be collected when adding a bank account in the * given country. This is the case when any of the user's policies can reimburse from a country other than the one * the bank account is being added in. + * + * The reimbursement countries this relies on are not part of the policy summary; they are fetched via + * useLoadDepositAccountSetup at the entry of the bank account setup flow, which also gates rendering until they load. */ function useShouldCollectInternationalDepositDetails(bankCountry: string): boolean { const selector = (policies: OnyxCollection) => diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e57c87cf6359..07b4c928e328 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1357,6 +1357,7 @@ const READ_COMMANDS = { OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView', GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken', OPEN_PAYMENTS_PAGE: 'OpenPaymentsPage', + OPEN_DEPOSIT_ACCOUNT_SETUP: 'OpenDepositAccountSetup', OPEN_BANK_ACCOUNT_SHARE_PAGE: 'OpenBankAccountSharePage', OPEN_PUBLIC_PROFILE_PAGE: 'OpenPublicProfilePage', OPEN_PLAID_BANK_LOGIN: 'OpenPlaidBankLogin', @@ -1462,6 +1463,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: Parameters.OpenWorkspaceViewParams; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: null; [READ_COMMANDS.OPEN_PAYMENTS_PAGE]: Parameters.OpenPaymentsPageParams | null; + [READ_COMMANDS.OPEN_DEPOSIT_ACCOUNT_SETUP]: null; [READ_COMMANDS.OPEN_BANK_ACCOUNT_SHARE_PAGE]: null; [READ_COMMANDS.OPEN_PUBLIC_PROFILE_PAGE]: Parameters.OpenPublicProfilePageParams; [READ_COMMANDS.OPEN_PLAID_BANK_LOGIN]: Parameters.OpenPlaidBankLoginParams; diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 3ff824a530cd..6cbaad5099b0 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -168,6 +168,32 @@ function openPersonalBankAccountSetupView({ }); } +/** + * Fetches the reimbursement countries for each of the user's policies and merges them into the policy_ Onyx + * collection. The personal bank account setup flow uses policy.reimbursement.enabled/countries to decide whether to + * collect international deposit details (IBAN/SWIFT), and this data is intentionally not part of the policy summary. + */ +function openDepositAccountSetup() { + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP, + value: true, + }, + ], + finallyData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP, + value: false, + }, + ], + }; + + API.read(READ_COMMANDS.OPEN_DEPOSIT_ACCOUNT_SETUP, null, onyxData); +} + /** * Open the personal bank account setup flow using Plaid, with an optional exitReportID to redirect to once the flow is finished. */ @@ -1881,6 +1907,7 @@ export { deletePaymentBankAccount, handlePlaidError, openPersonalBankAccountSetupView, + openDepositAccountSetup, openReimbursementAccountPage, updateBeneficialOwnersForBankAccount, updateCompanyInformationForBankAccount, diff --git a/src/pages/AddPersonalBankAccountPage.tsx b/src/pages/AddPersonalBankAccountPage.tsx index 7ad564857e61..226bfe8ce407 100644 --- a/src/pages/AddPersonalBankAccountPage.tsx +++ b/src/pages/AddPersonalBankAccountPage.tsx @@ -1,16 +1,19 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ConfirmationPage from '@components/ConfirmationPage'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import useLoadDepositAccountSetup from '@hooks/useLoadDepositAccountSetup'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {clearPersonalBankAccount} from '@userActions/BankAccounts'; import {continueSetup} from '@userActions/PaymentMethods'; @@ -28,6 +31,10 @@ function AddPersonalBankAccountPage() { const {translate} = useLocalize(); const [personalBankAccount] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT); const shouldShowSuccess = personalBankAccount?.shouldShowSuccess ?? false; + + // The reimbursement countries decide whether PersonalInfoPage collects international deposit details, so gate it + // until they load to keep the substep hook from initializing with stale/empty data. + const isLoadingDepositAccountSetup = useLoadDepositAccountSetup(); const topmostFullScreenRoute = navigationRef.current?.getRootState()?.routes.findLast((route) => isFullScreenName(route.name)); const kycWallRef = useContext(KYCWallContext); @@ -89,6 +96,10 @@ function AddPersonalBankAccountPage() { ); } + if (isLoadingDepositAccountSetup) { + return ; + } + return ; } diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx index e16d9c7f7a58..44568644bb34 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx @@ -1,5 +1,6 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import useLoadDepositAccountSetup from '@hooks/useLoadDepositAccountSetup'; import useOnyx from '@hooks/useOnyx'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -31,8 +32,13 @@ function InternationalDepositAccount({route}: InternationalDepositAccountProps) const [isAccountLoading, isLoadingMetadata] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {selector: isLoadingPersonalBankAccountSelector}); const backTo = route.params?.backTo; - console.log('InternationalDepositAccount'); - const isLoading = isLoadingOnyxValue(privatePersonalDetailsMetadata, corpayFieldsMetadata, bankAccountListMetadata, draftValuesMetadata, countryMetadata, isLoadingMetadata); + // The reimbursement countries drive which steps the flow shows and where it starts, so gate the content until + // they load to keep the substep hooks from initializing with stale/empty data. + const isLoadingDepositAccountSetup = useLoadDepositAccountSetup(); + + const isLoading = + isLoadingDepositAccountSetup || + isLoadingOnyxValue(privatePersonalDetailsMetadata, corpayFieldsMetadata, bankAccountListMetadata, draftValuesMetadata, countryMetadata, isLoadingMetadata); if (isLoading) { const reasonAttributes: SkeletonSpanReasonAttributes = {context: 'InternationalDepositAccount', isLoading}; From edff844c1a64546dbf127dfbb797529f936aa89c Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 30 Jul 2026 11:09:51 +0700 Subject: [PATCH 101/466] lint fix --- src/libs/ExportOnyxState/common.ts | 1 + .../Wallet/InternationalDepositAccount/utils.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 725f8b0953de..d23354b4ab1c 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -201,6 +201,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.IS_LOADING_APP, ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE, + ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP, ONYXKEYS.IS_LOADING_PAYMENT_METHODS, ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW, ONYXKEYS.IS_LOADING_REPORT_DATA, diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts index fc51f691af32..0f808ffa2b76 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts +++ b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts @@ -131,10 +131,19 @@ function hasValidInternationalBankAccountDetails(iban: string | undefined, swift * skipped because the Corpay bank-details step already gathered equivalent values). */ function getInternationalBankAccountDetailsValues(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string): {iban: string; swiftCode: string} { - const resolvedIBAN = iban || (accountNumber && CONST.BANK_ACCOUNT.REGEX.IBAN.test(accountNumber.trim()) ? accountNumber : ''); + let resolvedIBAN = iban ?? ''; + if (!resolvedIBAN && accountNumber && CONST.BANK_ACCOUNT.REGEX.IBAN.test(accountNumber.trim())) { + resolvedIBAN = accountNumber; + } + + let resolvedSwiftCode = swiftCode ?? ''; + if (!resolvedSwiftCode) { + resolvedSwiftCode = swiftBicCode ?? ''; + } + return { iban: resolvedIBAN, - swiftCode: swiftCode || swiftBicCode || '', + swiftCode: resolvedSwiftCode, }; } From b30ef9a214394d8c51a964974521397ee72054c7 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 30 Jul 2026 15:33:16 +0200 Subject: [PATCH 102/466] add required content --- .../HomeAddressRequiredContent.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx diff --git a/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx new file mode 100644 index 000000000000..2a418f41c17e --- /dev/null +++ b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx @@ -0,0 +1,45 @@ +import Button from '@components/ButtonComposed'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import Navigation from '@libs/Navigation/Navigation'; +import {getOriginalMessage, getReportActionText} from '@libs/ReportActionsUtils'; + +import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; +import type {ReportAction} from '@src/types/onyx'; + +import React from 'react'; + +type HomeAddressRequiredContentProps = { + action: ReportAction; +}; + +function HomeAddressRequiredContent({action}: HomeAddressRequiredContentProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + // The prompt is resolved once the member saves a home address; the backend clears the actionable state and + // stamps a resolution, so hide the CTA to avoid pointing at an already-completed task. + const isResolved = !!getOriginalMessage(action)?.resolution; + + return ( + + {!isResolved && ( + + )} + + ); +} + +export default HomeAddressRequiredContent; From 99b9b6f3c6918d8e8f0e14b5c9b421808a1963db Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 30 Jul 2026 15:54:33 +0200 Subject: [PATCH 103/466] typescript --- tests/actions/WorkflowTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 46388818af33..449a988431c8 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -63,7 +63,6 @@ async function getRulesCollection(): Promise> { let collection: OnyxCollection = {}; await getOnyxData({ key: ONYXKEYS.COLLECTION.RULE, - waitForCollectionCallback: true, callback: (value) => { collection = value ?? {}; }, From c3730bf75048e0525be630fc595e4a69a08301ee Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 30 Jul 2026 15:58:13 +0200 Subject: [PATCH 104/466] tests --- src/libs/WorkflowUtils.ts | 2 +- src/libs/actions/Workflow.ts | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index e9e3541f4a1f..d6d1cfbf81f1 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -681,7 +681,7 @@ function getEligibleExistingBusinessBankAccounts( account.bankCurrency === policyCurrency && (account.accountData?.state === CONST.BANK_ACCOUNT.STATE.OPEN || (shouldIncludePartiallySetup && isBankAccountPartiallySetup(account.accountData?.state))) && account.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && - account.methodID !== excludeBankAccountID + (excludeBankAccountID === undefined || account.methodID !== excludeBankAccountID) ); }); } diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index d3406342dbe7..8f08ca29bb29 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -298,11 +298,6 @@ type SetApprovalWorkflowRulesParams = { /** * Apply a set of approval-workflow rule changes to a policy via the SetApprovalWorkflow Auth command. - * - * Each rule lives under its own `ONYXKEYS.COLLECTION.RULE` key (`rules_`). Upserts use SET so - * a structurally different replacement doesn't deep-merge stale filters; removals mark the rule as - * pending-delete optimistically and clear it on success (or restore the previous rule on failure). - * Only the rule bodies (no `scope`/`scopeID`) are sent over the wire — the backend derives those. */ function setApprovalWorkflowRules({policyID, rulesDiff, previousRules}: SetApprovalWorkflowRulesParams) { if (!policyID || isEmptyObject(rulesDiff)) { From 1aa65ca87137be102752c15431716bf8f2d823cb Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 30 Jul 2026 17:11:35 +0200 Subject: [PATCH 105/466] cleanup for new shape --- src/CONST/index.ts | 5 ----- src/libs/WorkflowUtils.ts | 30 ++++++++++++------------------ src/libs/actions/Policy/Policy.ts | 2 -- src/libs/actions/Workflow.ts | 4 ++-- tests/unit/WorkflowUtilsTest.ts | 20 ++++++++------------ 5 files changed, 22 insertions(+), 39 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b631b21e33d7..5795a4c53a2b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7919,20 +7919,15 @@ const CONST = { }, }, - /** Vocabulary for the AST-based rules stored in the `rules` table / `ONYXKEYS.COLLECTION.RULE`. */ RULES: { - /** The kind of entity a rule is scoped to. Currently only workspace policies. Shared across all rule types. */ SCOPE: { POLICY: 'policy', }, - /** Vocabulary specific to approval-workflow rules. */ APPROVAL_WORKFLOW: { - /** The report lifecycle event that fires a rule. */ TRIGGER: { REPORT_SUBMIT: 'ReportSubmit', REPORT_APPROVE: 'ReportApprove', }, - /** The action taken when a rule matches. */ ACTION: { FORWARD_TO: 'ForwardTo', APPROVE_REPORT: 'ApproveReport', diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index d6d1cfbf81f1..ce8767eeb803 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -134,10 +134,7 @@ type PolicyConversionParams = { /** Current user's login email, used to determine if Expensify team members should be shown */ currentUserLogin?: string; - /** - * The policy's approval-workflow rules keyed by ruleID, read from the `ONYXKEYS.COLLECTION.RULE` - * collection (see `getApprovalWorkflowRulesForPolicy`). Only consumed by the rules-based converter. - */ + /** The policy's approval-workflow rules keyed by ruleID */ rules?: Record; }; @@ -844,7 +841,7 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva filters: buildAnd(fromComparison, buildAnd(toComparison, underAmount)), actions: nextApproverEmail ? buildForwardActions(nextApproverEmail) : buildApproveActions(), }); - // At/over the limit: escalate to the over-limit approver, who then finalizes the report. + // Over the limit: escalate to the over-limit approver, who then finalizes the report. rules.push({ triggers: buildApproveTriggers(), filters: buildAnd(fromComparison, buildAnd(toComparison, overAmount)), @@ -877,8 +874,11 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva } /** - * A leaf comparison node — its `left` is a primitive field name (or an array of values), unlike a - * boolean filter whose `left` is another node. + * True when this node is a single comparison like `from = alice@expensify.com`, rather than an `AND` that + * joins two other nodes. + * + * Both look the same (`{operator, left, right}`), so the giveaway is `left`: a comparison points at a field + * name, an `AND` points at another node. Arrays count as comparisons too. */ function isComparisonLeaf(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): node is ApprovalWorkflowFilterComparison { if (!node) { @@ -927,24 +927,18 @@ function extractSubmitterEmails(rule: ApprovalWorkflowRule): string[] { } /** - * Normalize a value so two logically-equal structures stringify identically: object keys are sorted and - * index-keyed maps (`{"0":x}`) are collapsed to arrays (`[x]`). The array collapse matters because the API - * decodes the rules JSON to PHP arrays, so `triggers`/`actions` come back as JSON arrays while a freshly-built - * rule uses the `{"0":…}` object form — without it the two shapes never match and merging fails. + * Rewrite a value so that `JSON.stringify` gives the same string for two structures that mean the same thing. + * + * Only key order needs fixing: a rule that came back from the server can list the same keys in a different + * order than one we just built, and those two would otherwise produce different strings and never match. */ function canonicalize(value: unknown): unknown { if (Array.isArray(value)) { return value.map(canonicalize); } if (value !== null && typeof value === 'object') { - const entries = Object.entries(value); - // A `{"0":…,"1":…}` map is the object form of a list — collapse it to an array to match the backend. - const isSequentialIndexMap = entries.length > 0 && entries.every(([key], index) => key === String(index)); - if (isSequentialIndexMap) { - return entries.map(([, val]) => canonicalize(val)); - } // Byte-order sort (not locale-aware): this is a structural fingerprint, not user-facing text. - const sortedEntries = entries.sort(([a], [b]) => { + const sortedEntries = Object.entries(value).sort(([a], [b]) => { if (a === b) { return 0; } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 60ea57d14d35..974cfc2a47b9 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1026,8 +1026,6 @@ function setWorkspaceApprovalMode( failureData.push(...nextStepFailureData); } - // Auth's DisablePolicyApprovals deletes the policy's rule rows server-side; clear them optimistically too - // so the workflows UI doesn't keep rebuilding stale workflows (e.g. after re-enabling). if (approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL && rules) { for (const [ruleKey, rule] of Object.entries(rules)) { if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 8f08ca29bb29..e2996eb040fb 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -287,12 +287,12 @@ type SetApprovalWorkflowRulesParams = { policyID: string; /** - * Diff of rule bodies keyed by ruleID. A value of `ApprovalWorkflowRule` sets/replaces the rule + * Diff of rules keyed by ruleID. A value of `ApprovalWorkflowRule` sets/replaces the rule * under its `ruleID` in the `ONYXKEYS.COLLECTION.RULE` collection; a value of `null` removes it. */ rulesDiff: ApprovalWorkflowRulesDiff; - /** Snapshot of the policy's rules (`ruleID -> rule body`) taken before applying `rulesDiff`. Used to roll back on failure. */ + /** Snapshot of the policy's rules taken before applying `rulesDiff`. Used to roll back on failure. */ previousRules: Record; }; diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 49854a6f0582..e160f46431fe 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1694,9 +1694,6 @@ describe('WorkflowUtils', () => { right, }); const keyRules = (rules: ApprovalWorkflowRule[]): Record => Object.fromEntries(rules.map((rule, index) => [`rule${index}`, rule])); - // Present a list as the array-shaped value the API returns for `triggers`/`actions` (PHP decodes index maps to arrays). - const asServerShape = (value: unknown[]): T => value as unknown as T; - describe('buildApprovalWorkflowRules', () => { it('Should return an empty list when there are no members', () => { expect(buildApprovalWorkflowRules(buildWorkflow([], [1]))).toEqual([]); @@ -1788,21 +1785,20 @@ describe('WorkflowUtils', () => { expect(Object.values(diff)).toEqual(buildApprovalWorkflowRules(buildWorkflow([20, 10], [1]))); }); - it('Should merge into a server-hydrated rule (array-shaped triggers/actions, reordered keys)', () => { - // The API decodes the rules JSON to PHP associative arrays, so a hydrated rule comes back with - // index-keyed maps flattened to arrays (`{"0":"ReportSubmit"}` -> `["ReportSubmit"]`) and its object - // keys in a different order than a freshly-built rule. The structural match must ignore both so the - // new submitter folds into the existing rule instead of minting a fresh pair. + it('Should merge into a server-hydrated rule (reordered keys)', () => { + // A rule hydrated from the server lists its object keys in a different order than a freshly-built + // one. The structural match must ignore key order so the new submitter folds into the existing rule + // instead of minting a fresh pair. const existingRules: Record = { r1: { - actions: asServerShape([{approver: '1@example.com', name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO}]), + actions: forwardActions('1@example.com'), filters: buildFromFilter(['20@example.com']), - triggers: asServerShape([CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT]), + triggers: submitTriggers, }, r2: { - actions: asServerShape([{name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.APPROVE_REPORT}]), + actions: approveActions, filters: and(buildFromFilter(['20@example.com']), buildToFilter('1@example.com')), - triggers: asServerShape([CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE]), + triggers: approveTriggers, }, }; const newRules = buildApprovalWorkflowRules(buildWorkflow([10], [1])); From 540ec3e8a616908d4c2d5ab32434b3df6f3f0811 Mon Sep 17 00:00:00 2001 From: "Jules (via MelvinBot)" Date: Thu, 30 Jul 2026 15:40:20 +0000 Subject: [PATCH 106/466] Let single-field edit input grow to reduce wasted white space Co-authored-by: Jules --- src/pages/EditReportFieldText.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/EditReportFieldText.tsx b/src/pages/EditReportFieldText.tsx index 4536e454ffdb..db9f719eaa46 100644 --- a/src/pages/EditReportFieldText.tsx +++ b/src/pages/EditReportFieldText.tsx @@ -10,6 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {hasCircularReferences} from '@libs/Formula'; import type {FieldList} from '@libs/Formula'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -84,6 +86,8 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f label={reportFieldName} accessibilityLabel={reportFieldName} role={CONST.ROLE.PRESENTATION} + autoGrowHeight + maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight} ref={inputCallbackRef} disabled={disabled} /> From 838758d7341800ccbbe9af06f4fcc942e13e50f7 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 30 Jul 2026 17:40:41 +0200 Subject: [PATCH 107/466] comment cleanup --- src/libs/WorkflowUtils.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index ce8767eeb803..760f101d46eb 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1060,8 +1060,7 @@ function reconcileApprovalWorkflowRulesForCreate(newRules: ApprovalWorkflowRule[ /** * Reconcile an edit to an existing workflow into add/replace/remove instructions, preserving rules shared - * with other workflows. Pass 1 rewrites/removes existing rules that hold this workflow's members; pass 2 - * folds each new rule into a structurally-matching foreign rule or creates it under a fresh ruleID. + * with other workflows. */ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { const diff: ApprovalWorkflowRulesDiff = {}; @@ -1126,8 +1125,8 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], /** * Reconcile the deletion of a workflow. Rules that listed only this workflow's members in - * their `from` filter are removed outright; rules shared with other workflows have just - * this workflow's members stripped from `from`. + * their `from` filter are removed outright; rules shared with other workflows have this workflow's members + * stripped from `from`. */ function reconcileApprovalWorkflowRulesForRemove(memberEmails: string[], context: ReconcileContext): ApprovalWorkflowRulesDiff { const diff: ApprovalWorkflowRulesDiff = {}; @@ -1188,9 +1187,6 @@ function applyApprovalWorkflowRulesDiff(existingRules: Record, employees: PolicyEmployeeList): string | undefined { for (const rule of Object.values(rules)) { From 413c56ad3add05aba425edc9d21ca47584dcc619 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 30 Jul 2026 17:50:03 +0200 Subject: [PATCH 108/466] more cleanup of robot quirks --- src/ONYXKEYS.ts | 3 +-- .../WorkspaceWorkflowsApprovalsEditPage.tsx | 3 --- src/types/onyx/ApprovalWorkflowRules.ts | 8 ++------ src/types/onyx/Rule.ts | 7 +------ tests/actions/WorkflowTest.ts | 14 +++++++------- 5 files changed, 11 insertions(+), 24 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 2d6e73abb651..47461b291304 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -867,8 +867,7 @@ const ONYXKEYS = { POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED: 'policyHasConnectionsDataBeenFetched_', POLICY_CONNECTION_SYNC_PROGRESS: 'policyConnectionSyncProgress_', POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN: 'policyMergeHRInitialSyncModalShown_', - // AST rules keyed by ruleID (`rules_`). Mirrors a row of the server `rules` table. - // Currently holds approval-workflow rules; intended for all AST rules in the future. + // AST rules keyed by ruleID RULE: 'rules_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index bd0f1b7e1c8f..b72fe2e00df8 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -135,9 +135,6 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true ? convertApprovalWorkflowRulesToWorkflows(conversionParams) : convertPolicyEmployeesToApprovalWorkflows(conversionParams); - // `firstApproverEmail` isn't unique once rule-based chains diverge, so prefer matching on the - // member that opened this workflow (a member belongs to exactly one workflow). Fall back to the - // first-approver match for the default workflow (no members) and legacy/unspecified cases. const memberEmail = route.params.memberEmail; const currentApprovalWorkflow = (memberEmail ? result.approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === memberEmail)) : undefined) ?? diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index bd99b259e426..06d5d308d702 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -73,12 +73,8 @@ type ApprovalWorkflowFilter = { }; /** - * The body of a single approval-workflow rule. This is exactly what is stored in the server `rules` - * table `value` column, what is sent over the wire to `SetApprovalWorkflow`, and (extended with - * `scope`/`scopeID`) what is stored in the `ONYXKEYS.COLLECTION.RULE` collection. - * - * When the report event matches one of the `triggers` and the `filters` match the report, the rule's - * `actions` are performed (forward the report to an approver, or approve it). + * The body of a single approval-workflow rule. When the report event matches one of the `triggers` and + * the `filters` match the report, the rule's `actions` are performed */ type ApprovalWorkflowRule = { /** Report lifecycle events that fire this rule. */ diff --git a/src/types/onyx/Rule.ts b/src/types/onyx/Rule.ts index 5348ecdcecd7..a48f1defc989 100644 --- a/src/types/onyx/Rule.ts +++ b/src/types/onyx/Rule.ts @@ -9,12 +9,7 @@ import type {Errors, OnyxValueWithOfflineFeedback} from './OnyxCommon'; type RuleScope = ValueOf; /** - * A rule as stored in the `ONYXKEYS.COLLECTION.RULE` collection under `rules_`. This mirrors a - * row of the server `rules` table: the AST rule body (`triggers`/`filters`/`actions`) plus the - * `scope`/`scopeID` that associate it with an entity (a policy, for approval workflows). - * - * The same collection is intended to hold every kind of AST rule in the future, not just approval - * workflows. + * A rule as stored in the `ONYXKEYS.COLLECTION.RULE` collection under `rules_`. */ type Rule = OnyxValueWithOfflineFeedback< ApprovalWorkflowRule & { diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 449a988431c8..02308bffeee8 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -57,7 +57,7 @@ const ownerEmail = 'owner@gmail.com'; /** * Reads every rule in the `ONYXKEYS.COLLECTION.RULE` collection that belongs to the given policy and is - * not optimistically pending deletion. This mirrors what the app treats as the policy's live rule set. + * not optimistically pending deletion. */ async function getRulesCollection(): Promise> { let collection: OnyxCollection = {}; @@ -77,13 +77,13 @@ async function getActivePolicyRules(policyID: string): Promise { ); } -/** Build the index-keyed object shape the rules API uses for lists (`['a'] -> {'0': 'a'}`). */ +/** Build the index-keyed object shape the rules API uses for lists */ function indexMap(...values: T[]): Record { return Object.fromEntries(values.map((value, index) => [String(index), value])); } -/** Seed the two rules (submit -> forward, approve -> finalize) that describe a `submitters -> approver` workflow. */ -async function seedForwardApproveRules(policyID: string, submitters: string[], approver: string) { +/** Write the two rules (submit -> forward, approve -> finalize) that describe a `submitters -> approver` workflow. */ +async function createForwardApproveRules(policyID: string, submitters: string[], approver: string) { await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}rule1`, { scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, @@ -961,7 +961,7 @@ describe('actions/Workflow', () => { }; // Existing workflow: employee1 (A) and employee2 (B) submit to owner (C). - await seedForwardApproveRules(policyID, [employee1Email, employee2Email], ownerEmail); + await createForwardApproveRules(policyID, [employee1Email, employee2Email], ownerEmail); // New workflow: employee3 (D) submits to owner (C) — same chain as the existing workflow. const approvalWorkflow = { @@ -1064,7 +1064,7 @@ describe('actions/Workflow', () => { }; // Seed the existing [employee1] → [owner] workflow as rules in the collection. - await seedForwardApproveRules(policyID, [employee1Email], ownerEmail); + await createForwardApproveRules(policyID, [employee1Email], ownerEmail); const initialApprovalWorkflow = { members: [{email: employee1Email, displayName: employee1Email}], @@ -1114,7 +1114,7 @@ describe('actions/Workflow', () => { }; // Seed the existing [employee1] → [owner] workflow as rules in the collection. - await seedForwardApproveRules(policyID, [employee1Email], ownerEmail); + await createForwardApproveRules(policyID, [employee1Email], ownerEmail); const approvalWorkflow = { members: [{email: employee1Email, displayName: employee1Email}], From b92715cd0945168a0f00a7f7dbdcb7b965ffa6e8 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 3 Aug 2026 13:16:06 +0200 Subject: [PATCH 109/466] add selector --- src/libs/WorkflowUtils.ts | 21 ++++++++++++ .../workflows/WorkspaceWorkflowsPage.tsx | 6 +++- .../WorkspaceWorkflowsApprovalsEditPage.tsx | 9 ++++-- tests/unit/WorkflowUtilsTest.ts | 32 +++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 760f101d46eb..03082e1f8e72 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1387,6 +1387,26 @@ function getSubmitterRuleIDs(submitter: string, rules: Record, policyID: string | undefined): NonNullable> { + const result: NonNullable> = {}; + if (!rulesCollection || !policyID) { + return result; + } + + for (const [onyxKey, rule] of Object.entries(rulesCollection)) { + if (rule?.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { + continue; + } + result[onyxKey] = rule; + } + + return result; +} + /** * Convert the `ONYXKEYS.COLLECTION.RULE` collection into the `ruleID -> rule body` map used by the * builder, reconcilers and converters, keeping only non-deleted rules scoped to `policyID`. @@ -1613,6 +1633,7 @@ export { extractSubmitterEmails, getApprovalLimitDescription, getApprovalWorkflowRulesForPolicy, + filterRulesForPolicy, getRulesSubmitterToFirstApprover, getRulesSubmitterToWorkflowKey, getWorkflowMemberEmails, diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 5af3d1dfa509..6f92cd740e9b 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -70,6 +70,7 @@ import tokenizedSearch from '@libs/tokenizedSearch'; import { convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, + filterRulesForPolicy, getApprovalWorkflowRulesForPolicy, getEligibleExistingBusinessBankAccounts, INITIAL_APPROVAL_WORKFLOW, @@ -93,7 +94,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import type Rule from '@src/types/onyx/Rule'; +import type {OnyxCollection} from 'react-native-onyx'; import type {TupleToUnion, ValueOf} from 'type-fest'; import {hasSeenTourSelector} from '@selectors/Onboarding'; @@ -200,7 +203,8 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const {accountID: currentUserAccountID, email: currentUserEmail = '', login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const isUserReimburser = account?.primaryLogin !== undefined && (policy?.achAccount?.reimburser ?? policy?.owner) === account?.primaryLogin; const isMultipleApproversBetaEnabled = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS); - const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const policyRulesSelector = useCallback((rules: OnyxCollection) => filterRulesForPolicy(rules, route.params.policyID), [route.params.policyID]); + const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE, {selector: policyRulesSelector}); const {approvalWorkflows, availableMembers, usedApproverEmails} = useMemo(() => { const params = { policy, diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index b72fe2e00df8..d6974250588f 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -19,7 +19,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {canMemberWrite, goBackFromInvalidPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; -import {convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, getApprovalWorkflowRulesForPolicy} from '@libs/WorkflowUtils'; +import {convertApprovalWorkflowRulesToWorkflows, convertPolicyEmployeesToApprovalWorkflows, filterRulesForPolicy, getApprovalWorkflowRulesForPolicy} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -39,13 +39,15 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import type Rule from '@src/types/onyx/Rule'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; +import type {OnyxCollection} from 'react-native-onyx'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import ApprovalWorkflowEditor from './ApprovalWorkflowEditor'; @@ -57,7 +59,8 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const {translate, localeCompare} = useLocalize(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [approvalWorkflow, approvalWorkflowMetadata] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); - const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const policyRulesSelector = useCallback((rules: OnyxCollection) => filterRulesForPolicy(rules, route.params.policyID), [route.params.policyID]); + const [rulesCollection] = useOnyx(ONYXKEYS.COLLECTION.RULE, {selector: policyRulesSelector}); const isLoadingApprovalWorkflow = isLoadingOnyxValue(approvalWorkflowMetadata); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const {isBetaEnabled} = usePermissions(); diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index e160f46431fe..47bfe45e728a 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -10,6 +10,7 @@ import { convertApprovalWorkflowToPolicyEmployees, convertPolicyEmployeesToApprovalWorkflows, extractSubmitterEmails, + filterRulesForPolicy, getApprovalLimitDescription, getEligibleExistingBusinessBankAccounts, getOpenConnectedToPolicyBusinessBankAccounts, @@ -31,6 +32,7 @@ import type {BankAccountList} from '@src/types/onyx/BankAccount'; import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; +import type Rule from '@src/types/onyx/Rule'; import createRandomPolicy from '../utils/collections/policies'; import {buildPersonalDetails, convertToDisplayString, localeCompare, translateLocal} from '../utils/TestHelper'; @@ -2015,4 +2017,34 @@ describe('WorkflowUtils', () => { }); }); }); + + describe('filterRulesForPolicy', () => { + const ruleForPolicy = (scopeID: string, extra: Partial = {}): Rule => ({ + scope: CONST.RULES.SCOPE.POLICY, + scopeID, + triggers: {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT}, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, right: 'a@example.com'}, + actions: {'0': {name: CONST.RULES.APPROVAL_WORKFLOW.ACTION.FORWARD_TO, approver: 'b@example.com'}}, + ...extra, + }); + + it('keeps only the rules scoped to the given policy, preserving the collection keys', () => { + const mine = ruleForPolicy('policy1'); + const collection = {rules_1: mine, rules_2: ruleForPolicy('policy2')}; + + // The value is passed through by reference so callers still see the full rule. + expect(filterRulesForPolicy(collection, 'policy1')).toEqual({rules_1: mine}); + }); + + it('keeps rules pending deletion, leaving that decision to the caller', () => { + const pendingDelete = ruleForPolicy('policy1', {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}); + + expect(filterRulesForPolicy({rules_1: pendingDelete}, 'policy1')).toEqual({rules_1: pendingDelete}); + }); + + it('returns an empty collection when there is no policy or no rules', () => { + expect(filterRulesForPolicy({rules_1: ruleForPolicy('policy1')}, undefined)).toEqual({}); + expect(filterRulesForPolicy(undefined, 'policy1')).toEqual({}); + }); + }); }); From c6fe293d85b9f03a333b7860595d247e24e0402d Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:05:51 +0700 Subject: [PATCH 110/466] fix: use TRACK instead of SUBMIT for Teachers Unite expenses --- .../SearchMoneyRequestReportEmptyState.tsx | 7 +++-- src/libs/ReportUtils.ts | 29 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx index 91c7c4e72567..902ee6bf6d31 100644 --- a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx +++ b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx @@ -6,7 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {canAddTransaction, isArchivedReport} from '@libs/ReportUtils'; +import {canAddTransaction, isArchivedReport, isTeachersUniteReport} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {cancelSpan} from '@libs/telemetry/activeSpans'; @@ -44,6 +44,7 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const isReportArchived = isArchivedReport(reportNameValuePairs); const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); const canAddTransactionToReport = canAddTransaction(report, isReportArchived); + const moneyRequestIOUType = isTeachersUniteReport(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; const addExpenseDropdownOptions = [ { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, @@ -57,7 +58,7 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } - startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs); + startMoneyRequest(moneyRequestIOUType, reportId, draftTransactionIDs); }, }, { @@ -72,7 +73,7 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } - startDistanceRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs, lastDistanceExpenseType); + startDistanceRequest(moneyRequestIOUType, reportId, draftTransactionIDs, lastDistanceExpenseType); }, }, { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f24300301ed1..3f4ed04fe100 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3146,6 +3146,7 @@ function getAddExpenseDropdownOptions({ lastDistanceExpenseType, currentUserAccountID, }: GetAddExpenseDropdownOptionsParams): Array>> { + const moneyRequestIOUType = isTeachersUnitePolicyID(policy?.id) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; return [ { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, @@ -3164,7 +3165,7 @@ function getAddExpenseDropdownOptions({ Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } - startMoneyRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); + startMoneyRequest(moneyRequestIOUType, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); }, }, { @@ -3180,7 +3181,7 @@ function getAddExpenseDropdownOptions({ Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } - startDistanceRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); + startDistanceRequest(moneyRequestIOUType, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); }, }, { @@ -10336,6 +10337,19 @@ function isGroupChatAdmin(report: OnyxEntry, accountID: number) { return participant?.role === CONST.REPORT.ROLE.ADMIN; } +/** + * The Teachers Unite workspace doesn't allow submitting expenses for reimbursement (CONST.IOU.TYPE.SUBMIT), + * only tracking them (CONST.IOU.TYPE.TRACK). + */ +function isTeachersUnitePolicyID(policyID: string | undefined): boolean { + const teacherUnitePolicyID = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; + return policyID === teacherUnitePolicyID; +} + +function isTeachersUniteReport(report: OnyxEntry): boolean { + return isTeachersUnitePolicyID(report?.policyID); +} + /** * Helper method to define what expense options we want to show for particular method. * There are 4 expense options: Submit, Split, Pay and Track expense: @@ -10371,8 +10385,7 @@ function getMoneyRequestOptions( isRestrictedToPreferredPolicy = false, currentUserAccountID?: number, ): IOUType[] { - const teacherUnitePolicyID = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; - const isTeachersUniteReport = report?.policyID === teacherUnitePolicyID; + const isTeachersUniteReportValue = isTeachersUniteReport(report); // In any thread, task report or trip room, we do not allow any new expenses if (isChatThread(report) || isTaskReport(report) || isInvoiceReport(report) || isSystemChat(report) || isReportArchived || isTripRoom(report)) { @@ -10405,7 +10418,7 @@ function getMoneyRequestOptions( if (canRequestMoney(report, policy, otherParticipants, currentUserAccountID)) { // For Teachers Unite policy, don't show Create Expense option - if (!isTeachersUniteReport) { + if (!isTeachersUniteReportValue) { options = [...options, CONST.IOU.TYPE.SUBMIT]; if (!filterDeprecatedTypes) { options = [...options, CONST.IOU.TYPE.REQUEST]; @@ -10419,7 +10432,7 @@ function getMoneyRequestOptions( } // For expense reports on Teachers Unite workspace, disable "Create report" option - if (isExpenseReport(report) && report?.policyID === teacherUnitePolicyID) { + if (isExpenseReport(report) && isTeachersUniteReportValue) { options = options.filter((option) => option !== CONST.IOU.TYPE.SUBMIT); } @@ -10432,7 +10445,7 @@ function getMoneyRequestOptions( (isChatRoom(report) && !isAnnounceRoom(report) && otherParticipants.length > 0) || (isDM(report) && otherParticipants.length > 0) || (isGroupChat(report) && otherParticipants.length > 0) || - (isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat && isTeachersUniteReport) + (isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat && isTeachersUniteReportValue) ) { options = [...options, CONST.IOU.TYPE.SPLIT]; } @@ -14139,6 +14152,8 @@ export { isSettled, isSystemChat, isTaskReport, + isTeachersUnitePolicyID, + isTeachersUniteReport, isThread, isTrackExpenseReport, isUnread, From c2b1379f125013bdfd30d1b31fa64f7ffccdbd26 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:49:25 +0700 Subject: [PATCH 111/466] update use getMoneyRequestIOUType --- .../SearchMoneyRequestReportEmptyState.tsx | 4 ++-- src/libs/ReportUtils.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx index 902ee6bf6d31..b2b5087d6c1a 100644 --- a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx +++ b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx @@ -6,7 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {canAddTransaction, isArchivedReport, isTeachersUniteReport} from '@libs/ReportUtils'; +import {canAddTransaction, getMoneyRequestIOUType, isArchivedReport} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {cancelSpan} from '@libs/telemetry/activeSpans'; @@ -44,7 +44,7 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const isReportArchived = isArchivedReport(reportNameValuePairs); const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); const canAddTransactionToReport = canAddTransaction(report, isReportArchived); - const moneyRequestIOUType = isTeachersUniteReport(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; + const moneyRequestIOUType = getMoneyRequestIOUType(report.policyID); const addExpenseDropdownOptions = [ { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3f4ed04fe100..e8c8003366b0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3146,7 +3146,7 @@ function getAddExpenseDropdownOptions({ lastDistanceExpenseType, currentUserAccountID, }: GetAddExpenseDropdownOptionsParams): Array>> { - const moneyRequestIOUType = isTeachersUnitePolicyID(policy?.id) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; + const moneyRequestIOUType = getMoneyRequestIOUType(policy?.id); return [ { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, @@ -10350,6 +10350,14 @@ function isTeachersUniteReport(report: OnyxEntry): boolean { return isTeachersUnitePolicyID(report?.policyID); } +/** + * The Teachers Unite workspace doesn't support reimbursement, so "Add expense" entry points must start a + * TRACK request instead of a SUBMIT request when targeting a report on that policy. + */ +function getMoneyRequestIOUType(policyID: string | undefined): IOUType { + return isTeachersUnitePolicyID(policyID) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; +} + /** * Helper method to define what expense options we want to show for particular method. * There are 4 expense options: Submit, Split, Pay and Track expense: @@ -14053,6 +14061,7 @@ export { getRouteFromLink, canDeleteCardTransactionByLiabilityType, getAddExpenseDropdownOptions, + getMoneyRequestIOUType, getTaskAssigneeChatOnyxData, getTransactionDetails, getTransactionReportName, @@ -14152,8 +14161,6 @@ export { isSettled, isSystemChat, isTaskReport, - isTeachersUnitePolicyID, - isTeachersUniteReport, isThread, isTrackExpenseReport, isUnread, From 0e6b2959ad7596fd240a9a0cff548ec118957ede Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:57:29 +0700 Subject: [PATCH 112/466] update use iouReport --- src/libs/ReportUtils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e8c8003366b0..daa155e86d01 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3146,7 +3146,10 @@ function getAddExpenseDropdownOptions({ lastDistanceExpenseType, currentUserAccountID, }: GetAddExpenseDropdownOptionsParams): Array>> { - const moneyRequestIOUType = getMoneyRequestIOUType(policy?.id); + // The iouReport being added to may belong to a different policy than the chat surrounding it is rendered in + // (e.g. a Teachers Unite expense report preview shown inside a non-Teachers-Unite chat), so derive the IOU + // type from the iouReport's own policy rather than from the surrounding `policy`. + const moneyRequestIOUType = getMoneyRequestIOUType(getReportOrDraftReport(iouReportID)?.policyID ?? policy?.id); return [ { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, From 1b513ce44f26852d584753146a80449a371a96e0 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 5 Aug 2026 11:54:16 +0200 Subject: [PATCH 113/466] delete workflow fix --- src/libs/actions/Workflow.ts | 13 ++++-- .../WorkspaceWorkflowsApprovalsEditPage.tsx | 7 ++- tests/actions/WorkflowTest.ts | 45 +++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index e2996eb040fb..255a4044c07b 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -414,17 +414,24 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); } -/** Delete an approval workflow using the rules-based backend structure. */ -function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry, rules: OnyxCollection) { +/** + * Delete an approval workflow using the rules-based backend structure. + */ +function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry, rules: OnyxCollection): boolean { if (!policy) { - return; + return false; } const existingRules = getApprovalWorkflowRulesForPolicy(rules, policy.id); const memberEmails = getWorkflowMemberEmails(approvalWorkflow.members); const rulesDiff = reconcileApprovalWorkflowRulesForRemove(memberEmails, {existingRules}); + if (isEmptyObject(rulesDiff)) { + return false; + } + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); + return true; } /** Set the members of the approval workflow that is currently edited */ diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index 97c564cf8a4c..8659b3d2754d 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -112,11 +112,10 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true Navigation.dismissModal({ afterTransition: () => { // Remove the approval workflow using the initial data as it could be already edited - if (useRulesBackend) { - removeApprovalWorkflowRules(initialApprovalWorkflow, policy, rulesCollection); - } else { - removeApprovalWorkflow(initialApprovalWorkflow, policy); + if (useRulesBackend && removeApprovalWorkflowRules(initialApprovalWorkflow, policy, rulesCollection)) { + return; } + removeApprovalWorkflow(initialApprovalWorkflow, policy); }, }); }; diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 02308bffeee8..071888aba243 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -1140,5 +1140,50 @@ describe('actions/Workflow', () => { await mockFetch.resume(); await waitForBatchedUpdates(); }); + + it('returns false when the workflow only exists in the employee list so the caller can fall back', async () => { + mockFetch.pause(); + + const policyID = '987654321'; + const policy: Policy = { + ...createRandomPolicy(2), + id: policyID, + owner: ownerEmail, + approver: ownerEmail, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + employeeList: { + [employee1Email]: {email: employee1Email, forwardsTo: '', role: CONST.POLICY.ROLE.USER, submitsTo: employee2Email}, + [employee2Email]: {email: employee2Email, forwardsTo: '', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, + }, + rules: {}, + }; + + const approvalWorkflow = { + members: [{email: employee1Email, displayName: employee1Email}], + approvers: [{email: employee2Email, displayName: employee2Email, isCircularReference: false}], + availableMembers: [], + usedApproverEmails: [], + isDefault: false, + action: 'remove', + originalApprovers: [], + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); + await waitForBatchedUpdates(); + + expect(removeApprovalWorkflowRules(approvalWorkflow, policy, await getRulesCollection())).toBe(false); + await waitForBatchedUpdates(); + + // Falling back to the employee-list command detaches the member from the removed approver. + removeApprovalWorkflow(approvalWorkflow, policy); + await waitForBatchedUpdates(); + + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + expect(updatedPolicy?.employeeList?.[employee1Email]?.submitsTo).not.toBe(employee2Email); + + await mockFetch.resume(); + await waitForBatchedUpdates(); + }); }); }); From 188f07c1f44b7bb3494763e8e6d6f3152823abfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski=20=28via=20MelvinBot=29?= Date: Wed, 5 Aug 2026 12:01:53 +0000 Subject: [PATCH 114/466] Fix: return to Agents list when tapping back on a newly created agent DM (narrow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Jasikowski --- src/pages/settings/Agents/AddAgentPage.tsx | 4 +++- tests/unit/pages/settings/AddAgentPageTest.tsx | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/AddAgentPage.tsx index d48ef8c1370d..9893a9ae97a6 100644 --- a/src/pages/settings/Agents/AddAgentPage.tsx +++ b/src/pages/settings/Agents/AddAgentPage.tsx @@ -128,7 +128,9 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { if (getIsNarrowLayout()) { // Reveal the DM under the modal before dismissing so we navigate directly to it in one animation, // instead of dismissing to the agents list first and navigating to the DM afterward. - Navigation.revealRouteBeforeDismissingModal(ROUTES.REPORT_WITH_ID.getRoute(optimisticReportID)); + // backTo points the DM header's back button at the agents list; without it, dismissing the whole + // settings modal stack leaves the list off the back stack, so back would fall through to the LHN. + Navigation.revealRouteBeforeDismissingModal(ROUTES.REPORT_WITH_ID.getRoute(optimisticReportID, undefined, undefined, ROUTES.SETTINGS_AGENTS)); return; } diff --git a/tests/unit/pages/settings/AddAgentPageTest.tsx b/tests/unit/pages/settings/AddAgentPageTest.tsx index 67fdedb05983..b90d2a37179c 100644 --- a/tests/unit/pages/settings/AddAgentPageTest.tsx +++ b/tests/unit/pages/settings/AddAgentPageTest.tsx @@ -319,7 +319,7 @@ describe('AddAgentPage', () => { expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined, undefined); expect(mockClearNewAgentTemplate).toHaveBeenCalledTimes(1); expect(mockClearNewAgentAvatarDraft).toHaveBeenCalledTimes(1); - expect(mockRevealRouteBeforeDismissingModal).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(OPTIMISTIC_REPORT_ID)); + expect(mockRevealRouteBeforeDismissingModal).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(OPTIMISTIC_REPORT_ID, undefined, undefined, ROUTES.SETTINGS_AGENTS)); }); it('forwards policyID from route params to createAgent', () => { @@ -328,7 +328,7 @@ describe('AddAgentPage', () => { mockFormOnSubmit?.({firstName: 'Bot', prompt: 'Reject gambling.'}); expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined, 'POL_42'); - expect(mockRevealRouteBeforeDismissingModal).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(OPTIMISTIC_REPORT_ID)); + expect(mockRevealRouteBeforeDismissingModal).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(OPTIMISTIC_REPORT_ID, undefined, undefined, ROUTES.SETTINGS_AGENTS)); }); it('opens the DM in the RHP on wide layouts instead of the fullscreen report', () => { From 47627c31757fd6ff110ee59d7a66e25775950135 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Wed, 5 Aug 2026 19:56:41 +0000 Subject: [PATCH 115/466] Squash line breaks to a single line when saving report field text Co-authored-by: mkhutornyi --- src/pages/EditReportFieldText.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/pages/EditReportFieldText.tsx b/src/pages/EditReportFieldText.tsx index db9f719eaa46..a3c2faf98f2a 100644 --- a/src/pages/EditReportFieldText.tsx +++ b/src/pages/EditReportFieldText.tsx @@ -66,11 +66,23 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f [fieldName, fieldKey, isRequired, translate, fieldList], ); + const handleSubmit = useCallback( + (values: FormOnyxValues) => { + // The input can grow to multiple lines while editing, but report titles and text fields are single-line + // values. Squash any line breaks the user entered down to a single line before saving. + onSubmit({ + ...values, + [fieldKey]: values[fieldKey]?.replaceAll(/[\r\n]+/g, ' '), + }); + }, + [fieldKey, onSubmit], + ); + return ( Date: Thu, 6 Aug 2026 11:02:54 +0200 Subject: [PATCH 116/466] Better comparison handling --- src/libs/WorkflowUtils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 03082e1f8e72..9f1a07a4f5c4 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1425,7 +1425,11 @@ function getApprovalWorkflowRulesForPolicy(rulesCollection: OnyxCollection continue; } const ruleID = onyxKey.slice(ONYXKEYS.COLLECTION.RULE.length); - result[ruleID] = {triggers: rule.triggers, filters: rule.filters, actions: rule.actions}; + result[ruleID] = { + triggers: toIndexMap(Object.values(rule.triggers ?? {})), + filters: rule.filters, + actions: toIndexMap(Object.values(rule.actions ?? {})), + }; } return result; From 7e66be4f57312ddb57cadffaf47399318a0c8572 Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Thu, 6 Aug 2026 11:02:55 +0000 Subject: [PATCH 117/466] Use StringUtils.lineBreaksToSpaces to squash line breaks on save Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com> --- src/pages/EditReportFieldText.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/EditReportFieldText.tsx b/src/pages/EditReportFieldText.tsx index a3c2faf98f2a..0517949c61bb 100644 --- a/src/pages/EditReportFieldText.tsx +++ b/src/pages/EditReportFieldText.tsx @@ -9,6 +9,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {hasCircularReferences} from '@libs/Formula'; import type {FieldList} from '@libs/Formula'; +import StringUtils from '@libs/StringUtils'; import variables from '@styles/variables'; @@ -72,7 +73,7 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f // values. Squash any line breaks the user entered down to a single line before saving. onSubmit({ ...values, - [fieldKey]: values[fieldKey]?.replaceAll(/[\r\n]+/g, ' '), + [fieldKey]: StringUtils.lineBreaksToSpaces(values[fieldKey]), }); }, [fieldKey, onSubmit], From fe6409ca29e8677549609ad0b65a1ea342243252 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 6 Aug 2026 14:01:46 +0200 Subject: [PATCH 118/466] fix for overLimit approver --- src/libs/WorkflowUtils.ts | 264 ++++++++++++++++++-------------- tests/unit/WorkflowUtilsTest.ts | 51 +++++- 2 files changed, 194 insertions(+), 121 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 9f1a07a4f5c4..1cf2109d1c19 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -809,56 +809,72 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva const fromComparison = buildSubmitterFilter(memberEmails); const rules: ApprovalWorkflowRule[] = []; - // On submission, route the report to the first approver. - rules.push({ - triggers: buildSubmitTriggers(), - filters: fromComparison, - actions: buildForwardActions(firstApproverEmail), + /** + * An approver's approval limit decides who holds the report at that position, not where it goes afterwards: a + * report over the limit is received by `overLimitForwardsTo` *instead of* the approver, and then carries on to the + * next approver as normal. So each position has one or two people who can hold it, and the hop into the position + * is what branches on the amount. + */ + const holdersAtPosition = approvers.map((approver) => { + const hasLimitSplit = !!approver.approvalLimit && approver.approvalLimit > 0 && !!approver.overLimitForwardsTo; + return { + underLimitEmail: approver.email, + overLimitEmail: hasLimitSplit ? approver.overLimitForwardsTo : undefined, + limit: hasLimitSplit ? approver.approvalLimit : undefined, + }; }); - // For each approver, describe what happens after THEY approve (gated on `to == approver`). - for (let i = 0; i < approvers.length; i++) { - const approver = approvers.at(i); - if (!approver?.email) { - continue; + /** The rules routing a report into `position`, from either submission or the approvers at the position before. */ + const buildRulesIntoPosition = (position: number): ApprovalWorkflowRule[] => { + const holders = holdersAtPosition.at(position); + if (!holders?.underLimitEmail) { + return []; } - const nextApproverEmail = approvers.at(i + 1)?.email; - const toComparison = buildToComparison(approver.email); + const isFirstPosition = position === 0; + const triggers = isFirstPosition ? buildSubmitTriggers() : buildApproveTriggers(); - const limitSplit = - approver.approvalLimit && approver.approvalLimit > 0 && approver.overLimitForwardsTo - ? {limit: approver.approvalLimit, overLimitForwardsTo: approver.overLimitForwardsTo} - : undefined; + // Everyone who can hold the previous position routes onward the same way, so each of them needs a rule. + const previousHolders = isFirstPosition ? [undefined] : [holdersAtPosition.at(position - 1)?.underLimitEmail, holdersAtPosition.at(position - 1)?.overLimitEmail]; - if (limitSplit) { - const underAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limitSplit.limit); - const overAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, limitSplit.limit); + const rulesIntoPosition: ApprovalWorkflowRule[] = []; + for (const previousHolderEmail of previousHolders) { + if (!isFirstPosition && !previousHolderEmail) { + continue; + } - // Under the limit: continue the chain, or approve if this is the last approver. - rules.push({ - triggers: buildApproveTriggers(), - filters: buildAnd(fromComparison, buildAnd(toComparison, underAmount)), - actions: nextApproverEmail ? buildForwardActions(nextApproverEmail) : buildApproveActions(), - }); - // Over the limit: escalate to the over-limit approver, who then finalizes the report. - rules.push({ - triggers: buildApproveTriggers(), - filters: buildAnd(fromComparison, buildAnd(toComparison, overAmount)), - actions: buildForwardActions(limitSplit.overLimitForwardsTo), - }); - rules.push({ - triggers: buildApproveTriggers(), - filters: buildAnd(fromComparison, buildToComparison(limitSplit.overLimitForwardsTo)), - actions: buildApproveActions(), - }); - } else { - rules.push({ - triggers: buildApproveTriggers(), - filters: buildAnd(fromComparison, toComparison), - actions: nextApproverEmail ? buildForwardActions(nextApproverEmail) : buildApproveActions(), - }); + const source = previousHolderEmail ? buildAnd(fromComparison, buildToComparison(previousHolderEmail)) : fromComparison; + + if (holders.overLimitEmail == null || holders.limit == null) { + rulesIntoPosition.push({triggers, filters: source, actions: buildForwardActions(holders.underLimitEmail)}); + continue; + } + + const underAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, holders.limit); + const overAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, holders.limit); + + rulesIntoPosition.push({triggers, filters: buildAnd(source, underAmount), actions: buildForwardActions(holders.underLimitEmail)}); + rulesIntoPosition.push({triggers, filters: buildAnd(source, overAmount), actions: buildForwardActions(holders.overLimitEmail)}); + } + + return rulesIntoPosition; + }; + + for (let position = 0; position < holdersAtPosition.length; position++) { + rules.push(...buildRulesIntoPosition(position)); + } + + // Whoever holds the last position finalizes the report. + const lastHolders = holdersAtPosition.at(-1); + for (const lastHolderEmail of [lastHolders?.underLimitEmail, lastHolders?.overLimitEmail]) { + if (!lastHolderEmail) { + continue; } + rules.push({ + triggers: buildApproveTriggers(), + filters: buildAnd(fromComparison, buildToComparison(lastHolderEmail)), + actions: buildApproveActions(), + }); } // Different approvers may share an `overLimitForwardsTo`, which would emit identical terminal rules. @@ -1235,81 +1251,87 @@ function getForwardApprover(rule: ApprovalWorkflowRule): string | undefined { * we fall back to the older `submitsTo` field on the employee. */ function resolveFirstApprover(submitter: string, rules: Record, employees: PolicyEmployeeList): string | undefined { - for (const rule of Object.values(rules)) { - if (!isSubmitRule(rule) || !extractSubmitterEmails(rule).includes(submitter)) { - continue; - } - const approver = getForwardApprover(rule); - if (approver) { - return approver; - } + const firstPosition = resolvePositionHolders(submitter, rules, undefined); + if (firstPosition?.underLimitEmail) { + return firstPosition.underLimitEmail; } return employees[submitter]?.submitsTo; } -/** What happens after `currentApprover` approves a report from `submitter`. */ -type AfterApproveInfo = { - /** The next approver in the chain, from the normal / under-limit `ForwardTo`. `undefined` means the report is approved (terminal). */ - nextEmail?: string; - /** `currentApprover`'s own approval limit, present only when there is an over-limit split. */ +/** Who can hold one position in a submitter's approval chain. */ +type PositionHolders = { + /** Who holds the position for a report under the limit, or for any report when there is no limit. */ + underLimitEmail?: string; + + /** Who holds it instead once the report reaches the limit. */ + overLimitEmail?: string; + + /** The limit that decides between the two. */ approvalLimit?: number; - /** `currentApprover`'s own over-limit target, present only when there is an over-limit split. */ - overLimitForwardsTo?: string; + + /** True when the routing rules approve the report at this point rather than passing it on. */ + isTerminal: boolean; }; /** - * Resolve what the rules say happens after `currentApprover` approves a report from `submitter`, by - * inspecting every rule gated on `to == currentApprover`. Returns `undefined` when no rule covers - * this approver (so callers can fall back to `employeeList`). + * Read who holds the position a report reaches from `sourceApprover` — or from submission when `sourceApprover` is + * undefined. + * + * An approval limit belongs to the position it guards, not to the approver before it: the rules routing into a + * position carry the amount split, so a report at or over the limit is received by `overLimitEmail` *instead of* + * `underLimitEmail`. Returns undefined when no rule covers this step, so callers can fall back to `employeeList`. */ -function resolveAfterApprove(submitter: string, currentApprover: string, rules: Record): AfterApproveInfo | undefined { - let hasNormalBranch = false; - let normalIsTerminal = false; - let normalNextEmail: string | undefined; - let overLimitForwardsTo: string | undefined; - let approvalLimit: number | undefined; +function resolvePositionHolders(submitter: string, rules: Record, sourceApprover: string | undefined): PositionHolders | undefined { + const holders: PositionHolders = {isTerminal: false}; + let didMatchRule = false; for (const rule of Object.values(rules)) { if (!extractSubmitterEmails(rule).includes(submitter)) { continue; } + + const isFromSubmission = sourceApprover === undefined; + if (isFromSubmission !== isSubmitRule(rule)) { + continue; + } + const toLeaf = findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO); - if (!toLeaf || toLeaf.right !== currentApprover) { + if (isFromSubmission ? !!toLeaf : toLeaf?.right !== sourceApprover) { + continue; + } + + didMatchRule = true; + + if (isApproveReportRule(rule)) { + holders.isTerminal = true; continue; } const amountLeaf = findComparisonByLeft(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT); - const isOverLimit = + const isOverLimitBranch = !!amountLeaf && (amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO || amountLeaf.operator === CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN); if (amountLeaf && typeof amountLeaf.right === 'number') { - approvalLimit = approvalLimit ?? amountLeaf.right; + holders.approvalLimit = holders.approvalLimit ?? amountLeaf.right; } - if (isOverLimit) { - overLimitForwardsTo = getForwardApprover(rule) ?? overLimitForwardsTo; - continue; - } - - // Normal branch: either a no-amount rule or the under-limit half of a split. - hasNormalBranch = true; - if (isApproveReportRule(rule)) { - normalIsTerminal = true; + if (isOverLimitBranch) { + holders.overLimitEmail = getForwardApprover(rule) ?? holders.overLimitEmail; } else { - normalNextEmail = getForwardApprover(rule) ?? normalNextEmail; + holders.underLimitEmail = getForwardApprover(rule) ?? holders.underLimitEmail; } } - if (!hasNormalBranch && overLimitForwardsTo === undefined) { + if (!didMatchRule) { return undefined; } - const hasSplit = overLimitForwardsTo !== undefined; - return { - nextEmail: normalIsTerminal ? undefined : normalNextEmail, - approvalLimit: hasSplit ? approvalLimit : undefined, - overLimitForwardsTo, - }; + // A limit only means anything when both halves of the split are known. + if (!holders.overLimitEmail) { + holders.approvalLimit = undefined; + } + + return holders; } type BuildApproverChainFromRulesParams = { @@ -1326,45 +1348,57 @@ type BuildApproverChainFromRulesParams = { * Any step the rules don't cover falls back to the legacy `employeeList` fields. */ function buildApproverChainFromRules({submitter, rules, employees, personalDetailsByEmail}: BuildApproverChainFromRulesParams): Approver[] { - let currentEmail: string | undefined = resolveFirstApprover(submitter, rules, employees); - if (!currentEmail) { - return []; - } - - const chain: Approver[] = []; + // Walk the positions forward first: an approver's own row needs to name the next position's holder, which is only + // known once that position has been read. + const positions: PositionHolders[] = []; const seenEmails = new Set(); + let sourceApprover: string | undefined; + let isFirstPosition = true; - while (currentEmail) { - const isCircularReference = seenEmails.has(currentEmail); - const employee: PolicyEmployee | undefined = employees[currentEmail]; + while (true) { + const holders = resolvePositionHolders(submitter, rules, sourceApprover); - const after = resolveAfterApprove(submitter, currentEmail, rules); - const approvalLimit: number | null = after?.approvalLimit ?? employee?.approvalLimit ?? null; - const overLimitForwardsTo: string | undefined = after?.overLimitForwardsTo ?? employee?.overLimitForwardsTo; - // When a rule covers this approver, trust its next hop (possibly terminal). Otherwise fall back. - const forwardsTo: string | undefined = after ? after.nextEmail : employee?.forwardsTo; + // Nothing covers this step, so fall back to the legacy employeeList fields for it. + const fallbackEmail = isFirstPosition ? employees[submitter]?.submitsTo : employees[sourceApprover ?? '']?.forwardsTo; + const nextEmail = holders?.isTerminal ? undefined : (holders?.underLimitEmail ?? (holders ? undefined : fallbackEmail)); + if (!nextEmail) { + break; + } - chain.push({ - email: currentEmail, - forwardsTo, - avatar: personalDetailsByEmail[currentEmail]?.avatar, - displayName: personalDetailsByEmail[currentEmail]?.displayName ?? currentEmail, - isCircularReference, - approvalLimit, - overLimitForwardsTo, - overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(overLimitForwardsTo, personalDetailsByEmail), - pendingAction: employee?.pendingAction, - errors: employee?.errors, + const fallbackEmployee: PolicyEmployee | undefined = employees[nextEmail]; + positions.push({ + isTerminal: false, + underLimitEmail: nextEmail, + overLimitEmail: holders?.overLimitEmail ?? (holders ? undefined : fallbackEmployee?.overLimitForwardsTo), + approvalLimit: holders?.approvalLimit ?? (holders ? undefined : (fallbackEmployee?.approvalLimit ?? undefined)), }); - if (isCircularReference || !forwardsTo) { + if (seenEmails.has(nextEmail)) { break; } - seenEmails.add(currentEmail); - currentEmail = forwardsTo; + seenEmails.add(nextEmail); + sourceApprover = nextEmail; + isFirstPosition = false; } - return chain; + return positions.map((position, index) => { + const email = position.underLimitEmail ?? ''; + const employee: PolicyEmployee | undefined = employees[email]; + const isCircularReference = positions.findIndex((other) => other.underLimitEmail === email) !== index; + + return { + email, + forwardsTo: isCircularReference ? undefined : positions.at(index + 1)?.underLimitEmail, + avatar: personalDetailsByEmail[email]?.avatar, + displayName: personalDetailsByEmail[email]?.displayName ?? email, + isCircularReference, + approvalLimit: position.approvalLimit ?? null, + overLimitForwardsTo: position.overLimitEmail, + overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(position.overLimitEmail, personalDetailsByEmail), + pendingAction: employee?.pendingAction, + errors: employee?.errors, + }; + }); } /** Structural identity of a chain — used to fold submitters with identical chains into one workflow. */ diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 47bfe45e728a..cc92ca8d728d 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1729,17 +1729,37 @@ describe('WorkflowUtils', () => { const underAmount = {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 50000}; const overAmount = {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 50000}; + // The limit decides who *receives* the report, so the amount split is on the way in. Both possible + // holders then finalize it, because this is the last position. expect(buildApprovalWorkflowRules(workflow)).toEqual([ - {triggers: submitTriggers, filters: buildFromFilter(['2@example.com']), actions: forwardActions('1@example.com')}, - // Under the limit and last approver → approve. - {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), and(buildToFilter('1@example.com'), underAmount)), actions: approveActions}, - // Over the limit → escalate to the over-limit approver. - {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), and(buildToFilter('1@example.com'), overAmount)), actions: forwardActions('3@example.com')}, - // The over-limit approver finalizes the report. + {triggers: submitTriggers, filters: and(buildFromFilter(['2@example.com']), underAmount), actions: forwardActions('1@example.com')}, + {triggers: submitTriggers, filters: and(buildFromFilter(['2@example.com']), overAmount), actions: forwardActions('3@example.com')}, + {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), buildToFilter('1@example.com')), actions: approveActions}, {triggers: approveTriggers, filters: and(buildFromFilter(['2@example.com']), buildToFilter('3@example.com')), actions: approveActions}, ]); }); + it('Should route an over-limit report to the over-limit approver instead of the limited one, then continue the chain', () => { + // Hola Dola approves up to 100.00; above that "a a" receives the report instead. Either way it carries + // on to the second approver afterwards. + const workflow: ApprovalWorkflow = { + members: [buildMember(2)], + approvers: [buildApprover(1, {approvalLimit: 10000, overLimitForwardsTo: '3@example.com'}), buildApprover(4)], + isDefault: false, + }; + const underAmount = {operator: CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}; + const overAmount = {operator: CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, right: 10000}; + const from = buildFromFilter(['2@example.com']); + + expect(buildApprovalWorkflowRules(workflow)).toEqual([ + {triggers: submitTriggers, filters: and(from, underAmount), actions: forwardActions('1@example.com')}, + {triggers: submitTriggers, filters: and(from, overAmount), actions: forwardActions('3@example.com')}, + {triggers: approveTriggers, filters: and(from, buildToFilter('1@example.com')), actions: forwardActions('4@example.com')}, + {triggers: approveTriggers, filters: and(from, buildToFilter('3@example.com')), actions: forwardActions('4@example.com')}, + {triggers: approveTriggers, filters: and(from, buildToFilter('4@example.com')), actions: approveActions}, + ]); + }); + it('Should ignore a limit split when the limit is not positive', () => { const workflow: ApprovalWorkflow = { members: [buildMember(2)], @@ -1971,6 +1991,25 @@ describe('WorkflowUtils', () => { expect(firstApprover?.overLimitForwardsTo).toBe('3@example.com'); }); + it('Should round-trip a limited first approver back into the same workflow', () => { + const workflow: ApprovalWorkflow = { + members: [buildMember(2)], + approvers: [buildApprover(1, {approvalLimit: 10000, overLimitForwardsTo: '3@example.com'}), buildApprover(4)], + isDefault: false, + }; + const rules = keyRules(buildApprovalWorkflowRules(workflow)); + const employees: PolicyEmployeeList = {'2@example.com': {email: '2@example.com', submitsTo: '1@example.com'}}; + const policy = createPolicy(employees, '1@example.com'); + + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules}); + + const approvers = approvalWorkflows.at(0)?.approvers ?? []; + expect(approvers.map((approver) => approver.email)).toEqual(['1@example.com', '4@example.com']); + expect(approvers.at(0)?.approvalLimit).toBe(10000); + expect(approvers.at(0)?.overLimitForwardsTo).toBe('3@example.com'); + expect(approvers.at(1)?.approvalLimit).toBeNull(); + }); + it('Should round-trip a workflow through rules and back', () => { const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); const employees: PolicyEmployeeList = {'5@example.com': {email: '5@example.com', submitsTo: '1@example.com'}}; From 14eaf3b454c92206c58ed730e07c5495fbecd813 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Thu, 6 Aug 2026 13:02:27 +0000 Subject: [PATCH 119/466] Bundle single-line auto-grow into an autoGrowSingleLine flag Replace the per-page autoGrowHeight/maxAutoGrowHeight props with a single autoGrowSingleLine flag on BaseTextInput. The flag renders the input via the multiline (wrapping) path but keeps single-line return-key semantics: it submits instead of inserting a line break on every platform, fixing Enter not submitting and iOS never submitting on touch devices. Adds unit tests for StringUtils.lineBreaksToSpaces. Co-authored-by: mkhutornyi --- src/components/Form/InputWrapper.tsx | 7 +- src/components/Form/types.ts | 1 + .../TextInput/BaseTextInput/index.tsx | 68 ++++++++++++++++++- .../TextInput/BaseTextInput/types.ts | 7 ++ src/pages/EditReportFieldText.tsx | 7 +- tests/unit/StringUtilsTest.ts | 28 ++++++++ 6 files changed, 110 insertions(+), 8 deletions(-) diff --git a/src/components/Form/InputWrapper.tsx b/src/components/Form/InputWrapper.tsx index 2c094d04673d..a2c2925035d7 100644 --- a/src/components/Form/InputWrapper.tsx +++ b/src/components/Form/InputWrapper.tsx @@ -31,10 +31,11 @@ function computeComponentSpecificRegistrationParams({ shouldSubmitForm, multiline, autoGrowHeight, + autoGrowSingleLine, submitBehavior, }: InputComponentBaseProps): ComputedComponentSpecificRegistrationParams { if (textInputBasedComponents.has(InputComponent)) { - const isEffectivelyMultiline = !!multiline || !!autoGrowHeight; + const isEffectivelyMultiline = !!multiline || !!autoGrowHeight || !!autoGrowSingleLine; // If the user can use the hardware keyboard, they have access to an alternative way of inserting a new line // (like a Shift+Enter keyboard shortcut). For simplicity, we assume that when there's no touch screen, it's a @@ -44,7 +45,9 @@ function computeComponentSpecificRegistrationParams({ // We want to avoid a situation when the user can't insert a new line. For single-line inputs, it's not a problem and we // force-enable form submission. For multi-line inputs, ensure that it was requested to enable form submission for this specific // input and that alternative ways exist to add a new line. - const shouldReallySubmitForm = isEffectivelyMultiline ? !!shouldSubmitForm && canUseHardwareKeyboard : true; + // `autoGrowSingleLine` inputs only render as multi-line so their value can wrap - a new line is never a valid + // value there, so nothing has to be reserved for inserting one and the return key always submits. + const shouldReallySubmitForm = !!autoGrowSingleLine || (isEffectivelyMultiline ? !!shouldSubmitForm && canUseHardwareKeyboard : true); return { // There are inputs that don't have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 2f3d3d3e29c4..e57c368c89bf 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -124,6 +124,7 @@ type InputComponentBaseProps = Input ref?: Ref; multiline?: boolean; autoGrowHeight?: boolean; + autoGrowSingleLine?: boolean; submitBehavior?: SubmitBehavior; shouldSubmitForm?: boolean; uncontrolled?: boolean; diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 0df586b70057..64e71e9b7202 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -1,3 +1,69 @@ -import BaseTextInput from './implementation'; +import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; + +import variables from '@styles/variables'; + +import type {TextInputKeyPressEvent, TextInputSubmitEditingEvent} from 'react-native'; + +import React from 'react'; + +import type {BaseTextInputProps} from './types'; + +import BaseTextInputImplementation from './implementation'; + +/** + * A single-line input cannot wrap - on web it renders as `` and on native as `UITextField` / + * a single-line `EditText`, none of which have a wrapping mode. Wrapping therefore requires the + * multiline rendering path, which brings newline affordances a single-line value must not have. + * + * `autoGrowSingleLine` bundles the growth with those affordances turned back off, so the input looks + * like a growing text box but still behaves like the single-line field it replaces: + * - grows with the content up to `variables.textInputAutoGrowMaxHeight`, then scrolls internally + * - the return key submits and blurs rather than inserting a line break, including on touch devices + * (see `computeComponentSpecificRegistrationParams` in `Form/InputWrapper`) + * - Shift+Enter submits too, since a line break is never a valid value here + * + * Pass `useAutoFocusInput(true)` when the input is autofocused, so the caret lands after an existing value + * instead of before it. + */ +function BaseTextInput({ref, autoGrowSingleLine = false, ...props}: BaseTextInputProps) { + /** + * react-native-web only treats Enter *without* modifiers as a submit, so Shift+Enter bypasses + * `submitBehavior` entirely and would insert a line break. Submit instead, which is what Enter with or + * without Shift does in a single-line input. Only web hands this handler a keyboard event carrying the + * modifier state and a working `preventDefault`, so a line break typed on native with a hardware keyboard + * drops out here and is left for the consumer to sanitize when the value is saved. + */ + const submitOnShiftEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event) || event.key !== 'Enter' || !event.shiftKey || isEnterWhileComposition(event)) { + return; + } + + event.preventDefault(); + + // The submit handler only needs to know that the input was submitted, it ignores the event payload. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + props.onSubmitEditing?.(event as unknown as TextInputSubmitEditingEvent); + }; + + const handleKeyPress = (event: TextInputKeyPressEvent) => { + props.onKeyPress?.(event); + submitOnShiftEnter(event); + }; + + return ( + + ); +} + +BaseTextInput.displayName = 'BaseTextInput'; export default BaseTextInput; diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 50a05bdf2bcc..d64f06101195 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -89,6 +89,13 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & */ maxAutoGrowHeight?: number; + /** + * For values that must stay on a single line (titles, names) but are too long to read in a single-line box. + * The input wraps and grows like a multiline box, while the return key keeps its single-line meaning: it + * submits instead of inserting a line break, on every platform. Implies `autoGrowHeight`. + */ + autoGrowSingleLine?: boolean; + /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; diff --git a/src/pages/EditReportFieldText.tsx b/src/pages/EditReportFieldText.tsx index 0517949c61bb..362a6a4df4a2 100644 --- a/src/pages/EditReportFieldText.tsx +++ b/src/pages/EditReportFieldText.tsx @@ -11,8 +11,6 @@ import {hasCircularReferences} from '@libs/Formula'; import type {FieldList} from '@libs/Formula'; import StringUtils from '@libs/StringUtils'; -import variables from '@styles/variables'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -46,7 +44,7 @@ type EditReportFieldTextPageProps = { function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldKey, fieldList, disabled = false}: EditReportFieldTextPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {inputCallbackRef} = useAutoFocusInput(); + const {inputCallbackRef} = useAutoFocusInput(true); const reportFieldName = Str.UCFirst(fieldName); const validate = useCallback( @@ -99,8 +97,7 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f label={reportFieldName} accessibilityLabel={reportFieldName} role={CONST.ROLE.PRESENTATION} - autoGrowHeight - maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight} + autoGrowSingleLine ref={inputCallbackRef} disabled={disabled} /> diff --git a/tests/unit/StringUtilsTest.ts b/tests/unit/StringUtilsTest.ts index 9849381417d4..bbd1f71b7676 100644 --- a/tests/unit/StringUtilsTest.ts +++ b/tests/unit/StringUtilsTest.ts @@ -129,4 +129,32 @@ second expect(StringUtils.startsWithVowel('@example')).toBe(false); }); }); + + describe('lineBreaksToSpaces', () => { + it('replaces every kind of line break with a space', () => { + expect(StringUtils.lineBreaksToSpaces('line1\nline2')).toBe('line1 line2'); + expect(StringUtils.lineBreaksToSpaces('line1\rline2')).toBe('line1 line2'); + expect(StringUtils.lineBreaksToSpaces('line1\r\nline2')).toBe('line1 line2'); + expect(StringUtils.lineBreaksToSpaces('line1 line2')).toBe('line1 line2'); + }); + + it('flattens multiple line breaks in a single string', () => { + expect(StringUtils.lineBreaksToSpaces('a\nb\nc')).toBe('a b c'); + }); + + it('leaves strings without line breaks untouched', () => { + expect(StringUtils.lineBreaksToSpaces('no line breaks here')).toBe('no line breaks here'); + }); + + it('returns an empty string for empty or missing input', () => { + expect(StringUtils.lineBreaksToSpaces('')).toBe(''); + expect(StringUtils.lineBreaksToSpaces()).toBe(''); + }); + + it('uses a non-breaking space when requested', () => { + const result = StringUtils.lineBreaksToSpaces('line1\nline2', true); + expect(result).not.toContain('\n'); + expect(result.charCodeAt(5)).toBe(0xa0); + }); + }); }); From 0c7f187a13357c8557814eb2139afa30834ee5f7 Mon Sep 17 00:00:00 2001 From: "Jules (via MelvinBot)" Date: Thu, 6 Aug 2026 13:11:28 +0000 Subject: [PATCH 120/466] Fix: add affordances and autofocused to cspell dictionary Co-authored-by: Jules --- cspell.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cspell.json b/cspell.json index 79157b793bbd..0e06ad8a0150 100644 --- a/cspell.json +++ b/cspell.json @@ -439,6 +439,7 @@ "actool", "adbd", "aeiou", + "affordances", "airshipconfig", "airside", "alrt", @@ -476,6 +477,7 @@ "autocorrection", "autodocs", "autofilled", + "autofocused", "automations", "autoplay", "autoreleasepool", From a2befe397b83ac49a7eadcdb10112f7e190ad2b7 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Thu, 6 Aug 2026 13:28:27 +0000 Subject: [PATCH 121/466] Reword comments to drop cspell exceptions (affordances, autofocused) Co-authored-by: mkhutornyi --- cspell.json | 2 -- src/components/TextInput/BaseTextInput/index.tsx | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cspell.json b/cspell.json index 7bd22b418f91..86d8040e672d 100644 --- a/cspell.json +++ b/cspell.json @@ -441,7 +441,6 @@ "actool", "adbd", "aeiou", - "affordances", "airshipconfig", "airside", "alrt", @@ -479,7 +478,6 @@ "autocorrection", "autodocs", "autofilled", - "autofocused", "automations", "autoplay", "autoreleasepool", diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 64e71e9b7202..0081b87e3220 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -13,16 +13,16 @@ import BaseTextInputImplementation from './implementation'; /** * A single-line input cannot wrap - on web it renders as `` and on native as `UITextField` / * a single-line `EditText`, none of which have a wrapping mode. Wrapping therefore requires the - * multiline rendering path, which brings newline affordances a single-line value must not have. + * multiline rendering path, which brings newline behaviors a single-line value must not have. * - * `autoGrowSingleLine` bundles the growth with those affordances turned back off, so the input looks + * `autoGrowSingleLine` bundles the growth with those behaviors turned back off, so the input looks * like a growing text box but still behaves like the single-line field it replaces: * - grows with the content up to `variables.textInputAutoGrowMaxHeight`, then scrolls internally * - the return key submits and blurs rather than inserting a line break, including on touch devices * (see `computeComponentSpecificRegistrationParams` in `Form/InputWrapper`) * - Shift+Enter submits too, since a line break is never a valid value here * - * Pass `useAutoFocusInput(true)` when the input is autofocused, so the caret lands after an existing value + * Pass `useAutoFocusInput(true)` when the input is focused automatically, so the caret lands after an existing value * instead of before it. */ function BaseTextInput({ref, autoGrowSingleLine = false, ...props}: BaseTextInputProps) { From 4c9338835e8699a376c2a7c15c873b38b670ba62 Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Thu, 6 Aug 2026 15:27:36 +0000 Subject: [PATCH 122/466] Remove redundant displayName and simplify inline comments in BaseTextInput Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com> --- .../TextInput/BaseTextInput/index.tsx | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 0081b87e3220..29009461c0f4 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -11,28 +11,16 @@ import type {BaseTextInputProps} from './types'; import BaseTextInputImplementation from './implementation'; /** - * A single-line input cannot wrap - on web it renders as `` and on native as `UITextField` / - * a single-line `EditText`, none of which have a wrapping mode. Wrapping therefore requires the - * multiline rendering path, which brings newline behaviors a single-line value must not have. + * `autoGrowSingleLine` makes the input grow like a text box while still behaving like a single-line field: + * it grows with content up to `variables.textInputAutoGrowMaxHeight` then scrolls internally, and the return + * key (including Shift+Enter and on touch devices) submits instead of inserting a line break. This needs the + * multiline rendering path, so those newline behaviors are turned back off here. * - * `autoGrowSingleLine` bundles the growth with those behaviors turned back off, so the input looks - * like a growing text box but still behaves like the single-line field it replaces: - * - grows with the content up to `variables.textInputAutoGrowMaxHeight`, then scrolls internally - * - the return key submits and blurs rather than inserting a line break, including on touch devices - * (see `computeComponentSpecificRegistrationParams` in `Form/InputWrapper`) - * - Shift+Enter submits too, since a line break is never a valid value here - * - * Pass `useAutoFocusInput(true)` when the input is focused automatically, so the caret lands after an existing value - * instead of before it. + * Pass `useAutoFocusInput(true)` when auto-focusing so the caret lands after an existing value. */ function BaseTextInput({ref, autoGrowSingleLine = false, ...props}: BaseTextInputProps) { - /** - * react-native-web only treats Enter *without* modifiers as a submit, so Shift+Enter bypasses - * `submitBehavior` entirely and would insert a line break. Submit instead, which is what Enter with or - * without Shift does in a single-line input. Only web hands this handler a keyboard event carrying the - * modifier state and a working `preventDefault`, so a line break typed on native with a hardware keyboard - * drops out here and is left for the consumer to sanitize when the value is saved. - */ + // react-native-web treats Shift+Enter as a line break rather than a submit, so submit it manually. Only web + // passes a keyboard event here, so line breaks typed on native are left for the consumer to sanitize. const submitOnShiftEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { if (!('key' in event) || event.key !== 'Enter' || !event.shiftKey || isEnterWhileComposition(event)) { return; @@ -40,7 +28,7 @@ function BaseTextInput({ref, autoGrowSingleLine = false, ...props}: BaseTextInpu event.preventDefault(); - // The submit handler only needs to know that the input was submitted, it ignores the event payload. + // The submit handler ignores the event payload, so the cast is safe. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion props.onSubmitEditing?.(event as unknown as TextInputSubmitEditingEvent); }; @@ -64,6 +52,4 @@ function BaseTextInput({ref, autoGrowSingleLine = false, ...props}: BaseTextInpu ); } -BaseTextInput.displayName = 'BaseTextInput'; - export default BaseTextInput; From 4f523758a3c7a86c2fd88bf8394600850237334e Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:10:28 -1000 Subject: [PATCH 123/466] Revise upcoming integrations and accounting features Updated the description and keywords for upcoming integrations and accounting features, adding new integrations with Campfire, Greenhouse, Ashby, and Lever while removing outdated references. --- .../Upcoming-Integrations-And-Accounting.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md index e7c26fa841bb..094509ee1974 100644 --- a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md +++ b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md @@ -1,7 +1,7 @@ --- title: Upcoming Features – Integrations & Accounting (New Expensify) -description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, NetSuite REST, and HR imports. Not for public search indexing. -keywords: upcoming, coming soon, planned, roadmap, Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, D365, NetSuite REST API, SuiteTalk, merge.dev, BambooHR, Workday, HiBob, HR import, HRIS, OAuth, Integration Server, accounting integration, AgentZero reference +description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, DualEntry, NetSuite REST, Greenhouse, Ashby, Lever, Campfire, Workable, and HR imports. Not for public search indexing. +keywords: upcoming, coming soon, planned, roadmap, Campfire, Rillet, Puzzle.io, DualEntry, Greenhouse, Ashby, Lever, Workable, Microsoft Dynamics 365, Microsoft Dynamics 365 Business Central, Dynamics, D365, NetSuite REST API, SuiteTalk, merge.dev, HR import, HRIS, OAuth, Integration Server, accounting integration, noindex: true sitemap: false internalScope: For AgentZero and internal tooling only. Use this page to give accurate, forward-looking answers about accounting and integration features that are planned but not yet generally available. Do not expose this URL in user-facing help. @@ -11,9 +11,13 @@ internalScope: For AgentZero and internal tooling only. Use this page to give ac This page is an **AgentZero reference** for accounting and integration features that are planned or in development for New Expensify. Use it to answer "is this coming soon?" questions accurately. These items are subject to change. -- **Rillet integration** – Native accounting integration with Rillet, a modern general ledger for US-based companies, enabling direct sync of expenses and reports. -- **Puzzle.io integration** – Native accounting integration with Puzzle.io, adding support for startups and SMBs using Puzzle as their accounting system. -- **Microsoft Dynamics 365 Business Central integration** – Native integration with Microsoft Dynamics 365 Business Central, covering a significant share of SMB and mid-market accounting users who currently rely on manual CSV/API workarounds. +- **Rillet integration** – Accounting integration with Rillet, a modern general ledger, enabling direct sync of expenses and reports. +- **Puzzle.io integration** – Accounting integration with Puzzle.io, adding support for startups and SMBs using Puzzle as their accounting system. +- **Microsoft Dynamics 365 Business Central integration** – Accounting integration with Microsoft Dynamics 365 Business Central, covering a significant share of accounting users who currently rely on manual CSV/API workarounds. +- **Campfire** – Accounting integration with Campfire, an AI-powered ERP and general ledger, expanding support for modern finance teams managing accounting, reporting, and close workflows. +- **Greenhouse** – HR integration with Greenhouse, a widely used recruiting and applicant tracking platform, expanding connectivity for companies that manage their hiring workflows in Greenhouse. +- **Ashby** – HR integration with Ashby, an all-in-one recruiting platform, expanding connectivity for teams using Ashby to manage recruiting and applicant tracking. +- **Lever** – HR integration with Lever, an applicant tracking system and recruiting CRM, expanding connectivity for companies using Lever to manage candidates and hiring workflows. +- **Workable** – HR integration with Workable, an applicant tracking and HR platform, expanding connectivity for companies using Workable to manage recruiting and talent workflows. - **NetSuite REST API migration** – Move the NetSuite integration from the legacy SuiteTalk SOAP API to the modern REST API, improving reliability, sync speed, and long-term supportability. -- **HR system import via merge.dev** – Connect New Expensify to a wide range of HR platforms (BambooHR, Workday, HiBob, and others) via merge.dev, automatically syncing employee data, org structure, and approval hierarchies. - **Extend OAuth to the Integration Server (IS)** – Extend the OAuth framework (originally built for MCP integrations) to the Integration Server API, making it easier for third-party developers to build secure integrations with Expensify. From cfcfb4dee7b1144f2d3cf1d3b1aac162dbc9902c Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:29:15 -1000 Subject: [PATCH 124/466] Update Upcoming-Integrations-And-Accounting.md --- docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md index 094509ee1974..6beded4bd7e1 100644 --- a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md +++ b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md @@ -14,10 +14,11 @@ This page is an **AgentZero reference** for accounting and integration features - **Rillet integration** – Accounting integration with Rillet, a modern general ledger, enabling direct sync of expenses and reports. - **Puzzle.io integration** – Accounting integration with Puzzle.io, adding support for startups and SMBs using Puzzle as their accounting system. - **Microsoft Dynamics 365 Business Central integration** – Accounting integration with Microsoft Dynamics 365 Business Central, covering a significant share of accounting users who currently rely on manual CSV/API workarounds. -- **Campfire** – Accounting integration with Campfire, an AI-powered ERP and general ledger, expanding support for modern finance teams managing accounting, reporting, and close workflows. +- **Campfire** – Accounting integration with Campfire, an AI-powered ERP and general ledger, enabling customers to import accounting data, export expenses and card spend, and keep reimbursements and settlements in sync without relying on CSV exports or custom workarounds. - **Greenhouse** – HR integration with Greenhouse, a widely used recruiting and applicant tracking platform, expanding connectivity for companies that manage their hiring workflows in Greenhouse. - **Ashby** – HR integration with Ashby, an all-in-one recruiting platform, expanding connectivity for teams using Ashby to manage recruiting and applicant tracking. - **Lever** – HR integration with Lever, an applicant tracking system and recruiting CRM, expanding connectivity for companies using Lever to manage candidates and hiring workflows. - **Workable** – HR integration with Workable, an applicant tracking and HR platform, expanding connectivity for companies using Workable to manage recruiting and talent workflows. +- **Sage Accounting** – Accounting integration with Sage Accounting, supporting core accounting workflows for businesses in the UK and Europe and making it easier to connect, import, export, and sync accounting data directly with Expensify. - **NetSuite REST API migration** – Move the NetSuite integration from the legacy SuiteTalk SOAP API to the modern REST API, improving reliability, sync speed, and long-term supportability. - **Extend OAuth to the Integration Server (IS)** – Extend the OAuth framework (originally built for MCP integrations) to the Integration Server API, making it easier for third-party developers to build secure integrations with Expensify. From 5934abc05a94b25212dccdd99de8c29902e3bb43 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:30:24 -1000 Subject: [PATCH 125/466] Update keywords format in Upcoming Integrations article --- docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md index 6beded4bd7e1..9b1738d28900 100644 --- a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md +++ b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md @@ -1,7 +1,7 @@ --- title: Upcoming Features – Integrations & Accounting (New Expensify) description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, DualEntry, NetSuite REST, Greenhouse, Ashby, Lever, Campfire, Workable, and HR imports. Not for public search indexing. -keywords: upcoming, coming soon, planned, roadmap, Campfire, Rillet, Puzzle.io, DualEntry, Greenhouse, Ashby, Lever, Workable, Microsoft Dynamics 365, Microsoft Dynamics 365 Business Central, Dynamics, D365, NetSuite REST API, SuiteTalk, merge.dev, HR import, HRIS, OAuth, Integration Server, accounting integration, +keywords: [upcoming, coming soon, planned, roadmap, Campfire, Rillet, Puzzle.io, DualEntry, Greenhouse, Ashby, Lever, Workable, Microsoft Dynamics 365, Microsoft Dynamics 365 Business Central, Dynamics, D365, NetSuite REST API, SuiteTalk, merge.dev, HR import, HRIS, OAuth, Integration Server, accounting integration] noindex: true sitemap: false internalScope: For AgentZero and internal tooling only. Use this page to give accurate, forward-looking answers about accounting and integration features that are planned but not yet generally available. Do not expose this URL in user-facing help. From d2c228d893e3c7072b780f89faa3c0d901f6c57e Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:31:57 -1000 Subject: [PATCH 126/466] Move Greenhouse.md to Hidden directory --- docs/{articles/new-expensify/connections => Hidden}/Greenhouse.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{articles/new-expensify/connections => Hidden}/Greenhouse.md (100%) diff --git a/docs/articles/new-expensify/connections/Greenhouse.md b/docs/Hidden/Greenhouse.md similarity index 100% rename from docs/articles/new-expensify/connections/Greenhouse.md rename to docs/Hidden/Greenhouse.md From f09abd526b4bf6700ed6191958dba09b87c74772 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:33:23 -1000 Subject: [PATCH 127/466] Add redirect for Greenhouse documentation --- docs/redirects.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/redirects.csv b/docs/redirects.csv index f6b26d58d13a..ef0b02fefe4a 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -1017,3 +1017,4 @@ https://help.expensify.com/articles/new-expensify/expensify-card/Set-Up-and-Mana https://help.expensify.com/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-or-Google-Pay,https://help.expensify.com/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet https://help.expensify.com/articles/expensify-classic/settings/Magic-Code-Access-Troubleshooting,https://help.expensify.com/articles/expensify-classic/settings/Security-Code-Access-Troubleshooting https://help.expensify.com/Hidden/issues-receiving-the-magic-code,https://help.expensify.com/Hidden/issues-receiving-the-security-code +https://help.expensify.com/articles/new-expensify/connections/Greenhouse,https://help.expensify.com/docs/Hidden/Greenhouse From ddfcf43520249fad643ac546ecb64631e3d80c09 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 7 Aug 2026 14:16:26 +0430 Subject: [PATCH 128/466] Show the new bank account statuses on Workflows for every workspace --- .../workspace/workflows/WorkspaceWorkflowsPage.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index f022d50a8f05..4402daa57ee8 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -204,7 +204,6 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { ); const canAccessSubmit2026Features = canAccessSubmitWorkspaceFeatures(policy, isSubmit2026BetaEnabled); - const canAccessWalletConnectionStatusFeatures = canAccessSubmitWorkspaceFeatures(policy, isWalletConnectionStatusBetaEnabled); const hasValidExistingAccounts = getEligibleExistingBusinessBankAccounts(bankAccountList, policy?.outputCurrency, true).length > 0; const isAdvanceApproval = (approvalWorkflows.length > 1 || (approvalWorkflows?.at(0)?.approvers ?? []).length > 1) && isControlPolicy(policy); @@ -484,7 +483,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { return undefined; } }; - const bankConnectionStatus = canAccessWalletConnectionStatusFeatures ? getBankAccountConnectionStatus(state) : undefined; + const bankConnectionStatus = isWalletConnectionStatusBetaEnabled ? getBankAccountConnectionStatus(state) : undefined; const bankConnectionBrickRoadIndicator = bankConnectionStatus?.brickRoadIndicator ?? (hasReimburserError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined); const bankConnectionStatusAddon = bankConnectionStatus ? ( ['onPress']; - if (canAccessWalletConnectionStatusFeatures) { + if (isWalletConnectionStatusBetaEnabled) { bankAccountMenuItemOnPress = canInteractWithBankAccountRow ? handleBankAccountPress : undefined; } else { bankAccountMenuItemOnPress = canWritePayments ? handleBankAccountPress : undefined; @@ -560,7 +558,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { shouldShowRightIcon: canWritePayments, interactive: canWritePayments, shouldGreyOutWhenDisabled: !policy?.pendingFields?.reimbursementChoice, - ...(canAccessWalletConnectionStatusFeatures + ...(isWalletConnectionStatusBetaEnabled ? { badgeIcon: bankBadgeIcon, descriptionAddon: bankConnectionStatusAddon, @@ -830,7 +828,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { {translate('workflowsPayerPage.paymentAccount')} - {canAccessWalletConnectionStatusFeatures ? ( + {isWalletConnectionStatusBetaEnabled ? ( {(isHovered) => ( @@ -1026,7 +1024,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { canAccessSubmit2026Features, isGlobalReimbursementsBetaEnabled, isGlobalReimbursementFXBetaEnabled, - canAccessWalletConnectionStatusFeatures, + isWalletConnectionStatusBetaEnabled, canWriteApprovals, canWritePayments, canWriteWorkflows, From 7f5117c17bd7c378e96a4f530409e5d7dd522dd7 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Fri, 7 Aug 2026 16:25:43 +0000 Subject: [PATCH 129/466] Remove unused eslint-disable directive for react/jsx-props-no-spreading Co-authored-by: mkhutornyi --- src/components/TextInput/BaseTextInput/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 29009461c0f4..c83a225d45a0 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -40,7 +40,6 @@ function BaseTextInput({ref, autoGrowSingleLine = false, ...props}: BaseTextInpu return ( Date: Sat, 8 Aug 2026 05:15:18 +0500 Subject: [PATCH 130/466] Fixed test cases --- tests/perf-test/OptionsListUtils.perf-test.ts | 3 ++- tests/unit/OptionsListUtilsTest.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 9b15fb94e141..86a9301b5e31 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -317,7 +317,7 @@ describe('OptionsListUtils', () => { test('[OptionsListUtils] filterAndOrderOptions with multi-word search on large dataset', async () => { const largePersonalDetails = getMockedPersonalDetails(LARGE_PERSONAL_DETAILS_COUNT); const largeReports = getMockedReports(LARGE_REPORTS_COUNT) as Record<`${typeof ONYXKEYS.COLLECTION.REPORT}`, Report>; - const largeOptionList = createFilteredOptionList(largePersonalDetails, largeReports, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined); + const largeOptionList = createFilteredOptionList(largePersonalDetails, largeReports, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, undefined, {conciergeReportID: undefined}); const {options: formattedOptions} = getValidOptions( {reports: largeOptionList.reports, personalDetails: largeOptionList.personalDetails}, @@ -328,6 +328,7 @@ describe('OptionsListUtils', () => { MOCK_CURRENT_USER_EMAIL, undefined, ValidOptionsConfig, + translateLocal, ); await measureFunction(() => { diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 6c09d6f6eb0f..a72549904d77 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3743,6 +3743,7 @@ describe('OptionsListUtils', () => { it('should return the same matches for normalized multi-word queries with extra spaces', () => { const {options} = getSearchOptions({ + translate: translateLocal, options: OPTIONS, reportAttributesDerived: MOCK_REPORT_ATTRIBUTES_DERIVED, draftComments: {}, From dc60b27f3d0d5347c8452e9716c3c48555c67cac Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Mon, 10 Aug 2026 09:31:10 +0000 Subject: [PATCH 131/466] Allow promoting the Authorized Payer to Workspace Admin Co-authored-by: Shridhar Goel --- src/components/WorkspaceMemberRoleList.tsx | 9 +++- .../members/WorkspaceMemberDetailsPage.tsx | 9 ++-- .../WorkspaceMemberDetailsRolePage.tsx | 11 +++- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 52 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index c27b37694980..0e1c85ad8042 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -34,9 +34,12 @@ type WorkspaceMemberRoleListProps = { navigateBackTo?: Route; isLoading?: boolean; onSelectRole?: (value: ListItemType) => void; + + /** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be promoted to Admin) */ + allowedRoles?: Array>; }; -function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}}: WorkspaceMemberRoleListProps) { +function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}, allowedRoles = undefined}: WorkspaceMemberRoleListProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); @@ -86,7 +89,9 @@ function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLo }, ]; - const availableRoleItems: ListItemType[] = workspaceRoles.filter((item) => canMemberAssignRole(policy, currentUserLogin, item.value)); + const availableRoleItems: ListItemType[] = workspaceRoles.filter( + (item) => canMemberAssignRole(policy, currentUserLogin, item.value) && (!allowedRoles || allowedRoles.includes(item.value)), + ); return ( <> diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 1b83cd170eeb..43232cfef383 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -147,6 +147,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; + // An Authorized Payer (reimburser) must stay an admin, so lock the Role row only once they are already an Admin — every + // remaining change would be a demotion that breaks reimbursements. A non-admin payer can still be promoted to Admin. + const isReimburserAdmin = isReimburser && member?.role === CONST.POLICY.ROLE.ADMIN; const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); @@ -380,9 +383,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM { if ( @@ -397,7 +400,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM } Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, accountID)); }} - hintText={isReimburser ? translate('common.roleCannotBeChanged', workspaceWorkflowsPageURL) : undefined} + hintText={isReimburserAdmin ? translate('common.roleCannotBeChanged', workspaceWorkflowsPageURL) : undefined} shouldRenderHintAsHTML /> {isControlPolicy(policy) && ( diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 2876830990d5..a754989b6edf 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -11,7 +11,7 @@ import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberAssignRole} from '@libs/PolicyUtils'; +import {canMemberAssignRole, getReimburserEmail} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -40,6 +40,10 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); + // The Authorized Payer (reimburser) must remain an admin, so they may only be promoted to Admin — never demoted to another role. + const reimburserEmail = getReimburserEmail(policy); + const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; + const allowedRoles = isReimburser ? [CONST.POLICY.ROLE.ADMIN] : undefined; useRedirectSubmitWorkspaceFeatureUpgrade({ policy, backTo: ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID), @@ -53,6 +57,10 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp if (!canMemberAssignRole(policy, currentUserLogin, value)) { return; } + // Guard the direct-navigation path: a reimburser can only be promoted to Admin, so reject any demotion. + if (isReimburser && value !== CONST.POLICY.ROLE.ADMIN) { + return; + } if (value !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { showRuleBotGuardModal('changeRole', policyID); return; @@ -76,6 +84,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp role={member?.role} policy={policy} onSelectRole={changeRole} + allowedRoles={allowedRoles} navigateBackTo={ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)} /> diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index 10ae88451ad7..ffedaa0050b7 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -67,6 +67,8 @@ describe('WorkspaceMemberDetailsPage', () => { const primaryAccountID = 7777; const primaryEmail = 'primary@example.com'; const secondaryEmail = 'secondary@example.com'; + const adminPayerAccountID = 8888; + const adminPayerEmail = 'adminpayer@example.com'; const policy = { ...LHNTestUtils.getFakePolicy(), @@ -83,6 +85,7 @@ describe('WorkspaceMemberDetailsPage', () => { [invitedEmail]: {email: invitedEmail, role: CONST.POLICY.ROLE.USER}, [phoneLogin]: {email: phoneLogin, role: CONST.POLICY.ROLE.USER}, [primaryEmail]: {email: primaryEmail, role: CONST.POLICY.ROLE.USER}, + [adminPayerEmail]: {email: adminPayerEmail, role: CONST.POLICY.ROLE.ADMIN}, }, }; @@ -102,6 +105,7 @@ describe('WorkspaceMemberDetailsPage', () => { [invitedAccountID]: TestHelper.buildPersonalDetails(invitedEmail, invitedAccountID, 'Invited'), [phoneAccountID]: TestHelper.buildPersonalDetails(phoneLogin, phoneAccountID, 'Phone'), [primaryAccountID]: TestHelper.buildPersonalDetails(primaryEmail, primaryAccountID, 'Primary'), + [adminPayerAccountID]: TestHelper.buildPersonalDetails(adminPayerEmail, adminPayerAccountID, 'AdminPayer'), }); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); }); @@ -223,6 +227,54 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); + it('should not lock the Role field for a non-admin Authorized Payer so they can be promoted to Admin', async () => { + // Make the invited member (a plain USER) the workspace Authorized Payer. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: invitedEmail, + }); + }); + + const {unmount} = renderPage({policyID: policy.id, accountID: String(invitedAccountID)}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('WorkspaceMemberDetailsPage')).toBeOnTheScreen(); + }); + + // The locked hint must NOT be shown — a non-admin payer can still be promoted to Admin. + expect(screen.queryByText(/Role can/)).not.toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should lock the Role field for an Authorized Payer who is already an Admin', async () => { + // The admin member is the workspace Authorized Payer — every remaining role change would be a demotion. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, + reimburser: adminPayerEmail, + }); + }); + + const {unmount} = renderPage({policyID: policy.id, accountID: String(adminPayerAccountID)}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('WorkspaceMemberDetailsPage')).toBeOnTheScreen(); + }); + + // The locked hint IS shown — an admin payer cannot be demoted while they remain the payer. + await waitFor(() => { + expect(screen.getByText(/Role can/)).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should show the not found page when the accountID matches no workspace member', async () => { const {unmount} = renderPage({policyID: policy.id, accountID: '999999'}); await waitForBatchedUpdatesWithAct(); From 9d235ce7e81e98e951611c0e8765b5618b168be0 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 13:14:26 +0200 Subject: [PATCH 132/466] better rollback --- src/libs/actions/Workflow.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 255a4044c07b..dced77e3d366 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -25,7 +25,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ApprovalWorkflowOnyx, PersonalDetailsList, Policy, Report} from '@src/types/onyx'; import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; -import type {ApprovalWorkflowRule} from '@src/types/onyx/ApprovalWorkflowRules'; import type Rule from '@src/types/onyx/Rule'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -292,8 +291,8 @@ type SetApprovalWorkflowRulesParams = { */ rulesDiff: ApprovalWorkflowRulesDiff; - /** Snapshot of the policy's rules taken before applying `rulesDiff`. Used to roll back on failure. */ - previousRules: Record; + /** The rules as currently stored in Onyx, which `rulesDiff` is applied on top of. Used to roll back on failure. */ + previousRules: OnyxCollection; }; /** @@ -312,13 +311,9 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousRules}: SetAppro for (const [ruleID, rule] of Object.entries(rulesDiff)) { const ruleKey = `${ONYXKEYS.COLLECTION.RULE}${ruleID}` as const; - const previousRule = previousRules[ruleID]; + const previousRule = previousRules?.[ruleKey]; const restore: OnyxUpdate = previousRule - ? { - onyxMethod: Onyx.METHOD.SET, - key: ruleKey, - value: {...previousRule, scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, pendingAction: null, errors: genericError}, - } + ? {onyxMethod: Onyx.METHOD.SET, key: ruleKey, value: {...previousRule, pendingAction: null, errors: genericError}} : {onyxMethod: Onyx.METHOD.SET, key: ruleKey, value: null}; if (rule === null) { @@ -368,7 +363,7 @@ function createApprovalWorkflowRules({approvalWorkflow, policy, addExpenseApprov const rulesDiff = {...removeDiff, ...createDiff}; - setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: rules}); if ( addExpenseApprovalsTaskReport && @@ -411,7 +406,7 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, const rulesDiff = {...removeFromOthersDiff, ...memberDiff, ...chainDiff}; - setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: rules}); } /** @@ -430,7 +425,7 @@ function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: return false; } - setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: existingRules}); + setApprovalWorkflowRules({policyID: policy.id, rulesDiff, previousRules: rules}); return true; } From 581b52eb1dbd39cb5ab49ada64a01e8fee657d3d Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 15:06:15 +0200 Subject: [PATCH 133/466] some simplification --- src/libs/WorkflowUtils.ts | 2 +- tests/unit/WorkflowUtilsTest.ts | 41 --------------------------------- 2 files changed, 1 insertion(+), 42 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 1cf2109d1c19..4d94deb9bf8d 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -678,7 +678,7 @@ function getEligibleExistingBusinessBankAccounts( account.bankCurrency === policyCurrency && (account.accountData?.state === CONST.BANK_ACCOUNT.STATE.OPEN || (shouldIncludePartiallySetup && isBankAccountPartiallySetup(account.accountData?.state))) && account.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && - (excludeBankAccountID === undefined || account.methodID !== excludeBankAccountID) + account.methodID !== excludeBankAccountID ); }); } diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index cc92ca8d728d..8bbbb0a3d3c7 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -12,7 +12,6 @@ import { extractSubmitterEmails, filterRulesForPolicy, getApprovalLimitDescription, - getEligibleExistingBusinessBankAccounts, getOpenConnectedToPolicyBusinessBankAccounts, getOverLimitForwardsToDisplayName, getRulesSubmitterToFirstApprover, @@ -1643,46 +1642,6 @@ describe('WorkflowUtils', () => { }); }); - describe('getEligibleExistingBusinessBankAccounts', () => { - const openBusinessBankAccount = { - bankCurrency: 'USD', - bankCountry: 'US', - accountData: { - state: CONST.BANK_ACCOUNT.STATE.OPEN, - type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, - }, - }; - - it('should return an empty array when the bank account list is undefined', () => { - expect(getEligibleExistingBusinessBankAccounts(undefined, 'USD')).toEqual([]); - }); - - it('should return an empty array when the policy currency is undefined', () => { - expect(getEligibleExistingBusinessBankAccounts({'1': openBusinessBankAccount}, undefined)).toEqual([]); - }); - - it('should return open business accounts matching the policy currency', () => { - expect(getEligibleExistingBusinessBankAccounts({'1': openBusinessBankAccount}, 'USD')).toEqual([openBusinessBankAccount]); - }); - - it('should filter out accounts with a non-matching currency', () => { - const eurAccount = {...openBusinessBankAccount, bankCurrency: 'EUR'}; - expect(getEligibleExistingBusinessBankAccounts({'1': eurAccount}, 'USD')).toEqual([]); - }); - - it('should filter out non-business accounts', () => { - const personalAccount = {...openBusinessBankAccount, accountData: {...openBusinessBankAccount.accountData, type: CONST.BANK_ACCOUNT.TYPE.PERSONAL}}; - expect(getEligibleExistingBusinessBankAccounts({'1': personalAccount}, 'USD')).toEqual([]); - }); - - it('should exclude partially set up accounts by default but include them when requested', () => { - const setupAccount = {...openBusinessBankAccount, accountData: {...openBusinessBankAccount.accountData, state: CONST.BANK_ACCOUNT.STATE.SETUP}}; - - expect(getEligibleExistingBusinessBankAccounts({'1': setupAccount}, 'USD')).toEqual([]); - expect(getEligibleExistingBusinessBankAccounts({'1': setupAccount}, 'USD', true)).toEqual([setupAccount]); - }); - }); - describe('rule-based approval workflows', () => { const submitTriggers = {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_SUBMIT}; const approveTriggers = {'0': CONST.RULES.APPROVAL_WORKFLOW.TRIGGER.REPORT_APPROVE}; From 059b8ace5a107f39bdad224d142f14891a10c252 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 15:07:46 +0200 Subject: [PATCH 134/466] reorder pieces --- src/libs/WorkflowUtils.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 4d94deb9bf8d..3cb993a092a6 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -744,8 +744,8 @@ function mergeWorkflowMembersWithAvailableMembers(workflowMembers: Member[], all type ApprovalWorkflowRulesDiff = Record; function buildComparison( - operator: ValueOf, left: ApprovalWorkflowFilterComparison['left'], + operator: ValueOf, right: ApprovalWorkflowFilterComparison['right'], ): ApprovalWorkflowFilterComparison { return {operator, left, right}; @@ -756,11 +756,11 @@ function buildAnd(left: ApprovalWorkflowFilter['left'], right: ApprovalWorkflowF } function buildSubmitterFilter(memberEmails: string[]): ApprovalWorkflowFilterComparison { - return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, [...memberEmails]); + return buildComparison(CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, [...memberEmails]); } function buildToComparison(email: string): ApprovalWorkflowFilterComparison { - return buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, email); + return buildComparison(CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, email); } /** The index-keyed object shape the rules API uses for lists (`['a', 'b'] -> {'0': 'a', '1': 'b'}`). */ @@ -850,8 +850,8 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva continue; } - const underAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, holders.limit); - const overAmount = buildComparison(CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, holders.limit); + const underAmount = buildComparison(CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN, holders.limit); + const overAmount = buildComparison(CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN_OR_EQUAL_TO, holders.limit); rulesIntoPosition.push({triggers, filters: buildAnd(source, underAmount), actions: buildForwardActions(holders.underLimitEmail)}); rulesIntoPosition.push({triggers, filters: buildAnd(source, overAmount), actions: buildForwardActions(holders.overLimitEmail)}); From f805a3de645d9b74b8532b317feb46c8ad6012ee Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 15:25:01 +0200 Subject: [PATCH 135/466] better fingerprint --- src/libs/WorkflowUtils.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 3cb993a092a6..87faec81af72 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -880,7 +880,7 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva // Different approvers may share an `overLimitForwardsTo`, which would emit identical terminal rules. const seen = new Set(); return rules.filter((rule) => { - const fingerprint = JSON.stringify(rule); + const fingerprint = canonicalFingerprint(rule); if (seen.has(fingerprint)) { return false; } @@ -970,6 +970,11 @@ function canonicalize(value: unknown): unknown { * stripped and keys canonicalized. Two rules with the same fingerprint differ only in their submitters, * which is what we look for when deciding whether to merge two workflows into a shared rule. */ +/** A string that is equal for two values that mean the same thing, regardless of the order their keys were built in. */ +function canonicalFingerprint(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} + function structuralFingerprint(rule: ApprovalWorkflowRule): string { const stripFromValues = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): unknown => { if (!node) { @@ -984,13 +989,11 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { return {operator: node.operator, left: stripFromValues(node.left), right: stripFromValues(node.right)}; }; - return JSON.stringify( - canonicalize({ - triggers: rule.triggers, - filters: stripFromValues(rule.filters), - actions: rule.actions, - }), - ); + return canonicalFingerprint({ + triggers: rule.triggers, + filters: stripFromValues(rule.filters), + actions: rule.actions, + }); } /** Replace the `right` value on every `from` leaf with `newEmails`. */ From 6ceef53b7bb5d29973cb83cbe10e5dbd4a5488e0 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 15:26:23 +0200 Subject: [PATCH 136/466] remove comment --- src/ONYXKEYS.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 2ddf961928bc..7ee36477683b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -880,7 +880,6 @@ const ONYXKEYS = { POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED: 'policyHasConnectionsDataBeenFetched_', POLICY_CONNECTION_SYNC_PROGRESS: 'policyConnectionSyncProgress_', POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN: 'policyMergeHRInitialSyncModalShown_', - // AST rules keyed by ruleID RULE: 'rules_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', From 7c07b85fb1abded79eea44ce5e9d7bdd9acd4b9e Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 15:36:40 +0200 Subject: [PATCH 137/466] reuse generic methods --- src/libs/WorkflowUtils.ts | 79 +++++++++++++-------------------------- 1 file changed, 27 insertions(+), 52 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 87faec81af72..548fa206d764 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -909,37 +909,37 @@ function isSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilter return isComparisonLeaf(node) && node.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && node.left === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM; } -/** Walk a filter tree and call `callback` on every submitter filter (the `from` leaf) in it. */ -function forEachSubmitterFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined, callback: (filter: ApprovalWorkflowFilterComparison) => void): void { +/** Return the first comparison leaf in the filter tree whose `left` field matches. */ +function getFilter(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined, leftKey: string): ApprovalWorkflowFilterComparison | undefined { if (!node) { - return; + return undefined; } if (isComparisonLeaf(node)) { - if (isSubmitterFilter(node)) { - callback(node); - } - return; + return node.left === leftKey ? node : undefined; + } + return getFilter(node.left, leftKey) ?? getFilter(node.right, leftKey); +} + +/** Rebuild a filter tree, replacing every comparison leaf with the result of `mapLeaf`. */ +function mapFilters( + node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison, + mapLeaf: (leaf: ApprovalWorkflowFilterComparison) => ApprovalWorkflowFilterComparison, +): ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison { + if (isComparisonLeaf(node)) { + return mapLeaf(node); } - forEachSubmitterFilter(node.left, callback); - forEachSubmitterFilter(node.right, callback); + return {...node, left: mapFilters(node.left, mapLeaf), right: mapFilters(node.right, mapLeaf)}; } -/** Extract the union of email values across every `from` leaf in a rule. */ +/** The emails a rule's `from` filter lists. */ function extractSubmitterEmails(rule: ApprovalWorkflowRule): string[] { - const emails = new Set(); - forEachSubmitterFilter(rule.filters, (filter) => { - const right = filter.right; - if (Array.isArray(right)) { - for (const email of right) { - if (typeof email === 'string') { - emails.add(email); - } - } - } else if (typeof right === 'string') { - emails.add(right); - } - }); - return Array.from(emails); + const submitterFilter = getFilter(rule.filters, CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM); + if (!submitterFilter || !isSubmitterFilter(submitterFilter)) { + return []; + } + + const emails = Array.isArray(submitterFilter.right) ? submitterFilter.right : [submitterFilter.right]; + return Array.from(new Set(emails.filter((email): email is string => typeof email === 'string'))); } /** @@ -998,17 +998,7 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { /** Replace the `right` value on every `from` leaf with `newEmails`. */ function replaceSubmitterEmails(rule: ApprovalWorkflowRule, newEmails: string[]): ApprovalWorkflowRule { - const rewrite = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison): ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison => { - if (isComparisonLeaf(node)) { - return isSubmitterFilter(node) ? {...node, right: [...newEmails]} : node; - } - return { - ...node, - left: rewrite(node.left), - right: rewrite(node.right), - }; - }; - return {...rule, filters: rewrite(rule.filters)}; + return {...rule, filters: mapFilters(rule.filters, (leaf) => (isSubmitterFilter(leaf) ? {...leaf, right: [...newEmails]} : leaf))}; } /** Merge `emailsToAdd` into `existingEmails`, preserving the order of `existingEmails` and dropping duplicates. */ @@ -1206,21 +1196,6 @@ function applyApprovalWorkflowRulesDiff(existingRules: Record> { return Object.values(rule.triggers ?? {}); @@ -1298,7 +1273,7 @@ function resolvePositionHolders(submitter: string, rules: Record Date: Mon, 10 Aug 2026 15:37:49 +0200 Subject: [PATCH 138/466] Update src/libs/WorkflowUtils.ts Co-authored-by: Jack Senyitko <49812749+JS00001@users.noreply.github.com> --- src/libs/WorkflowUtils.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 548fa206d764..686041f80efa 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1003,16 +1003,12 @@ function replaceSubmitterEmails(rule: ApprovalWorkflowRule, newEmails: string[]) /** Merge `emailsToAdd` into `existingEmails`, preserving the order of `existingEmails` and dropping duplicates. */ function mergeEmails(existingEmails: string[], emailsToAdd: string[]): string[] { - const seen = new Set(existingEmails); - const result = [...existingEmails]; - for (const email of emailsToAdd) { - if (seen.has(email)) { - continue; - } - seen.add(email); - result.push(email); - } - return result; + const seen = new Set(existingEmails); + + return [ + ...existingEmails, + ...emailsToAdd.filter(email => !seen.has(email) && !!seen.add(email)), + ]; } /** Remove everything in `emailsToRemove` from `existingEmails`, preserving the order of `existingEmails`. */ From e8b8d2c1a411b2ac504e313c695e7152b9fa1b84 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 10 Aug 2026 18:42:30 +0430 Subject: [PATCH 139/466] Match the wallet on the Workflows bank row --- .../workflows/WorkspaceWorkflowsPage.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 4402daa57ee8..f148d870188f 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -495,7 +495,6 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const bankConnectionMessage = bankConnectionStatus?.messageKey ? translate(bankConnectionStatus.messageKey) : undefined; const bankConnectionActionText = bankConnectionStatus?.actionKey ? translate(bankConnectionStatus.actionKey) : undefined; const bankBadgeIcon = !isWalletConnectionStatusBetaEnabled && (isAccountInSetupState || (isBusinessBankAccountLocked && canWritePayments)) ? expensifyIcons.DotIndicator : undefined; - const canInteractWithBankAccountRow = canWritePayments && !isOffline; const updateWorkspaceCurrencyPrompt = ( @@ -535,17 +534,10 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID), }); }; - let bankAccountMenuItemOnPress: React.ComponentProps['onPress']; - if (isWalletConnectionStatusBetaEnabled) { - bankAccountMenuItemOnPress = canInteractWithBankAccountRow ? handleBankAccountPress : undefined; - } else { - bankAccountMenuItemOnPress = canWritePayments ? handleBankAccountPress : undefined; - } - const bankAccountMenuItemProps: React.ComponentProps = { title: bankTitle, description: getPaymentMethodDescription(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, accountData, translate), - onPress: bankAccountMenuItemOnPress, + onPress: canWritePayments ? handleBankAccountPress : undefined, displayInDefaultIconColor: true, icon: bankIcon.icon, iconHeight: bankIcon.iconHeight ?? bankIcon.iconSize, @@ -553,13 +545,15 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { iconStyles: bankIcon.iconStyles, titleStyle: isBankAccountPendingDelete ? styles.offlineFeedbackDeleted : undefined, descriptionTextStyle: isBankAccountPendingDelete ? styles.offlineFeedbackDeleted : undefined, - disabled: isOffline || !canWritePayments, sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.BANK_ACCOUNT, shouldShowRightIcon: canWritePayments, interactive: canWritePayments, shouldGreyOutWhenDisabled: !policy?.pendingFields?.reimbursementChoice, ...(isWalletConnectionStatusBetaEnabled ? { + // The row stays pressable offline so it behaves like the same account in the wallet, which opens the + // bank account flow and lets that screen explain that the user is offline. + disabled: !canWritePayments, badgeIcon: bankBadgeIcon, descriptionAddon: bankConnectionStatusAddon, shouldRemoveBackground: true, @@ -568,6 +562,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { brickRoadIndicator: bankConnectionMessage ? undefined : bankConnectionBrickRoadIndicator, } : { + disabled: isOffline || !canWritePayments, badgeText: getBadgeText(accountData?.state), badgeIcon: isAccountInSetupState || (isBusinessBankAccountLocked && canWritePayments) ? expensifyIcons.DotIndicator : undefined, isBadgeSuccess: isAccountInSetupState, @@ -838,8 +833,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { From 6b05cea208afb1e92e69fb8e36b185b6bfdd3a4e Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 16:19:19 +0200 Subject: [PATCH 140/466] better comment --- src/libs/actions/Workflow.ts | 3 +++ .../approvals/WorkspaceWorkflowsApprovalsEditPage.tsx | 4 +++- tests/unit/WorkflowUtilsTest.ts | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index dced77e3d366..1ad6322528cc 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -411,6 +411,9 @@ function updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, /** * Delete an approval workflow using the rules-based backend structure. + * + * Returns false without calling the API when this policy has no rules covering the workflow's members, so the + * caller can fall back to the `employeeList` path. */ function removeApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow, policy: OnyxEntry, rules: OnyxCollection): boolean { if (!policy) { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index b1bea4455930..4b0e251a768e 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -112,9 +112,11 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true Navigation.dismissModal({ afterTransition: () => { // Remove the approval workflow using the initial data as it could be already edited - if (useRulesBackend && removeApprovalWorkflowRules(initialApprovalWorkflow, policy, rulesCollection)) { + const didRemoveRules = useRulesBackend && removeApprovalWorkflowRules(initialApprovalWorkflow, policy, rulesCollection); + if (didRemoveRules) { return; } + removeApprovalWorkflow(initialApprovalWorkflow, policy); }, }); diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 8bbbb0a3d3c7..a563dd2bee88 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1699,8 +1699,8 @@ describe('WorkflowUtils', () => { }); it('Should route an over-limit report to the over-limit approver instead of the limited one, then continue the chain', () => { - // Hola Dola approves up to 100.00; above that "a a" receives the report instead. Either way it carries - // on to the second approver afterwards. + // 1@example.com approves up to 100.00; above that 3@example.com receives the report instead. Either way + // it carries on to 4@example.com afterwards. const workflow: ApprovalWorkflow = { members: [buildMember(2)], approvers: [buildApprover(1, {approvalLimit: 10000, overLimitForwardsTo: '3@example.com'}), buildApprover(4)], From a24d7a041049320a62a1cbd751f9e0c7a73b45fd Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 16:27:13 +0200 Subject: [PATCH 141/466] uneeded helper function --- src/libs/WorkflowUtils.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 548fa206d764..21e59c1f9975 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1021,11 +1021,6 @@ function removeEmails(existingEmails: string[], emailsToRemove: string[]): strin return existingEmails.filter((email) => !removalSet.has(email)); } -/** True when any of `emails` is in `set` — i.e. a rule's submitters overlap a workflow's members. */ -function containsAnyEmail(emails: string[], set: Set): boolean { - return emails.some((email) => set.has(email)); -} - /** Fold `memberEmails` into a rule's `from` list (union), returning the updated rule. */ function addMembersToRule(rule: ApprovalWorkflowRule, memberEmails: string[]): ApprovalWorkflowRule { return replaceSubmitterEmails(rule, mergeEmails(extractSubmitterEmails(rule), memberEmails)); @@ -1082,7 +1077,7 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], // Pass 1: walk existing rules that belong (at least partially) to this workflow. for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { const ruleEmails = extractSubmitterEmails(existingRule); - if (!containsAnyEmail(ruleEmails, memberSet)) { + if (!ruleEmails.some((email) => memberSet.has(email))) { continue; } @@ -1117,7 +1112,7 @@ function reconcileApprovalWorkflowRulesForEdit(newRules: ApprovalWorkflowRule[], return false; } // "Different workflow" => no overlap with our members. - return !containsAnyEmail(extractSubmitterEmails(existing), memberSet); + return !extractSubmitterEmails(existing).some((email) => memberSet.has(email)); }); if (foreignMatch) { @@ -1143,7 +1138,7 @@ function reconcileApprovalWorkflowRulesForRemove(memberEmails: string[], context for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { const ruleEmails = extractSubmitterEmails(existingRule); - if (!containsAnyEmail(ruleEmails, memberSet)) { + if (!ruleEmails.some((email) => memberSet.has(email))) { continue; } @@ -1166,7 +1161,7 @@ function reconcileApprovalWorkflowRulesForMembersChange(previousMemberEmails: st for (const [ruleID, existingRule] of Object.entries(context.existingRules)) { const ruleEmails = extractSubmitterEmails(existingRule); - if (!containsAnyEmail(ruleEmails, previousSet)) { + if (!ruleEmails.some((email) => previousSet.has(email))) { continue; } From 0c69fc012404763c2dc86a33774a920936a4b9b6 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 16:46:40 +0200 Subject: [PATCH 142/466] fmt --- src/libs/WorkflowUtils.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index eabe40218d55..0b429976aa54 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1003,12 +1003,9 @@ function replaceSubmitterEmails(rule: ApprovalWorkflowRule, newEmails: string[]) /** Merge `emailsToAdd` into `existingEmails`, preserving the order of `existingEmails` and dropping duplicates. */ function mergeEmails(existingEmails: string[], emailsToAdd: string[]): string[] { - const seen = new Set(existingEmails); + const seen = new Set(existingEmails); - return [ - ...existingEmails, - ...emailsToAdd.filter(email => !seen.has(email) && !!seen.add(email)), - ]; + return [...existingEmails, ...emailsToAdd.filter((email) => !seen.has(email) && !!seen.add(email))]; } /** Remove everything in `emailsToRemove` from `existingEmails`, preserving the order of `existingEmails`. */ From 5ec188489349710069fd29715a7015a3fb2f4a17 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 19:10:15 +0200 Subject: [PATCH 143/466] missing language --- src/languages/el.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/languages/el.ts b/src/languages/el.ts index f9cb77991895..c54e399dd16b 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1002,6 +1002,12 @@ const translations: TranslationDeepObject = { subtitle: 'Λογαριασμός', cta: 'Επικυρώστε', }, + addHomeAddress: { + title: 'Προσθέστε διεύθυνση κατοικίας', + subtitle: + 'Ο διαχειριστής σας ενεργοποίησε τις εξαιρέσεις μετακίνησης για τα έξοδα απόστασης. Προσθέστε μια διεύθυνση κατοικίας ώστε να μπορούμε να τις εφαρμόσουμε στα αιτήματά σας.', + cta: 'Προσθήκη διεύθυνσης', + }, fixFailedBilling: { title: 'Δεν μπορέσαμε να χρεώσουμε την αποθηκευμένη κάρτα σας', subtitle: 'Συνδρομή', @@ -1282,6 +1288,14 @@ const translations: TranslationDeepObject = { createTimeExpense: 'Δημιουργία χρονοχρέωσης', }, iou: { + homeAddressRequired: { + title: 'Η διεύθυνση κατοικίας είναι υποχρεωτική', + prompt: ({workspaceName}: {workspaceName: string}) => + workspaceName + ? `Προτού καταγράψετε απόσταση, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό σας προφίλ. Ο χώρος εργασίας ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για τις εκπτώσεις μετακίνησης.` + : 'Προτού καταγράψετε απόσταση, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό σας προφίλ. Αυτός ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για τις εκπτώσεις μετακίνησης.', + cta: 'Προσθήκη διεύθυνσης κατοικίας', + }, amount: 'Ποσό', percent: 'Ποσοστό', date: 'Ημερομηνία', @@ -7342,13 +7356,24 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ title: 'Εξαίρεση μετακινήσεων από και προς την εργασία', summaryDisabled: 'Χωρίς εξαίρεση μετακίνησης', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Εξαίρεση ${distance} ${unit} ανά αίτημα`, + summaryHomeAndOffice: 'Χρήση τοποθεσιών κατοικίας και γραφείου', optionDisabledTitle: 'Να μην εξαιρούνται οι μετακινήσεις από και προς την εργασία', optionDisabledHelp: 'Δεν εφαρμόζεται καμία εξαίρεση μετακίνησης.', optionFixedDistanceTitle: 'Εξαίρεση σταθερής απόστασης ανά αίτημα', optionFixedDistanceHelp: 'Αφαιρέστε την ίδια απόσταση μετακίνησης από κάθε αίτημα. Ιδανικό για μέλη που υποβάλλουν ένα αίτημα ανά εργάσιμη ημέρα.', + optionHomeAndOfficeTitle: 'Υπολογισμός βάσει κατοικίας και γραφείου', + optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας του μέλους, τη διευθέτηση εργασίας και την ανάθεση γραφείου για τον υπολογισμό των εξαιρέσεων μετακίνησης.', distanceLabel: 'Απόσταση', + workspaceAddressRequired: { + title: 'Όχι τόσο γρήγορα...', + promptStart: 'Δεν μπορείτε να ενεργοποιήσετε τη ρύθμιση υπολογισμού βάσει κατοικίας και γραφείου έως ότου προσθέσετε πρώτα μια τοποθεσία γραφείου στην ', + linkText: 'Επισκόπηση', + promptEnd: '.', + cta: 'Το κατάλαβα', + }, errors: { distanceMustBePositive: 'Η απόσταση πρέπει να είναι ένας θετικός ακέραιος αριθμός.', + invalidAddress: 'Παρακαλώ εισαγάγετε μια έγκυρη διεύθυνση', distanceTooLarge: 'Η απόσταση είναι πολύ μεγάλη.', }, }, From 279e252192d5283e80ab7111bb10a76de3970d83 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 19:25:11 +0200 Subject: [PATCH 144/466] better translation --- src/languages/de.ts | 17 +++++++++-------- src/languages/el.ts | 16 ++++++++-------- src/languages/es.ts | 13 ++++++------- src/languages/fr.ts | 15 ++++++++------- src/languages/it.ts | 14 +++++++------- src/languages/ja.ts | 16 ++++++++-------- src/languages/nl.ts | 16 ++++++++-------- src/languages/pl.ts | 16 ++++++++-------- src/languages/pt-BR.ts | 12 ++++++------ src/languages/zh-hans.ts | 18 +++++++++--------- 10 files changed, 77 insertions(+), 76 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 660e8e6ceeee..855a92d76093 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -962,7 +962,8 @@ const translations: TranslationDeepObject = { validateAccount: {title: 'Bestätigen Sie Ihr Konto', subtitle: 'Konto', cta: 'Bestätigen'}, addHomeAddress: { title: 'Fügen Sie eine Privatadresse hinzu', - subtitle: 'Ihre Verwaltung hat Pendlerausnahmen für Entfernungskosten aktiviert. Fügen Sie eine Privatadresse hinzu, damit wir diese auf Ihre Abrechnungen anwenden können.', + subtitle: + 'Ihr Administrator hat Pendlerausschlüsse für Entfernungskosten aktiviert. Fügen Sie eine Privatadresse hinzu, damit wir diese auf Ihre Abrechnungen anwenden können.', cta: 'Adresse hinzufügen', }, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, @@ -1242,8 +1243,8 @@ const translations: TranslationDeepObject = { title: 'Privatadresse ist erforderlich', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` - : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. Dieser Workspace verwendet diese Adresse für Pendlerabzüge.', + ? `Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse zu Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` + : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse zu Ihrem privaten Profil hinzufügen. Dieser Workspace verwendet diese Adresse für Pendlerabzüge.', cta: 'Privatadresse hinzufügen', }, amount: 'Betrag', @@ -7107,18 +7108,18 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, summaryDisabled: 'Kein Ausschluss von Pendelstrecken', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `${distance} ${unit} pro Abrechnung ausschließen`, optionDisabledTitle: 'Pendelfahrten nicht ausschließen', - optionDisabledHelp: 'Keine Pendelfahrt wird von den Abrechnungen entfernt.', + optionDisabledHelp: 'Es wird keine Pendelstrecke von den Abrechnungen abgezogen.', optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', distanceLabel: 'Entfernung', summaryHomeAndOffice: 'Standort für Zuhause und Büro verwenden', optionHomeAndOfficeTitle: 'Nach Zuhause und Büro berechnen', - optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuordnung des Mitglieds, um Fahrtkostenausschlüsse zu berechnen.', + optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuordnung des Mitglieds, um Pendlerausschlüsse zu berechnen.', workspaceAddressRequired: { - title: 'Nur langsam …', - promptStart: 'Sie können die Einstellung „Nach Zuhause und Büro berechnen“ nicht aktivieren, bevor Sie nicht zuerst einen Bürostandort in hinzugefügt haben', + title: 'Nicht so schnell …', + promptStart: 'Sie können die Einstellung „Nach Zuhause und Büro berechnen“ erst aktivieren, wenn Sie zuerst einen Bürostandort in der ', linkText: 'Übersicht', - promptEnd: ' eine Büroadresse hinzugefügt hast.', + promptEnd: ' hinzugefügt haben.', cta: 'Verstanden', }, errors: { diff --git a/src/languages/el.ts b/src/languages/el.ts index c54e399dd16b..1c112c9f4cc2 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1003,9 +1003,9 @@ const translations: TranslationDeepObject = { cta: 'Επικυρώστε', }, addHomeAddress: { - title: 'Προσθέστε διεύθυνση κατοικίας', + title: 'Προσθέστε μια διεύθυνση κατοικίας', subtitle: - 'Ο διαχειριστής σας ενεργοποίησε τις εξαιρέσεις μετακίνησης για τα έξοδα απόστασης. Προσθέστε μια διεύθυνση κατοικίας ώστε να μπορούμε να τις εφαρμόσουμε στα αιτήματά σας.', + 'Ο διαχειριστής σας ενεργοποίησε τις εξαιρέσεις μετακίνησης για τις δαπάνες απόστασης. Προσθέστε μια διεύθυνση κατοικίας, ώστε να μπορούμε να τις εφαρμόζουμε στα αιτήματά σας.', cta: 'Προσθήκη διεύθυνσης', }, fixFailedBilling: { @@ -1289,11 +1289,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Η διεύθυνση κατοικίας είναι υποχρεωτική', + title: 'Απαιτείται διεύθυνση κατοικίας', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Προτού καταγράψετε απόσταση, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό σας προφίλ. Ο χώρος εργασίας ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για τις εκπτώσεις μετακίνησης.` - : 'Προτού καταγράψετε απόσταση, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό σας προφίλ. Αυτός ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για τις εκπτώσεις μετακίνησης.', + ? `Προτού καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό σας προφίλ. Ο χώρος εργασίας ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για τις εκπτώσεις μετακίνησης προς και από την εργασία.` + : 'Προτού καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό σας προφίλ. Αυτός ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για τις εκπτώσεις μετακίνησης προς και από την εργασία.', cta: 'Προσθήκη διεύθυνσης κατοικίας', }, amount: 'Ποσό', @@ -7358,11 +7358,11 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Εξαίρεση ${distance} ${unit} ανά αίτημα`, summaryHomeAndOffice: 'Χρήση τοποθεσιών κατοικίας και γραφείου', optionDisabledTitle: 'Να μην εξαιρούνται οι μετακινήσεις από και προς την εργασία', - optionDisabledHelp: 'Δεν εφαρμόζεται καμία εξαίρεση μετακίνησης.', + optionDisabledHelp: 'Καμία μετακίνηση δεν αφαιρείται από τα αιτήματα.', optionFixedDistanceTitle: 'Εξαίρεση σταθερής απόστασης ανά αίτημα', optionFixedDistanceHelp: 'Αφαιρέστε την ίδια απόσταση μετακίνησης από κάθε αίτημα. Ιδανικό για μέλη που υποβάλλουν ένα αίτημα ανά εργάσιμη ημέρα.', optionHomeAndOfficeTitle: 'Υπολογισμός βάσει κατοικίας και γραφείου', - optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας του μέλους, τη διευθέτηση εργασίας και την ανάθεση γραφείου για τον υπολογισμό των εξαιρέσεων μετακίνησης.', + optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας του μέλους, το καθεστώς εργασίας και την ανάθεση γραφείου για τον υπολογισμό των εξαιρέσεων μετακίνησης.', distanceLabel: 'Απόσταση', workspaceAddressRequired: { title: 'Όχι τόσο γρήγορα...', @@ -7373,7 +7373,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, errors: { distanceMustBePositive: 'Η απόσταση πρέπει να είναι ένας θετικός ακέραιος αριθμός.', - invalidAddress: 'Παρακαλώ εισαγάγετε μια έγκυρη διεύθυνση', + invalidAddress: 'Παρακαλούμε εισαγάγετε μια έγκυρη διεύθυνση', distanceTooLarge: 'Η απόσταση είναι πολύ μεγάλη.', }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index aa7244c4bb46..de757bbfed97 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -955,9 +955,8 @@ const translations: TranslationDeepObject = { cta: 'Validar', }, addHomeAddress: { - title: 'Añade una dirección de domicilio', - subtitle: - 'Tu administrador habilitó exclusiones de desplazamientos para los gastos de distancia. Añade una dirección de casa para que podamos aplicarlas a tus reclamaciones.', + title: 'Añade una dirección de casa', + subtitle: 'Tu administrador habilitó exclusiones de desplazamiento para los gastos de distancia. Añade una dirección de casa para que podamos aplicarlas a tus solicitudes.', cta: 'Añade dirección', }, fixFailedBilling: { @@ -1238,7 +1237,7 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'La dirección particular es obligatoria', + title: 'La dirección de casa es obligatoria', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName ? `Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. ${workspaceName} utiliza esta dirección para las deducciones por desplazamiento al trabajo.` @@ -7075,15 +7074,15 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, summaryHomeAndOffice: 'Usar ubicaciones de casa y oficina', optionDisabledTitle: 'No excluir los desplazamientos al trabajo', - optionDisabledHelp: 'Ningún desplazamiento se elimina de las solicitudes.', + optionDisabledHelp: 'No se elimina ningún desplazamiento de las solicitudes.', optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', optionFixedDistanceHelp: 'Resta la misma distancia de desplazamiento de cada solicitud. Ideal para personas que envían una solicitud por día laborable.', optionHomeAndOfficeTitle: 'Calcular por casa y oficina', - optionHomeAndOfficeHelp: 'Utiliza la dirección de casa del miembro, su modalidad de trabajo y la asignación de oficina para calcular las exclusiones de desplazamiento.', + optionHomeAndOfficeHelp: 'Usa la dirección de casa del miembro, su modalidad de trabajo y la asignación de oficina para calcular las exclusiones de desplazamiento.', distanceLabel: 'Distancia', workspaceAddressRequired: { title: 'No tan rápido...', - promptStart: 'No puedes habilitar el ajuste de calcular por casa y oficina hasta que primero añadas una ubicación de oficina en', + promptStart: 'No puedes habilitar el ajuste de cálculo por casa y oficina hasta que primero añadas una ubicación de oficina en ', linkText: 'Resumen', promptEnd: '.', cta: 'Entendido', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 093567e91872..c48fb1d26826 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -966,7 +966,7 @@ const translations: TranslationDeepObject = { addHomeAddress: { title: 'Ajouter une adresse personnelle', subtitle: - 'Votre administrateur a activé des exclusions de trajet domicile-travail pour les dépenses de distance. Ajoutez une adresse de domicile afin que nous puissions les appliquer à vos demandes.', + 'Votre administrateur a activé les exclusions de trajets domicile-travail pour les dépenses de distance. Ajoutez une adresse de domicile pour que nous puissions les appliquer à vos demandes.', cta: 'Ajouter une adresse', }, fixFailedBilling: {title: 'Nous n’avons pas pu débiter votre carte enregistrée', subtitle: 'Abonnement'}, @@ -1247,9 +1247,9 @@ const translations: TranslationDeepObject = { title: 'L’adresse du domicile est obligatoire', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions de frais de déplacement domicile-travail.` + ? `Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions liées aux trajets domicile-travail.` : 'Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions liées aux trajets domicile-travail.', - cta: 'Ajouter une adresse personnelle', + cta: 'Ajouter une adresse de domicile', }, amount: 'Montant', percent: 'Pourcentage', @@ -7136,16 +7136,17 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, summaryDisabled: 'Pas d’exclusion du trajet domicile-travail', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclure ${distance} ${unit} par demande`, optionDisabledTitle: 'Ne pas exclure les trajets domicile-travail', - optionDisabledHelp: 'Aucun trajet domicile-travail n’est supprimé des déclarations.', + optionDisabledHelp: 'Aucun trajet domicile-travail n’est retiré des demandes.', optionFixedDistanceTitle: 'Exclure une distance fixe par demande', optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', distanceLabel: 'Distance', - summaryHomeAndOffice: 'Utiliser les emplacements domicile et bureau', + summaryHomeAndOffice: 'Utiliser les adresses domicile et bureau', optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', - optionHomeAndOfficeHelp: 'Utilise l’adresse du domicile du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet domicile-travail.', + optionHomeAndOfficeHelp: + 'Utilisez l’adresse du domicile du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet domicile-travail.', workspaceAddressRequired: { title: 'Pas si vite…', - promptStart: 'Vous ne pouvez pas activer le paramètre de calcul domicile-bureau avant d’ajouter d’abord un lieu de bureau dans', + promptStart: 'Vous ne pouvez pas activer le paramètre de calcul domicile-bureau tant que vous n’avez pas ajouté un lieu de bureau dans l’', linkText: 'Aperçu', promptEnd: '.', cta: 'Compris', diff --git a/src/languages/it.ts b/src/languages/it.ts index 8e213679b3e8..610941191297 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -964,7 +964,7 @@ const translations: TranslationDeepObject = { addHomeAddress: { title: 'Aggiungi un indirizzo di casa', subtitle: - 'Il tuo amministratore ha abilitato le esclusioni per i tragitti casa-lavoro nelle spese chilometriche. Aggiungi un indirizzo di casa così potremo applicarle alle tue richieste di rimborso.', + 'Il tuo amministratore ha attivato le esclusioni per i tragitti casa-lavoro nelle spese chilometriche. Aggiungi un indirizzo di casa così potremo applicarle alle tue richieste di rimborso.', cta: 'Aggiungi indirizzo', }, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, @@ -1239,11 +1239,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: "L'indirizzo di casa è obbligatorio", + title: 'L’indirizzo di casa è obbligatorio', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Prima di tracciare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le detrazioni per i pendolari.` - : 'Prima di tracciare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro usa questo indirizzo per le detrazioni per pendolari.', + ? `Prima di tenere traccia della distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le detrazioni per pendolarismo.` + : 'Prima di tenere traccia della distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro usa questo indirizzo per le detrazioni per pendolarismo.', cta: 'Aggiungi indirizzo di casa', }, amount: 'Importo', @@ -7079,16 +7079,16 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, summaryDisabled: 'Nessuna esclusione per il tragitto', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Escludi ${distance} ${unit} per rimborso`, optionDisabledTitle: 'Non escludere gli spostamenti casa-lavoro', - optionDisabledHelp: 'Nessuno spostamento casa-lavoro viene rimosso dalle richieste.', + optionDisabledHelp: 'Nessun tragitto casa-lavoro viene rimosso dalle richieste.', optionFixedDistanceTitle: 'Escludi una distanza fissa per richiesta', optionFixedDistanceHelp: 'Detrai la stessa distanza del tragitto casa-lavoro da ogni richiesta. Ideale per chi invia una richiesta per ogni giorno lavorativo.', distanceLabel: 'Distanza', - summaryHomeAndOffice: 'Usa le posizioni di casa e ufficio', + summaryHomeAndOffice: 'Usa gli indirizzi di casa e ufficio', optionHomeAndOfficeTitle: 'Calcola per casa e ufficio', optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, la modalità di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni sul tragitto casa-lavoro.', workspaceAddressRequired: { title: 'Non così in fretta...', - promptStart: "Non puoi abilitare l'impostazione per calcolare in base a casa e ufficio finché non aggiungi prima una sede dell'ufficio in", + promptStart: 'Non puoi attivare l’impostazione “Calcola per casa e ufficio” finché non aggiungi prima una sede dell’ufficio in ', linkText: 'Panoramica', promptEnd: '.', cta: 'Capito', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4624f0935266..ae63561a4fc3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -948,7 +948,7 @@ const translations: TranslationDeepObject = { }, fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, validateAccount: {title: 'アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, - addHomeAddress: {title: '自宅住所を追加', subtitle: '管理者が距離経費に通勤分の除外を有効にしました。精算に適用できるよう、自宅住所を追加してください。', cta: '住所を追加'}, + addHomeAddress: {title: '自宅住所を追加', subtitle: '管理者が距離経費に通勤分の除外を有効にしました。申請に適用できるよう、自宅住所を追加してください。', cta: '住所を追加'}, fixFailedBilling: {title: '登録されているカードから請求できませんでした', subtitle: 'サブスクリプション'}, unlockBankAccount: { workspaceTitle: 'ビジネス用銀行口座がロックされました', @@ -1221,8 +1221,8 @@ const translations: TranslationDeepObject = { title: '自宅住所は必須です', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除のためにこの住所を使用します。` - : '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースは通勤控除のためにこの住所を使用します。', + ? `距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除にこの住所を使用します。` + : '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースは通勤控除にこの住所を使用します。', cta: '自宅住所を追加', }, amount: '金額', @@ -6994,18 +6994,18 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から summaryDisabled: '通勤除外なし', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `申請ごとに ${distance} ${unit} を除外します`, optionDisabledTitle: '通勤を除外しない', - optionDisabledHelp: '通勤分は申請から差し引かれていません。', + optionDisabledHelp: '申請から通勤分は差し引かれません。', optionFixedDistanceTitle: '申請ごとに一定距離を除外します', optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', distanceLabel: '距離', summaryHomeAndOffice: '自宅と勤務先の所在地を使用する', optionHomeAndOfficeTitle: '自宅とオフィス別に計算', - optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、およびオフィスの配属情報を使用して、通勤の非課税対象額を算出します。', + optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィスの割り当てを使用して、通勤分の除外を計算します。', workspaceAddressRequired: { - title: 'ちょっとお待ちください…', - promptStart: '自宅とオフィスで計算する設定は、先にオフィス所在地を追加しないと有効にできません', + title: 'ちょっと待ってください…', + promptStart: '「自宅とオフィスで計算」を有効にするには、まず', linkText: '概要', - promptEnd: ' でオフィスの住所を追加してください。', + promptEnd: 'でオフィスの住所を追加してください。', cta: '了解しました', }, errors: {distanceMustBePositive: '距離は正の整数で入力してください。', invalidAddress: '有効な住所を入力してください', distanceTooLarge: '距離が大きすぎます。'}, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6cd35c39e9b3..0a5bd299e44d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1240,9 +1240,9 @@ const translations: TranslationDeepObject = { title: 'Huisadres is vereist', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werk­aftrekken.` - : 'Voordat je een afstand bijhoudt, moet je je huisadres toevoegen aan je privéprofiel. Deze workspace gebruikt dit adres voor woon-werkverkeer­aftrek.', - cta: 'Thuisadres toevoegen', + ? `Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werkaftrek.` + : 'Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. Deze workspace gebruikt dit adres voor woon-werkaftrek.', + cta: 'Huisadres toevoegen', }, amount: 'Bedrag', percent: 'Procent', @@ -7066,16 +7066,16 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, summaryDisabled: 'Geen woon-werkuitzondering', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Sluit ${distance} ${unit} per declaratie uit`, optionDisabledTitle: 'Woon-werkverkeer niet uitsluiten', - optionDisabledHelp: 'Geen woon-werkverkeer is uit declaraties verwijderd.', + optionDisabledHelp: 'Er wordt geen woon-werkverkeer uit declaraties verwijderd.', optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', distanceLabel: 'Afstand', - summaryHomeAndOffice: 'Gebruik thuis- en kantoorlokaties', - optionHomeAndOfficeTitle: 'Bereken per huis en kantoor', - optionHomeAndOfficeHelp: 'Gebruik het thuisadres, de werkafspraak en de kantoorindeling van het lid om woon-werkuitzonderingen te berekenen.', + summaryHomeAndOffice: 'Huis- en kantoorlocaties gebruiken', + optionHomeAndOfficeTitle: 'Bereken op basis van huis en kantoor', + optionHomeAndOfficeHelp: 'Gebruik het huisadres, de werkregeling en de kantoorindeling van het lid om woon-werkuitzonderingen te berekenen.', workspaceAddressRequired: { title: 'Niet zo snel...', - promptStart: 'Je kunt de instelling ‘berekenen op basis van thuis en kantoor’ pas inschakelen nadat je eerst een kantoorlocatie hebt toegevoegd in', + promptStart: 'Je kunt de instelling ‘berekenen op basis van huis en kantoor’ pas inschakelen nadat je eerst een kantoorlocatie hebt toegevoegd in ', linkText: 'Overzicht', promptEnd: '.', cta: 'Begrepen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index bead0e8c63f8..aeaca4f7018c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -963,7 +963,7 @@ const translations: TranslationDeepObject = { validateAccount: {title: 'Zweryfikuj swoje konto', subtitle: 'Konto', cta: 'Zatwierdź'}, addHomeAddress: { title: 'Dodaj adres domowy', - subtitle: 'Twój administrator włączył wyłączenia dojazdów dla wydatków odległościowych. Dodaj adres domowy, żebyśmy mogli zastosować je do twoich rozliczeń.', + subtitle: 'Administrator włączył wykluczenia dojazdów dla wydatków odległościowych. Dodaj adres domowy, żebyśmy mogli zastosować je do twoich rozliczeń.', cta: 'Dodaj adres', }, fixFailedBilling: {title: 'Nie mogliśmy obciążyć zapisanej karty', subtitle: 'Subskrypcja'}, @@ -1236,8 +1236,8 @@ const translations: TranslationDeepObject = { title: 'Adres domowy jest wymagany', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy do pracy.` - : 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. To miejsce pracy wykorzystuje ten adres do odliczeń za dojazdy.', + ? `Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy.` + : 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. Ta przestrzeń robocza używa tego adresu do odliczeń za dojazdy.', cta: 'Dodaj adres domowy', }, amount: 'Kwota', @@ -7044,21 +7044,21 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, summaryDisabled: 'Bez wykluczenia dojazdów', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Wyklucz ${distance} ${unit} na zgłoszenie`, optionDisabledTitle: 'Nie wykluczaj dojazdów', - optionDisabledHelp: 'Nie usunięto żadnego dojazdu z rozliczeń.', + optionDisabledHelp: 'Żaden dojazd nie jest usuwany z rozliczeń.', optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', distanceLabel: 'Dystans', summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', optionHomeAndOfficeTitle: 'Oblicz według domu i biura', - optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy i przypisania do biura, aby obliczyć wyłączenia z dojazdów.', + optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy i przypisanego biura, aby obliczyć wykluczenia dojazdów.', workspaceAddressRequired: { title: 'Nie tak szybko…', - promptStart: 'Nie możesz włączyć ustawienia obliczania według domu i biura, dopóki najpierw nie dodasz lokalizacji biura w', + promptStart: 'Nie możesz włączyć ustawienia obliczania według domu i biura, dopóki najpierw nie dodasz lokalizacji biura w ', linkText: 'Przegląd', promptEnd: '.', - cta: 'Jasne', + cta: 'Rozumiem', }, - errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', invalidAddress: 'Wpisz prawidłowy adres', distanceTooLarge: 'Odległość jest zbyt duża.'}, + errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', invalidAddress: 'Podaj prawidłowy adres', distanceTooLarge: 'Odległość jest zbyt duża.'}, }, distance: 'Dystans', centrallyManage: 'Centralnie zarządzaj stawkami, śledź w milach lub kilometrach i ustaw domyślną kategorię.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4cad802c7291..cde9315dfb4e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1240,8 +1240,8 @@ const translations: TranslationDeepObject = { title: 'Endereço residencial é obrigatório', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` - : 'Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.', + ? `Antes de registrar distâncias, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` + : 'Antes de registrar distâncias, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.', cta: 'Adicionar endereço residencial', }, amount: 'Valor', @@ -7066,16 +7066,16 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, summaryDisabled: 'Sem exclusão de deslocamento', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por solicitação`, optionDisabledTitle: 'Não excluir deslocamentos de casa para o trabalho', - optionDisabledHelp: 'Nenhum trajeto de deslocamento é removido das solicitações.', + optionDisabledHelp: 'Nenhum deslocamento é removido das solicitações.', optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', distanceLabel: 'Distância', - summaryHomeAndOffice: 'Usar endereços de casa e do trabalho', + summaryHomeAndOffice: 'Usar endereços de casa e do escritório', optionHomeAndOfficeTitle: 'Calcular por casa e escritório', - optionHomeAndOfficeHelp: 'Use o endereço residencial, o regime de trabalho e a alocação de escritório da pessoa colaboradora para calcular as exclusões de deslocamento.', + optionHomeAndOfficeHelp: 'Use o endereço residencial do membro, o regime de trabalho e a designação de escritório para calcular as exclusões de deslocamento.', workspaceAddressRequired: { title: 'Calma lá...', - promptStart: 'Você não pode ativar a configuração de cálculo por casa e escritório até primeiro adicionar um endereço de escritório em', + promptStart: 'Você não pode ativar a configuração de cálculo por casa e escritório até adicionar primeiro um endereço de escritório em ', linkText: 'Visão geral', promptEnd: '.', cta: 'Entendi', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 32146c0438ec..811ee014fbbd 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -929,7 +929,7 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, validateAccount: {title: '验证您的账户', subtitle: '账户', cta: '验证'}, - addHomeAddress: {title: '添加家庭住址', subtitle: '您的管理员已为里程报销启用了通勤排除。请添加家庭住址,以便我们将其应用于您的报销申请。', cta: '添加地址'}, + addHomeAddress: {title: '添加家庭住址', subtitle: '您的管理员已为里程报销启用了通勤排除。请添加家庭住址,以便我们将其应用到您的报销申请中。', cta: '添加地址'}, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, unlockBankAccount: { workspaceTitle: '您的企业银行账户已被锁定', @@ -1184,11 +1184,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: '必须填写家庭住址', + title: '需要填写家庭住址', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `在记录里程之前,您需要在个人资料中添加家庭住址。${workspaceName} 会使用此地址计算通勤扣除。` - : '在开始记录行程前,您需要在个人资料中添加家庭住址。此工作区会使用该地址用于通勤扣除。', + ? `在开始记录里程之前,您需要先在个人资料中添加家庭住址。${workspaceName} 会使用该地址计算通勤扣除。` + : '在开始记录里程之前,您需要先在个人资料中添加家庭住址。此工作区会使用该地址计算通勤扣除。', cta: '添加家庭住址', }, amount: '金额', @@ -6827,21 +6827,21 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM summaryDisabled: '不排除通勤', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `每次报销排除 ${distance} ${unit}`, optionDisabledTitle: '不要排除通勤', - optionDisabledHelp: '未从报销中移除任何通勤费用。', + optionDisabledHelp: '不会从报销申请中扣除任何通勤里程。', optionFixedDistanceTitle: '为每笔报销排除固定距离', optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', distanceLabel: '距离', summaryHomeAndOffice: '使用家庭和办公地点', optionHomeAndOfficeTitle: '按家庭和办公室计算', - optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公室分配来计算通勤豁免。', + optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公地点分配来计算通勤扣除。', workspaceAddressRequired: { title: '别急……', - promptStart: '在您先添加一个办公室地址之前,无法启用“按家庭和办公室计算”设置,添加位置于', + promptStart: '您需要先在', linkText: '概览', - promptEnd: '中添加办公室地址之前,无法启用按家庭和办公室计算的设置。', + promptEnd: '中添加办公地点,才能启用“按家庭和办公室计算”设置。', cta: '知道了', }, - errors: {distanceMustBePositive: '距离必须是一个正整数。', invalidAddress: '请输入有效的地址', distanceTooLarge: '距离过大。'}, + errors: {distanceMustBePositive: '距离必须是一个正整数。', invalidAddress: '请输入有效地址', distanceTooLarge: '距离过大。'}, }, distance: '距离', centrallyManage: '集中管理费率,以英里或公里跟踪,并设置默认类别。', From a045ce32f89e360171423e17061bf2d093935de6 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 19:27:28 +0200 Subject: [PATCH 145/466] better performance --- .../hooks/useTimeSensitiveHomeAddress.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts index debd893b9fbb..85e77007be9c 100644 --- a/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts +++ b/src/pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress.ts @@ -2,6 +2,16 @@ import useOnyx from '@hooks/useOnyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PrivatePersonalDetails} from '@src/types/onyx'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +const hasHomeAndOfficeWorkspaceSelector = (policies: OnyxCollection) => + Object.values(policies ?? {}).some( + (policy) => policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + +const hasHomeAddressSelector = (privatePersonalDetails: OnyxEntry) => (privatePersonalDetails?.addresses ?? []).some((address) => !!address?.street?.trim()); /** * Surfaces the "Add a home address" item under Home > Time sensitive when the user belongs to at @@ -9,17 +19,11 @@ import ONYXKEYS from '@src/ONYXKEYS'; * no home address recorded in their private personal details. */ function useTimeSensitiveHomeAddress() { - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); - - const hasHomeAndOfficeWorkspace = Object.values(allPolicies ?? {}).some( - (policy) => policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - ); - - const hasHomeAddress = (privatePersonalDetails?.addresses ?? []).some((address) => !!address?.street?.trim()); + const [hasHomeAndOfficeWorkspace] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasHomeAndOfficeWorkspaceSelector}); + const [hasHomeAddress] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {selector: hasHomeAddressSelector}); return { - shouldShowAddHomeAddress: hasHomeAndOfficeWorkspace && !hasHomeAddress, + shouldShowAddHomeAddress: !!hasHomeAndOfficeWorkspace && !hasHomeAddress, }; } From 90cba6ef9fa68a1e6b61bb748a9172c0061ce3b0 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Mon, 10 Aug 2026 17:33:17 +0000 Subject: [PATCH 146/466] Validate normalized field value and add InputWrapper submit-form tests Co-authored-by: mkhutornyi --- src/pages/EditReportFieldText.tsx | 5 +- tests/unit/InputWrapperTest.tsx | 145 ++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 tests/unit/InputWrapperTest.tsx diff --git a/src/pages/EditReportFieldText.tsx b/src/pages/EditReportFieldText.tsx index 362a6a4df4a2..c1f02a48dfff 100644 --- a/src/pages/EditReportFieldText.tsx +++ b/src/pages/EditReportFieldText.tsx @@ -50,7 +50,10 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f const validate = useCallback( (values: FormOnyxValues) => { const errors: FormInputErrors = {}; - const inputValue = values[fieldKey].trim(); + // Validate the same value we submit: handleSubmit squashes line breaks to spaces before saving, so + // normalize here too. Otherwise a formula like `{field:Client\nName}` would be validated as a reference + // to `Client\nName` but saved as `{field:Client Name}`, letting a circular reference slip past this check. + const inputValue = StringUtils.lineBreaksToSpaces(values[fieldKey]).trim(); if (isRequired && inputValue === '') { errors[fieldKey] = translate('common.error.fieldRequired'); diff --git a/tests/unit/InputWrapperTest.tsx b/tests/unit/InputWrapperTest.tsx new file mode 100644 index 000000000000..4938b29d1e6f --- /dev/null +++ b/tests/unit/InputWrapperTest.tsx @@ -0,0 +1,145 @@ +import {render} from '@testing-library/react-native'; + +import FormContext from '@components/Form/FormContext'; +import type {RegisterInput} from '@components/Form/FormContext'; +import InputWrapper from '@components/Form/InputWrapper'; +import TextInput from '@components/TextInput'; + +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; + +import React from 'react'; + +// Replace TextInput with a lightweight stand-in. Because InputWrapper builds its `textInputBasedComponents` +// Set from the same `@components/TextInput` module reference, the mock is treated as a text-based input by +// identity - which is exactly the branch we want to exercise - while rendering to nothing. +jest.mock('@components/TextInput', () => { + function MockTextInput() { + return null; + } + return {__esModule: true, default: MockTextInput}; +}); + +jest.mock('@libs/DeviceCapabilities', () => ({ + __esModule: true, + canUseTouchScreen: jest.fn(), + hasHoverSupport: jest.fn(), +})); + +const mockCanUseTouchScreen = jest.mocked(canUseTouchScreen); + +type RegistrationResult = { + /** The `shouldSubmitForm` value InputWrapper computed and passed to `registerInput` */ + shouldSubmitForm?: boolean; + + /** The `submitBehavior` InputWrapper resolved for the input */ + submitBehavior?: string; +}; + +/** + * Render InputWrapper with a spied `registerInput` and return the params it computed. + * `registerInput` receives (inputID, shouldSubmitForm, inputProps), so we read the computed + * `shouldSubmitForm` and the resolved `submitBehavior` straight off the recorded call. + */ +function renderAndCaptureRegistration(props: Partial> & {shouldSubmitForm?: boolean}): RegistrationResult { + const registerInput: jest.MockedFunction = jest.fn((_inputID, _shouldSubmitForm, inputProps) => inputProps); + const contextValue = { + registerInput, + getErrorAnnouncementKey: () => 0, + getFallbackAnnouncementMessage: () => '', + }; + + render( + + + , + ); + + const call = registerInput.mock.calls.at(0); + return {shouldSubmitForm: call?.[1], submitBehavior: call?.[2]?.submitBehavior}; +} + +describe('InputWrapper - shouldReallySubmitForm', () => { + afterEach(() => { + mockCanUseTouchScreen.mockReset(); + }); + + describe('autoGrowSingleLine text input', () => { + it('always submits the form and blurs, even on a touch device with no hardware keyboard', () => { + mockCanUseTouchScreen.mockReturnValue(true); + + const {shouldSubmitForm, submitBehavior} = renderAndCaptureRegistration({autoGrowSingleLine: true}); + + expect(shouldSubmitForm).toBe(true); + expect(submitBehavior).toBe('blurAndSubmit'); + }); + + it('always submits the form when a hardware keyboard is available', () => { + mockCanUseTouchScreen.mockReturnValue(false); + + const {shouldSubmitForm, submitBehavior} = renderAndCaptureRegistration({autoGrowSingleLine: true}); + + expect(shouldSubmitForm).toBe(true); + expect(submitBehavior).toBe('blurAndSubmit'); + }); + + it('ignores an explicit shouldSubmitForm=false and still submits', () => { + mockCanUseTouchScreen.mockReturnValue(true); + + const {shouldSubmitForm} = renderAndCaptureRegistration({autoGrowSingleLine: true, shouldSubmitForm: false}); + + expect(shouldSubmitForm).toBe(true); + }); + }); + + describe('plain single-line text input', () => { + it('force-enables submission and forwards the provided submitBehavior', () => { + mockCanUseTouchScreen.mockReturnValue(true); + + const {shouldSubmitForm, submitBehavior} = renderAndCaptureRegistration({submitBehavior: 'submit'}); + + expect(shouldSubmitForm).toBe(true); + // Not multiline, so submitBehavior is passed through untouched (not overridden to 'blurAndSubmit'). + expect(submitBehavior).toBe('submit'); + }); + }); + + describe('multi-line text input', () => { + it('submits when shouldSubmitForm is requested and a hardware keyboard is available', () => { + mockCanUseTouchScreen.mockReturnValue(false); + + const {shouldSubmitForm, submitBehavior} = renderAndCaptureRegistration({multiline: true, shouldSubmitForm: true}); + + expect(shouldSubmitForm).toBe(true); + expect(submitBehavior).toBe('blurAndSubmit'); + }); + + it('does not submit on a touch device even when shouldSubmitForm is requested', () => { + mockCanUseTouchScreen.mockReturnValue(true); + + const {shouldSubmitForm} = renderAndCaptureRegistration({multiline: true, shouldSubmitForm: true}); + + expect(shouldSubmitForm).toBe(false); + }); + + it('does not submit when shouldSubmitForm is not requested', () => { + mockCanUseTouchScreen.mockReturnValue(false); + + const {shouldSubmitForm} = renderAndCaptureRegistration({multiline: true, shouldSubmitForm: false}); + + expect(shouldSubmitForm).toBe(false); + }); + + it('treats autoGrowHeight as multi-line', () => { + mockCanUseTouchScreen.mockReturnValue(true); + + const {shouldSubmitForm} = renderAndCaptureRegistration({autoGrowHeight: true, shouldSubmitForm: true}); + + // On a touch device with no hardware keyboard, a multi-line input must reserve the return key for new lines. + expect(shouldSubmitForm).toBe(false); + }); + }); +}); From 831b73fd8a36f5a5e3d88dcf64075947e67f0c13 Mon Sep 17 00:00:00 2001 From: alberto Date: Mon, 10 Aug 2026 19:51:26 +0200 Subject: [PATCH 147/466] remove semicolons --- src/libs/actions/Policy/DistanceRate.ts | 2 +- .../report/actionContents/HomeAddressRequiredContent.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/DistanceRate.ts b/src/libs/actions/Policy/DistanceRate.ts index e41a337283c3..ae89a1058bfd 100644 --- a/src/libs/actions/Policy/DistanceRate.ts +++ b/src/libs/actions/Policy/DistanceRate.ts @@ -580,7 +580,7 @@ function setPolicyCommuterExclusions( ], }; - // Only send distance when the server actually needs it; homeAndOffice ignores the field. + // Only send distance when the server actually needs it. HomeAndOffice ignores the field. const parameters: SetPolicyCommuterExclusionsParams = method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE ? {policyID, commuterExclusionMethod: method, distance: fixedDistance} : {policyID, commuterExclusionMethod: method}; API.write(WRITE_COMMANDS.SET_POLICY_COMMUTER_EXCLUSIONS, parameters, onyxData); diff --git a/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx index 2a418f41c17e..7a6fd4174003 100644 --- a/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx +++ b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx @@ -23,8 +23,8 @@ function HomeAddressRequiredContent({action}: HomeAddressRequiredContentProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - // The prompt is resolved once the member saves a home address; the backend clears the actionable state and - // stamps a resolution, so hide the CTA to avoid pointing at an already-completed task. + // The prompt is resolved once the member saves a home address. The backend clears the actionable state + // and stamps a resolution, so hide the CTA to avoid pointing at a task that is already done. const isResolved = !!getOriginalMessage(action)?.resolution; return ( From 24e79e792fe21f05c58ed5fe56ac8aff87856f01 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Mon, 10 Aug 2026 18:17:18 +0000 Subject: [PATCH 148/466] Fix typecheck: pass InputWrapper test props explicitly instead of spreading a broad Partial Co-authored-by: mkhutornyi --- tests/unit/InputWrapperTest.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/unit/InputWrapperTest.tsx b/tests/unit/InputWrapperTest.tsx index 4938b29d1e6f..d08d0e9e286e 100644 --- a/tests/unit/InputWrapperTest.tsx +++ b/tests/unit/InputWrapperTest.tsx @@ -7,6 +7,8 @@ import TextInput from '@components/TextInput'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import type {SubmitBehavior} from 'react-native'; + import React from 'react'; // Replace TextInput with a lightweight stand-in. Because InputWrapper builds its `textInputBasedComponents` @@ -27,12 +29,20 @@ jest.mock('@libs/DeviceCapabilities', () => ({ const mockCanUseTouchScreen = jest.mocked(canUseTouchScreen); +type RegistrationInput = { + autoGrowSingleLine?: boolean; + autoGrowHeight?: boolean; + multiline?: boolean; + shouldSubmitForm?: boolean; + submitBehavior?: SubmitBehavior; +}; + type RegistrationResult = { /** The `shouldSubmitForm` value InputWrapper computed and passed to `registerInput` */ shouldSubmitForm?: boolean; /** The `submitBehavior` InputWrapper resolved for the input */ - submitBehavior?: string; + submitBehavior?: SubmitBehavior; }; /** @@ -40,7 +50,7 @@ type RegistrationResult = { * `registerInput` receives (inputID, shouldSubmitForm, inputProps), so we read the computed * `shouldSubmitForm` and the resolved `submitBehavior` straight off the recorded call. */ -function renderAndCaptureRegistration(props: Partial> & {shouldSubmitForm?: boolean}): RegistrationResult { +function renderAndCaptureRegistration({autoGrowSingleLine, autoGrowHeight, multiline, shouldSubmitForm, submitBehavior}: RegistrationInput): RegistrationResult { const registerInput: jest.MockedFunction = jest.fn((_inputID, _shouldSubmitForm, inputProps) => inputProps); const contextValue = { registerInput, @@ -53,7 +63,11 @@ function renderAndCaptureRegistration(props: Partial , ); From ada29a107694e29ad3da43d6ad7dd6d9bc634df7 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Mon, 10 Aug 2026 20:03:07 +0000 Subject: [PATCH 149/466] Reword Authorized Payer role-lock comment per review suggestion Co-authored-by: Shridhar Goel --- src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 43232cfef383..d45aa125b522 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -147,7 +147,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - // An Authorized Payer (reimburser) must stay an admin, so lock the Role row only once they are already an Admin — every + // An Authorized Payer (reimburser) must be either an admin or a payments admin. Lock the Role row only once they are already an Admin — every // remaining change would be a demotion that breaks reimbursements. A non-admin payer can still be promoted to Admin. const isReimburserAdmin = isReimburser && member?.role === CONST.POLICY.ROLE.ADMIN; const {isAccountLocked} = useLockedAccountState(); From d27cd9924d25b4174a898c9422520637e3a92709 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Mon, 10 Aug 2026 20:32:23 +0000 Subject: [PATCH 150/466] Allow the Authorized Payer to be Admin or Payments Admin, not Admin only Co-authored-by: Shridhar Goel --- src/components/WorkspaceMemberRoleList.tsx | 2 +- src/pages/workspace/WorkspaceMembersPage.tsx | 6 ++-- .../members/WorkspaceMemberDetailsPage.tsx | 12 ++----- .../WorkspaceMemberDetailsRolePage.tsx | 8 ++--- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 10 +++--- tests/ui/WorkspaceMembersTest.tsx | 34 ++++++++++++------- 6 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index 0e1c85ad8042..b569350f1969 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -35,7 +35,7 @@ type WorkspaceMemberRoleListProps = { isLoading?: boolean; onSelectRole?: (value: ListItemType) => void; - /** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be promoted to Admin) */ + /** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be an Admin or Payments Admin) */ allowedRoles?: Array>; }; diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 66c57827ec41..7548bd96d656 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -590,7 +590,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(memberOption); } - if (hasAtLeastOneNonAdminRole && !hasAtLeastOnePayer && canAssignElevatedRoles) { + // Admin is a valid payer role, so the payer may be promoted to Admin (Admin and Payments Admin are the two roles that can pay). + if (hasAtLeastOneNonAdminRole && canAssignElevatedRoles) { options.push(adminOption); } @@ -612,7 +613,8 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(peopleAdminOption); } - if (hasAtLeastOneNonPaymentsAdminRole && isControlPolicy(policy) && !hasAtLeastOnePayer && canAssignElevatedRoles) { + // Payments Admin is a valid payer role, so the payer may be changed to Payments Admin (Admin and Payments Admin are the two roles that can pay). + if (hasAtLeastOneNonPaymentsAdminRole && isControlPolicy(policy) && canAssignElevatedRoles) { options.push(paymentsAdminOption); } diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index d45aa125b522..49f8d692e036 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -16,7 +16,6 @@ import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useEnvironment from '@hooks/useEnvironment'; import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -114,7 +113,6 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const illustrations = useThemeIllustrations(); const companyCardFeedIcons = useCompanyCardFeedIcons(); const {accountID: currentUserAccountID, login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); - const {environmentURL} = useEnvironment(); const [cardFeeds] = useCardFeeds(policyID); const [cardList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`); const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); @@ -142,14 +140,10 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails); const policyOwnerDisplayName = formatPhoneNumber(temporaryGetDisplayNameOrDefault({passedPersonalDetails: ownerDetails, translate})) ?? policy?.owner ?? ''; const {cardList: assignableCards, ...workspaceCards} = getAllCardsForWorkspace(workspaceAccountID, cardList, cardFeeds, expensifyCardSettings); - const workspaceWorkflowsPageURL = `${environmentURL}/${ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)}`; const isSMSLogin = Str.isSMSLogin(memberLogin); const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - // An Authorized Payer (reimburser) must be either an admin or a payments admin. Lock the Role row only once they are already an Admin — every - // remaining change would be a demotion that breaks reimbursements. A non-admin payer can still be promoted to Admin. - const isReimburserAdmin = isReimburser && member?.role === CONST.POLICY.ROLE.ADMIN; const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); @@ -383,9 +377,9 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM { if ( @@ -400,8 +394,6 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM } Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.getRoute(policyID, accountID)); }} - hintText={isReimburserAdmin ? translate('common.roleCannotBeChanged', workspaceWorkflowsPageURL) : undefined} - shouldRenderHintAsHTML /> {isControlPolicy(policy) && ( <> diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index a754989b6edf..de2f5614d73d 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -40,10 +40,10 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); - // The Authorized Payer (reimburser) must remain an admin, so they may only be promoted to Admin — never demoted to another role. + // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to Admin or Payments Admin — the two roles that can pay. const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - const allowedRoles = isReimburser ? [CONST.POLICY.ROLE.ADMIN] : undefined; + const allowedRoles = isReimburser ? [CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN] : undefined; useRedirectSubmitWorkspaceFeatureUpgrade({ policy, backTo: ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID), @@ -57,8 +57,8 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp if (!canMemberAssignRole(policy, currentUserLogin, value)) { return; } - // Guard the direct-navigation path: a reimburser can only be promoted to Admin, so reject any demotion. - if (isReimburser && value !== CONST.POLICY.ROLE.ADMIN) { + // Guard the direct-navigation path: a reimburser must stay a valid payer, so reject any role other than Admin or Payments Admin. + if (isReimburser && value !== CONST.POLICY.ROLE.ADMIN && value !== CONST.POLICY.ROLE.PAYMENTS_ADMIN) { return; } if (value !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index ffedaa0050b7..9a4bc5594b77 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -250,8 +250,8 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); - it('should lock the Role field for an Authorized Payer who is already an Admin', async () => { - // The admin member is the workspace Authorized Payer — every remaining role change would be a demotion. + it('should not lock the Role field for an Authorized Payer who is already an Admin so they can be changed to Payments Admin', async () => { + // The admin member is the workspace Authorized Payer — Payments Admin is also a valid payer, so a lateral change is allowed. await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, @@ -266,10 +266,8 @@ describe('WorkspaceMemberDetailsPage', () => { expect(screen.getByTestId('WorkspaceMemberDetailsPage')).toBeOnTheScreen(); }); - // The locked hint IS shown — an admin payer cannot be demoted while they remain the payer. - await waitFor(() => { - expect(screen.getByText(/Role can/)).toBeOnTheScreen(); - }); + // The locked hint must NOT be shown — an admin payer can still be changed to Payments Admin, another valid payer role. + expect(screen.queryByText(/Role can/)).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index 2876f57b72bc..0e5d5ea4d547 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -426,11 +426,10 @@ describe('WorkspaceMembers', () => { await waitForBatchedUpdatesWithAct(); }); - it('should hide role-change options when the selected member is the Authorized Payer resolved via policy.reimburser', async () => { + it('should hide demotions but offer Make payments admin when the selected member is the Authorized Payer resolved via policy.reimburser', async () => { // Given a workspace whose Authorized Payer is an admin configured through policy.reimburser - // (the canonical resolution) rather than achAccount.reimburser. On the buggy code the guard - // only read achAccount.reimburser, so it failed to recognize this payer and wrongly offered - // the role-change options. + // (the canonical resolution) rather than achAccount.reimburser. Demotions to roles that cannot + // pay must stay hidden, but changing to Payments Admin (the other valid payer role) must be offered. await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, @@ -456,7 +455,7 @@ describe('WorkspaceMembers', () => { expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen(); }); - // ...and none of the role-change options are offered for the payer + // ...the demotions that would strip the payer of pay capability are hidden const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); expect(screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`)).not.toBeOnTheScreen(); @@ -466,16 +465,18 @@ describe('WorkspaceMembers', () => { const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1}); expect(screen.queryByTestId(`PopoverMenuItem-${makeCardAdminText}`)).not.toBeOnTheScreen(); + // ...but Make payments admin IS offered — Payments Admin is a valid payer role + const makePaymentsAdminText = TestHelper.translateLocal('workspace.people.makePaymentsAdmin', {count: 1}); + expect(screen.getByTestId(`PopoverMenuItem-${makePaymentsAdminText}`)).toBeOnTheScreen(); + unmount(); await waitForBatchedUpdatesWithAct(); }); - it('should hide the Make workspace admin option when the selected member is a Payments Admin who is the Authorized Payer', async () => { - // Given a Payments Admin who is also the Authorized Payer. PAYMENTS_ADMIN is the only non-admin - // role with write access to WORKFLOWS_PAYMENTS, so it is the sole role that can hold the payer - // role without already being an admin — which makes it the only path that can reach the - // "Make workspace admin" option. Every other role-change option is already gated on the payer, - // but adminOption was not, so it was wrongly offered for this payer. + it('should offer Make workspace admin but hide demotions when the selected member is a Payments Admin who is the Authorized Payer', async () => { + // Given a Payments Admin who is also the Authorized Payer. Admin and Payments Admin are both valid + // payer roles, so promoting this payer to Admin keeps them a valid payer and must be offered. + // Every demotion to a role that cannot pay (Member, Auditor, Card Admin) stays gated on the payer. await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, @@ -504,9 +505,16 @@ describe('WorkspaceMembers', () => { expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen(); }); - // ...but "Make workspace admin" is hidden for the payer, even though their role is not admin + // ...and "Make workspace admin" IS offered — Admin is a valid payer role const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}); - expect(screen.queryByTestId(`PopoverMenuItem-${makeAdminText}`)).not.toBeOnTheScreen(); + expect(screen.getByTestId(`PopoverMenuItem-${makeAdminText}`)).toBeOnTheScreen(); + + // ...but the demotions that would strip the payer of pay capability stay hidden + const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1}); + expect(screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`)).not.toBeOnTheScreen(); + + const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1}); + expect(screen.queryByTestId(`PopoverMenuItem-${makeAuditorText}`)).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); From 60d4dfeff07c2efab92b76b9c68333f95adf0d07 Mon Sep 17 00:00:00 2001 From: "Krishna (via MelvinBot)" Date: Mon, 10 Aug 2026 22:12:28 +0000 Subject: [PATCH 151/466] Docs: remove tag from expense preview details to match new preview card styling Co-authored-by: Krishna --- .../reports-and-expenses/Getting-Started-with-the-Spend-Page.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md index 47ecf9830798..a936960561c3 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md +++ b/docs/articles/new-expensify/reports-and-expenses/Getting-Started-with-the-Spend-Page.md @@ -20,7 +20,7 @@ Report previews are summaries shown directly in your workspace chat in the **Inb ## Preview Details - Report title and status (e.g., Approved, Paid) - Up to 10 expense previews with: - - Date, category, tag + - Date and category - Merchant or description - Receipt thumbnail - Carousel navigation From 69114f26a7f29af00722a5ae6ed162ebad71e16a Mon Sep 17 00:00:00 2001 From: "nkdengineer (via MelvinBot)" Date: Mon, 10 Aug 2026 22:18:44 +0000 Subject: [PATCH 152/466] Docs: note Personal Karma is web-only, disabled in mobile app Co-authored-by: nkdengineer --- .../Manage-Personal-Karma-and-Corporate-Karma.md | 4 +++- .../new-expensify/settings/Personal-Karma.md | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md index 5c5ddc821672..4954567723e6 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md @@ -28,7 +28,9 @@ Before you can enable either program, you must have a valid payment card on file ## How to enable or disable Personal Karma -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Account**. +Personal Karma can only be managed in a web browser. In the Expensify mobile app (iOS and Android), the **Enable Personal Karma** toggle is disabled and shows "Manage your personal karma preferences from web." + +1. In a web browser, go to **Account** from the navigation tabs on the left. 2. Select **Save the world**. 3. In the **Enable Personal Karma** section, turn Personal Karma on or off. 4. Add a payment card if prompted. diff --git a/docs/articles/new-expensify/settings/Personal-Karma.md b/docs/articles/new-expensify/settings/Personal-Karma.md index 7201d18ff105..73d51205c068 100644 --- a/docs/articles/new-expensify/settings/Personal-Karma.md +++ b/docs/articles/new-expensify/settings/Personal-Karma.md @@ -14,8 +14,10 @@ For every $500 in expenses you add, $1 is donated to a related Expensify.org fun ## How to enable Personal Karma -1. In the navigation tabs (on the left on web, at the bottom on mobile), choose **Account**. -2. Select **Save the World**. +Personal Karma can only be managed in a web browser. In the Expensify mobile app (iOS and Android), the **Enable Personal Karma** toggle is disabled and shows "Manage your personal karma preferences from web." + +1. In a web browser, select **Account** from the navigation tabs on the left. +2. Select **Save the world**. 3. Toggle **Enable Personal Karma** on. If you don't have a payment card on file, you'll be prompted to add one before Personal Karma can be enabled. If you dismiss the payment card prompt without adding a card, the toggle will revert to disabled. @@ -26,8 +28,10 @@ Once enabled, your billing card details are displayed below the toggle. ## How to disable Personal Karma -1. In the navigation tabs (on the left on web, at the bottom on mobile), choose **Account**. -2. Select **Save the World**. +Personal Karma can only be managed in a web browser. In the Expensify mobile app (iOS and Android), the **Enable Personal Karma** toggle is disabled and shows "Manage your personal karma preferences from web." + +1. In a web browser, select **Account** from the navigation tabs on the left. +2. Select **Save the world**. 3. Toggle **Enable Personal Karma** off. 4. A confirmation prompt will ask if you want to stop donating to Expensify.org. Select **Disable** to confirm, or **Cancel** to keep Personal Karma enabled. From af3363104982a8eabb84ea68a9052b834de81388 Mon Sep 17 00:00:00 2001 From: "Situ Chandra Shil (via MelvinBot)" Date: Tue, 11 Aug 2026 03:15:11 +0000 Subject: [PATCH 153/466] Use success (green) style for Settle balance confirm button Co-authored-by: Situ Chandra Shil --- src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx index e32a13215369..545eb391288d 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -126,7 +126,6 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr const handleSettleBalanceButtonClick = () => { showConfirmModal({ - danger: true, title: translate('workspace.expensifyCard.settleBalanceConfirmationTitle'), prompt: translate('workspace.expensifyCard.settleBalanceConfirmationPrompt'), confirmText: translate('workspace.expensifyCard.settleBalance'), From d28b43a97a72786b62b8db707c676d072e107ee6 Mon Sep 17 00:00:00 2001 From: "Situ Chandra Shil (via MelvinBot)" Date: Tue, 11 Aug 2026 03:34:02 +0000 Subject: [PATCH 154/466] Add missing Greek translations for settle balance confirmation keys Co-authored-by: Situ Chandra Shil --- src/languages/el.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/languages/el.ts b/src/languages/el.ts index 81d3aeb08997..ba17af4f2991 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -6081,6 +6081,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ 'Το τρέχον υπόλοιπο είναι το άθροισμα όλων των καταχωρημένων συναλλαγών της Κάρτας Expensify που έχουν πραγματοποιηθεί από την ημερομηνία της τελευταίας εκκαθάρισης.', balanceWillBeSettledOn: (settlementDate: string) => `Το υπόλοιπο θα διακανονιστεί στις ${settlementDate}`, settleBalance: 'Εξόφληση υπολοίπου', + settleBalanceConfirmationTitle: 'Εξόφληση υπολοίπου;', + settleBalanceConfirmationPrompt: + 'Αυτό θα εξοφλήσει το τρέχον υπόλοιπό σας την επόμενη εργάσιμη ημέρα. Μόλις ολοκληρωθεί, το ποσό θα προστεθεί ξανά στο υπόλοιπο όριό σας.', cardLimit: 'Όριο κάρτας', remainingLimit: 'Υπόλοιπο ορίου', requestLimitIncrease: 'Αίτημα αύξησης ορίου', From ecb84dac9b699c17ce083786c50f16f941fd5ac7 Mon Sep 17 00:00:00 2001 From: "Situ Chandra Shil (via MelvinBot)" Date: Tue, 11 Aug 2026 03:37:38 +0000 Subject: [PATCH 155/466] Run oxfmt on el.ts Co-authored-by: Situ Chandra Shil --- src/languages/el.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/languages/el.ts b/src/languages/el.ts index ba17af4f2991..caf134d97f03 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -6082,8 +6082,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ balanceWillBeSettledOn: (settlementDate: string) => `Το υπόλοιπο θα διακανονιστεί στις ${settlementDate}`, settleBalance: 'Εξόφληση υπολοίπου', settleBalanceConfirmationTitle: 'Εξόφληση υπολοίπου;', - settleBalanceConfirmationPrompt: - 'Αυτό θα εξοφλήσει το τρέχον υπόλοιπό σας την επόμενη εργάσιμη ημέρα. Μόλις ολοκληρωθεί, το ποσό θα προστεθεί ξανά στο υπόλοιπο όριό σας.', + settleBalanceConfirmationPrompt: 'Αυτό θα εξοφλήσει το τρέχον υπόλοιπό σας την επόμενη εργάσιμη ημέρα. Μόλις ολοκληρωθεί, το ποσό θα προστεθεί ξανά στο υπόλοιπο όριό σας.', cardLimit: 'Όριο κάρτας', remainingLimit: 'Υπόλοιπο ορίου', requestLimitIncrease: 'Αίτημα αύξησης ορίου', From 858d04f111abb55cd2fa55df4b24972fb2c4a85e Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Tue, 11 Aug 2026 09:49:08 +0430 Subject: [PATCH 156/466] Move pre-selected value to top of list in report field initial-value picker --- .../ReportFieldsInitialListValuePicker.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker.tsx b/src/pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker.tsx index 2576064c03b5..7f0a15541a41 100644 --- a/src/pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker.tsx +++ b/src/pages/workspace/reports/InitialListValueSelector/ReportFieldsInitialListValuePicker.tsx @@ -1,8 +1,11 @@ import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; + import React, {useMemo} from 'react'; type ReportFieldsInitialListValuePickerProps = { @@ -21,6 +24,8 @@ type ReportFieldsInitialListValuePickerProps = { function ReportFieldsInitialListValuePicker({listValues, disabledOptions, value, onValueChange}: ReportFieldsInitialListValuePickerProps) { const {localeCompare} = useLocalize(); + // Freeze the value that was selected when this picker opened so the pre-selected option stays pinned to the top for the whole open/focus cycle, even as the live selection changes. + const initialValue = useInitialSelection(value, {resetOnFocus: true}); const listValueOptions = useMemo( () => Object.values(listValues ?? {}) @@ -34,14 +39,17 @@ function ReportFieldsInitialListValuePicker({listValues, disabledOptions, value, })), [value, listValues, disabledOptions, localeCompare], ); + const orderedListValueOptions = moveInitialSelectionToTop(listValueOptions, initialValue ? [initialValue] : []); return ( onValueChange(item.value)} - initiallyFocusedItemKey={listValueOptions.find((listValue) => listValue.isSelected)?.keyForList} + initiallyFocusedItemKey={initialValue} shouldSingleExecuteRowSelect + shouldScrollToFocusedIndexOnMount={false} + shouldUpdateFocusedIndex addBottomSafeAreaPadding /> ); From a06b079fcd63206ed7c7f796750d886ed02f250e Mon Sep 17 00:00:00 2001 From: Alberto Gonzalez-Cela Date: Tue, 11 Aug 2026 10:27:05 +0200 Subject: [PATCH 157/466] Update src/languages/en.ts Co-authored-by: Fedi Rajhi --- src/languages/en.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index db59cb8e0d6c..62495b22e938 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1026,8 +1026,8 @@ const translations = { cta: 'Validate', }, addHomeAddress: { - title: 'Add a home address', - subtitle: 'Your admin enabled commuter exclusions for distance expenses. Add a home address so we can apply them to your claims.', + title: 'Add your home address for distance tracking', + subtitle: 'Account', cta: 'Add address', }, fixFailedBilling: { From c530c06af4a30acf380bff151398faa8171d3cc9 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 11 Aug 2026 10:31:19 +0200 Subject: [PATCH 158/466] use ActionableItemButtons --- .../HomeAddressRequiredContent.tsx | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx index 7a6fd4174003..d2d31a1f124f 100644 --- a/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx +++ b/src/pages/inbox/report/actionContents/HomeAddressRequiredContent.tsx @@ -1,14 +1,12 @@ -import Button from '@components/ButtonComposed'; - -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; +import type {ActionableItem} from '@components/ReportActionItem/ActionableItemButtons'; +import ActionableItemButtons from '@components/ReportActionItem/ActionableItemButtons'; import Navigation from '@libs/Navigation/Navigation'; import {getOriginalMessage, getReportActionText} from '@libs/ReportActionsUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; -import CONST from '@src/CONST'; +import type CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; import type {ReportAction} from '@src/types/onyx'; @@ -20,23 +18,27 @@ type HomeAddressRequiredContentProps = { }; function HomeAddressRequiredContent({action}: HomeAddressRequiredContentProps) { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - // The prompt is resolved once the member saves a home address. The backend clears the actionable state // and stamps a resolution, so hide the CTA to avoid pointing at a task that is already done. - const isResolved = !!getOriginalMessage(action)?.resolution; + const buttons: ActionableItem[] = getOriginalMessage(action)?.resolution + ? [] + : [ + { + text: 'homePage.timeSensitiveSection.addHomeAddress.cta', + key: `${action.reportActionID}-homeAddressRequired-addHomeAddress`, + onPress: () => Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.getRoute(INPUT_IDS.ADDRESS_LINE_1)), + isPrimary: true, + }, + ]; return ( - {!isResolved && ( - + {buttons.length > 0 && ( + )} ); From a70bcc85ce39b14c8bf7a35557878fb19897c5dd Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 11 Aug 2026 10:45:27 +0200 Subject: [PATCH 159/466] use useCommuterExclusionGuard --- .../MoneyRequestReportTransactionList.tsx | 9 ++ .../SearchMoneyRequestReportEmptyState.tsx | 10 ++ .../AddExpenseActionButton.tsx | 8 ++ src/hooks/useCommuterExclusionGuard.ts | 110 ++++++++++++++---- src/hooks/useDistanceHomeAddressCheck.ts | 62 ---------- src/hooks/useExpenseActions.ts | 8 ++ src/hooks/useParticipantSubmission.ts | 5 +- src/libs/ReportUtils.ts | 5 + .../DynamicReportChangeWorkspacePage.tsx | 8 +- .../AttachmentPickerWithMenuItems.tsx | 28 +++-- .../menuItems/TrackDistanceMenuItem.tsx | 11 ++ .../step/IOURequestStepConfirmation.tsx | 9 +- .../request/step/IOURequestStepDistance.tsx | 33 ++---- .../step/IOURequestStepDistanceMap.tsx | 11 +- .../step/confirmation/useExpenseSubmission.ts | 14 ++- .../components/AddExpenseActionButtonTest.tsx | 2 +- .../hooks/useCommuterExclusionGuard.test.ts | 48 +++++++- 17 files changed, 244 insertions(+), 137 deletions(-) delete mode 100644 src/hooks/useDistanceHomeAddressCheck.ts diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index d5c62a146539..90a60aa555d4 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -11,6 +11,7 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import Text from '@components/Text'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -318,6 +319,12 @@ function MoneyRequestReportTransactionList({ const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); const [policyTagLists] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + isManualDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isOdometerDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + }); const shouldShowGroupedTransactions = isExpenseReport(report) && !isIOUReport(report); @@ -334,6 +341,7 @@ function MoneyRequestReportTransactionList({ ownerBillingGracePeriodEnd, lastDistanceExpenseType, currentUserAccountID: currentUserDetails?.accountID, + blockDistanceRequestIfNeeded, }), [ translate, @@ -346,6 +354,7 @@ function MoneyRequestReportTransactionList({ ownerBillingGracePeriodEnd, draftTransactionIDs, currentUserDetails?.accountID, + blockDistanceRequestIfNeeded, ], ); diff --git a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx index 91c7c4e72567..225c2dcc1bcb 100644 --- a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx +++ b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx @@ -1,5 +1,6 @@ import EmptyStateComponent from '@components/EmptyStateComponent'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -44,6 +45,12 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const isReportArchived = isArchivedReport(reportNameValuePairs); const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); const canAddTransactionToReport = canAddTransaction(report, isReportArchived); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + isManualDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isOdometerDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + }); const addExpenseDropdownOptions = [ { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, @@ -72,6 +79,9 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } + if (blockDistanceRequestIfNeeded()) { + return; + } startDistanceRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs, lastDistanceExpenseType); }, }, diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx index d7ffca5cf536..618eb3256f4f 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx @@ -1,5 +1,6 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -26,6 +27,12 @@ function AddExpenseActionButton() { const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + isManualDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isOdometerDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + }); return ( ; +type PolicyWithCommuterExclusions = { + method: NonNullable['method']; + name: Policy['name']; +}; + +type PoliciesWithCommuterExclusions = Record; + +type CommuterExclusionGuardReason = 'mapOrGpsRequired' | 'homeAddressRequired'; const policiesWithCommuterExclusionsSelector = (policies: OnyxCollection): PoliciesWithCommuterExclusions => Object.values(policies ?? {}).reduce((acc, policy) => { if (isCommuterExclusionEnabled(policy)) { - acc[policy.id] = true; + acc[policy.id] = { + method: policy.commuterExclusions.method, + name: policy.name, + }; } return acc; }, {}); +const hasHomeAddressSelector = (privatePersonalDetails: OnyxEntry) => !!getCurrentAddress(privatePersonalDetails)?.street?.trim(); + /** - * Returns a guard function that blocks manual/odometer distance flows for policies - * that have commuter exclusions configured. Callers can pass an override policy ID - * when checking a newly selected workspace before committing it. + * Returns a guard function that blocks unsupported distance flows for policies with + * commuter exclusions configured. Callers can pass an override policy ID when + * checking a newly selected workspace before committing it. * - * When a block occurs, it surfaces a modal explaining that only map/GPS distance - * is supported (because exclusions are computed from the mapped route) and returns - * true so callers can early return. + * When a block occurs, it surfaces the relevant modal and returns true so callers + * can early return. */ -function useCommuterExclusionGuard({policyID, isManualDistanceRequest = false, isOdometerDistanceRequest = false}: UseCommuterExclusionGuardParams) { +function useCommuterExclusionGuard({policyID, isManualDistanceRequest = false, isOdometerDistanceRequest = false, isDistanceRequest = false}: UseCommuterExclusionGuardParams) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {showConfirmModal} = useConfirmModal(); const illustrations = useMemoizedLazyIllustrations(['HouseWithMap']); const [policiesWithCommuterExclusions] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: policiesWithCommuterExclusionsSelector}); + const [hasHomeAddress] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, {selector: hasHomeAddressSelector}); - return useCallback( - (...policyIDsToCheck: [string?]) => { - const policyIDToCheck = policyIDsToCheck.length > 0 ? policyIDsToCheck[0] : policyID; - if (!isManualDistanceRequest && !isOdometerDistanceRequest) { - return false; + const getGuardReason = useCallback( + (policyIDToCheck: string | undefined): CommuterExclusionGuardReason | undefined => { + if (!policyIDToCheck || !policiesWithCommuterExclusions?.[policyIDToCheck]) { + return; } - if (!policyIDToCheck || !policiesWithCommuterExclusions?.[policyIDToCheck]) { - return false; + if (isManualDistanceRequest || isOdometerDistanceRequest) { + return 'mapOrGpsRequired'; } - showConfirmModal({ - title: translate('distance.error.mapOrGpsDistanceRequired.title'), + if (isDistanceRequest && policiesWithCommuterExclusions[policyIDToCheck].method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && !hasHomeAddress) { + return 'homeAddressRequired'; + } + }, + [hasHomeAddress, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, policiesWithCommuterExclusions], + ); + + const showGuardModal = useCallback( + (reason: CommuterExclusionGuardReason, policyIDToCheck: string | undefined) => { + const baseModalProps = { + image: illustrations.HouseWithMap, titleStyles: styles.textHeadline, - prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), promptStyles: styles.textSupporting, - confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, - image: illustrations.HouseWithMap, shouldUseSuccessStyleForConfirm: true, shouldFitImageToContainer: true, imageStyles: styles.commuterExclusionStaticIllustration, + shouldShowDismissIcon: false, + }; + if (reason === 'homeAddressRequired') { + showConfirmModal({ + ...baseModalProps, + title: translate('iou.homeAddressRequired.title'), + prompt: translate('iou.homeAddressRequired.prompt', {workspaceName: policyIDToCheck ? (policiesWithCommuterExclusions?.[policyIDToCheck]?.name ?? '') : ''}), + confirmText: translate('iou.homeAddressRequired.cta'), + }).then(({action: modalAction}) => { + if (modalAction !== ModalActions.CONFIRM) { + return; + } + Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.getRoute(INPUT_IDS.ADDRESS_LINE_1)); + }); + return; + } + + showConfirmModal({ + ...baseModalProps, + title: translate('distance.error.mapOrGpsDistanceRequired.title'), + prompt: translate('distance.error.mapOrGpsDistanceRequired.description'), + confirmText: translate('common.buttonConfirm'), }); + }, + [illustrations.HouseWithMap, policiesWithCommuterExclusions, showConfirmModal, styles, translate], + ); + const blockDistanceRequestIfNeeded = useCallback( + (...policyIDsToCheck: [string?]) => { + const policyIDToCheck = policyIDsToCheck.length > 0 ? policyIDsToCheck[0] : policyID; + const reason = getGuardReason(policyIDToCheck); + if (!reason) { + return false; + } + + showGuardModal(reason, policyIDToCheck); return true; }, - [policyID, isManualDistanceRequest, isOdometerDistanceRequest, policiesWithCommuterExclusions, showConfirmModal, translate, styles, illustrations.HouseWithMap], + [getGuardReason, policyID, showGuardModal], ); + + return blockDistanceRequestIfNeeded; } export default useCommuterExclusionGuard; diff --git a/src/hooks/useDistanceHomeAddressCheck.ts b/src/hooks/useDistanceHomeAddressCheck.ts deleted file mode 100644 index f8990c7387fa..000000000000 --- a/src/hooks/useDistanceHomeAddressCheck.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {ModalActions} from '@components/Modal/Global/ModalContext'; - -import Navigation from '@libs/Navigation/Navigation'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; -import type Policy from '@src/types/onyx/Policy'; - -import type {OnyxEntry} from 'react-native-onyx'; - -import {useCallback} from 'react'; - -import useConfirmModal from './useConfirmModal'; -import {useMemoizedLazyIllustrations} from './useLazyAsset'; -import useLocalize from './useLocalize'; -import useOnyx from './useOnyx'; - -type UseDistanceHomeAddressCheckResult = { - /** - * True when the destination workspace uses homeAndOffice commuter exclusions but the current - * user has no saved home address. - */ - needsHomeAddressPrompt: boolean; - - /** - * Show the blocking "Home address is required" modal. - */ - promptForHomeAddress: () => void; -}; - -function useDistanceHomeAddressCheck(policy: OnyxEntry): UseDistanceHomeAddressCheckResult { - const {translate} = useLocalize(); - const {showConfirmModal} = useConfirmModal(); - const illustrations = useMemoizedLazyIllustrations(['House']); - const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); - - const usesHomeAndOfficeExclusions = policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE; - const memberHasHomeAddress = (privatePersonalDetails?.addresses ?? []).some((addressEntry) => !!addressEntry?.street?.trim()); - const needsHomeAddressPrompt = usesHomeAndOfficeExclusions && !memberHasHomeAddress; - - const promptForHomeAddress = useCallback(() => { - showConfirmModal({ - image: illustrations.House, - title: translate('iou.homeAddressRequired.title'), - prompt: translate('iou.homeAddressRequired.prompt', {workspaceName: policy?.name ?? ''}), - confirmText: translate('iou.homeAddressRequired.cta'), - shouldShowCancelButton: false, - shouldShowDismissIcon: true, - }).then(({action: modalAction}) => { - if (modalAction !== ModalActions.CONFIRM) { - return; - } - Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.getRoute(INPUT_IDS.ADDRESS_LINE_1)); - }); - }, [illustrations.House, policy?.name, showConfirmModal, translate]); - - return {needsHomeAddressPrompt, promptForHomeAddress}; -} - -export default useDistanceHomeAddressCheck; diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 198d1b0d1b54..83bcde193184 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -58,6 +58,7 @@ import passthroughPolicyTagListSelector from '@selectors/PolicyTagList'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; import {useRef} from 'react'; +import useCommuterExclusionGuard from './useCommuterExclusionGuard'; import useConfirmModal from './useConfirmModal'; import {useCurrencyListActions} from './useCurrencyList'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; @@ -290,6 +291,12 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic } }; + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + isManualDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isOdometerDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + }); const addExpenseDropdownOptions = getAddExpenseDropdownOptions({ translate, icons: useMemoizedLazyExpensifyIcons(['Plus', 'ReceiptPlus', 'Location', 'Feed', 'ArrowRight']), @@ -301,6 +308,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic ownerBillingGracePeriodEnd, lastDistanceExpenseType, currentUserAccountID: accountID, + blockDistanceRequestIfNeeded, }); const expensifyIcons = useMemoizedLazyExpensifyIcons([ diff --git a/src/hooks/useParticipantSubmission.ts b/src/hooks/useParticipantSubmission.ts index a7a3fef88955..fe66acd7c7f8 100644 --- a/src/hooks/useParticipantSubmission.ts +++ b/src/hooks/useParticipantSubmission.ts @@ -104,7 +104,8 @@ function useParticipantSubmission({ // explicit useMemo is needed here. const transactionIDs = draftTransactions?.map((transaction) => transaction.transactionID); const [transactions] = useTransactionsByID(transactionIDs); - const blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({ + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + isDistanceRequest: isDistanceRequest(initialTransaction), isManualDistanceRequest: isManualDistanceRequest(initialTransaction), isOdometerDistanceRequest: isOdometerDistanceRequest(initialTransaction), }); @@ -246,7 +247,7 @@ function useParticipantSubmission({ // Block selecting a workspace with commuter exclusions before participants/workspace are committed. const selectedPolicyID = firstParticipant?.policyID ?? (firstParticipant?.reportID ? getReportOrDraftReport(firstParticipant.reportID)?.policyID : undefined); - if (blockManualOrOdometerDistanceRequestIfNeeded(selectedPolicyID)) { + if (blockDistanceRequestIfNeeded(selectedPolicyID)) { return; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5593a0fa9101..ee8ff5c32d58 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3137,6 +3137,7 @@ type GetAddExpenseDropdownOptionsParams = { unreportedExpenseBackToReport?: string; lastDistanceExpenseType?: IOURequestType; currentUserAccountID: number; + blockDistanceRequestIfNeeded?: () => boolean; }; function getAddExpenseDropdownOptions({ @@ -3152,6 +3153,7 @@ function getAddExpenseDropdownOptions({ unreportedExpenseBackToReport, lastDistanceExpenseType, currentUserAccountID, + blockDistanceRequestIfNeeded, }: GetAddExpenseDropdownOptionsParams): Array>> { return [ { @@ -3187,6 +3189,9 @@ function getAddExpenseDropdownOptions({ Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } + if (blockDistanceRequestIfNeeded?.()) { + return; + } startDistanceRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); }, }, diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index 957f5acf0593..e3958d505638 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -38,7 +38,7 @@ import { isWorkspaceEligibleForReportChange, } from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; -import {hasAppliedCommuterExclusion, isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils'; +import {hasAppliedCommuterExclusion, isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -101,9 +101,11 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const navigateBackFromChangeWorkspacePath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_CHANGE_WORKSPACE.path); const hasCommuterExclusionDistanceRequest = reportTransactions.some((transaction) => hasAppliedCommuterExclusion(transaction)); + const hasDistanceRequest = reportTransactions.some((transaction) => isDistanceRequest(transaction)); const hasManualDistanceRequest = reportTransactions.some((transaction) => isManualDistanceRequest(transaction)); const hasOdometerDistanceRequest = reportTransactions.some((transaction) => isOdometerDistanceRequest(transaction)); - const blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({ + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + isDistanceRequest: hasDistanceRequest, isManualDistanceRequest: hasManualDistanceRequest, isOdometerDistanceRequest: hasOdometerDistanceRequest, }); @@ -114,7 +116,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace if (!policyID || !policy) { return; } - if (blockManualOrOdometerDistanceRequestIfNeeded(policyID)) { + if (blockDistanceRequestIfNeeded(policyID)) { return; } if (shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriods, amountOwed, currentUserPersonalDetails.accountID)) { diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index f9fe10e5eefd..29c7b00cad71 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -7,6 +7,7 @@ import PopoverMenu from '@components/PopoverMenu'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useEnvironment from '@hooks/useEnvironment'; @@ -169,6 +170,12 @@ function AttachmentPickerWithMenuItems({ const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + isManualDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isOdometerDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + }); const {isProduction} = useEnvironment(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); const {setIsLoaderVisible} = useFullScreenLoaderActions(); @@ -261,10 +268,12 @@ function AttachmentPickerWithMenuItems({ shouldCallAfterModalHide: shouldUseNarrowLayout, sentryLabel: CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_MENU_TRACK_DISTANCE, onSelected: () => - selectOption( - () => startDistanceRequest(CONST.IOU.TYPE.SUBMIT, report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), draftTransactionIDs, lastDistanceExpenseType), - true, - ), + selectOption(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } + startDistanceRequest(CONST.IOU.TYPE.SUBMIT, report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), draftTransactionIDs, lastDistanceExpenseType); + }, true), }, ], [CONST.IOU.TYPE.PAY]: [ @@ -298,10 +307,12 @@ function AttachmentPickerWithMenuItems({ shouldCallAfterModalHide: shouldUseNarrowLayout, sentryLabel: CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_MENU_TRACK_DISTANCE, onSelected: () => - selectOption( - () => startDistanceRequest(CONST.IOU.TYPE.TRACK, report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), draftTransactionIDs, lastDistanceExpenseType), - true, - ), + selectOption(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } + startDistanceRequest(CONST.IOU.TYPE.TRACK, report?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), draftTransactionIDs, lastDistanceExpenseType); + }, true), }, ], [CONST.IOU.TYPE.INVOICE]: [ @@ -321,6 +332,7 @@ function AttachmentPickerWithMenuItems({ return moneyRequestOptionsList.flat().filter((item, index, self) => index === self.findIndex((t) => t.text === item.text)); }, [ + blockDistanceRequestIfNeeded, isDelegateAccessRestricted, isReportArchived, isRestrictedToPreferredPolicy, diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TrackDistanceMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TrackDistanceMenuItem.tsx index cec9950ad80f..2f56ac673ef3 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TrackDistanceMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/TrackDistanceMenuItem.tsx @@ -1,3 +1,4 @@ +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -26,6 +27,13 @@ function TrackDistanceMenuItem({reportID}: TrackDistanceMenuItemProps) { const icons = useMemoizedLazyExpensifyIcons(['Location']); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: report?.policyID, + isDistanceRequest: true, + isManualDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + isOdometerDistanceRequest: lastDistanceExpenseType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + }); return ( interceptAnonymousUser(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } // Start the flow to start tracking a distance request startDistanceRequest(CONST.IOU.TYPE.CREATE, reportID, draftTransactionIDs, lastDistanceExpenseType, undefined, undefined, true); }) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index a02db22301e2..b4f2d25c003b 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -263,8 +263,9 @@ function IOURequestStepConfirmation({ const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); const isManualRequest = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); - const blockManualOrOdometerDistanceRequestIfNeeded = useCommuterExclusionGuard({ + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ policyID: policy?.id, + isDistanceRequest, isManualDistanceRequest, isOdometerDistanceRequest, }); @@ -392,7 +393,7 @@ function IOURequestStepConfirmation({ } const selectedParticipant = participantsList.at(0); const selectedPolicyID = selectedParticipant?.policyID ?? (selectedParticipant?.reportID ? getReportOrDraftReport(selectedParticipant.reportID)?.policyID : undefined); - if (blockManualOrOdometerDistanceRequestIfNeeded(selectedPolicyID)) { + if (blockDistanceRequestIfNeeded(selectedPolicyID)) { return; } // P2P chats don't support negative amounts. When a negative amount was entered before a participant @@ -491,7 +492,7 @@ function IOURequestStepConfirmation({ lastSelectedDistanceRates, transaction, personalPolicy?.outputCurrency, - blockManualOrOdometerDistanceRequestIfNeeded, + blockDistanceRequestIfNeeded, mappedPolicies, getCurrencyDecimals, policyID, @@ -1029,7 +1030,7 @@ function IOURequestStepConfirmation({ // Clicking the backdrop (outside the panel) should dismiss the whole expense creation RHP, // matching standard RHP behavior, not just close the stacked participant picker. onBackdropPress={() => Navigation.dismissModal()} - shouldBlockParticipantSelection={blockManualOrOdometerDistanceRequestIfNeeded} + shouldBlockParticipantSelection={blockDistanceRequestIfNeeded} /> )} diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 6d2fdad1731a..ce35c1708e14 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -5,11 +5,11 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; -import useDistanceHomeAddressCheck from '@hooks/useDistanceHomeAddressCheck'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useFetchRoute from '@hooks/useFetchRoute'; import useLocalize from '@hooks/useLocalize'; @@ -307,21 +307,10 @@ function IOURequestStepDistance({ return stop; }, []); - // When the destination workspace requires home/office commuter exclusions, the per-member - // commute deduction can't be computed unless the member has a home address. - const {needsHomeAddressPrompt, promptForHomeAddress} = useDistanceHomeAddressCheck(policy); - const hasShownHomeAddressModalRef = useRef(false); - useEffect(() => { - if (!needsHomeAddressPrompt) { - hasShownHomeAddressModalRef.current = false; - return; - } - if (hasShownHomeAddressModalRef.current) { - return; - } - hasShownHomeAddressModalRef.current = true; - promptForHomeAddress(); - }, [needsHomeAddressPrompt, promptForHomeAddress]); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest: true, + }); useEffect(() => { if (numberOfWaypoints <= numberOfPreviousWaypoints) { @@ -488,8 +477,7 @@ function IOURequestStepDistance({ ); const submitWaypoints = useCallback(() => { - if (needsHomeAddressPrompt) { - promptForHomeAddress(); + if (blockDistanceRequestIfNeeded()) { return; } // If there is any error or loading state, don't let user go to next page. @@ -570,8 +558,7 @@ function IOURequestStepDistance({ suppressDiscardPrompt(); navigateToNextStep(); }, [ - needsHomeAddressPrompt, - promptForHomeAddress, + blockDistanceRequestIfNeeded, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, @@ -611,8 +598,7 @@ function IOURequestStepDistance({ ]); const submitManualDistance = useCallback(() => { - if (needsHomeAddressPrompt) { - promptForHomeAddress(); + if (blockDistanceRequestIfNeeded()) { return; } isManuallyEditing.current = false; @@ -700,8 +686,7 @@ function IOURequestStepDistance({ removeBackupTransaction(transaction?.transactionID); navigateBackAfterSave(); }, [ - needsHomeAddressPrompt, - promptForHomeAddress, + blockDistanceRequestIfNeeded, translate, distanceRate, transactionID, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 61f22c109c77..e3fb41b66366 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -2,6 +2,7 @@ import DistanceRequestRenderItem from '@components/DistanceRequest/DistanceReque import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useFetchRoute from '@hooks/useFetchRoute'; @@ -10,8 +11,8 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; -import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePrevious from '@hooks/usePrevious'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -75,7 +76,8 @@ function IOURequestStepDistanceMap({ const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const isArchived = useReportIsArchived(report?.reportID); const selfDMReport = useSelfDMReport(); - const policy = usePolicy(report?.policyID); + const {policy} = usePolicyForTransaction({transaction, reportPolicyID: report?.policyID, action, iouType}); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({policyID: policy?.id, isDistanceRequest: true}); const personalPolicy = usePersonalPolicy(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const defaultExpensePolicy = useDefaultExpensePolicy(); @@ -290,6 +292,9 @@ function IOURequestStepDistanceMap({ ); const submitWaypoints = useCallback(() => { + if (blockDistanceRequestIfNeeded()) { + return; + } // If there is any error or loading state, don't let user go to next page. if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || isLoading) { setShouldShowAtLeastTwoDifferentWaypointsError(true); @@ -297,7 +302,7 @@ function IOURequestStepDistanceMap({ } suppressDiscardPrompt(); navigateToNextStep(); - }, [duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, isLoadingRoute, isLoading, suppressDiscardPrompt, navigateToNextStep]); + }, [blockDistanceRequestIfNeeded, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, isLoadingRoute, isLoading, suppressDiscardPrompt, navigateToNextStep]); const renderItem = useCallback( ({item, drag, isActive, getIndex}: RenderItemParams) => { diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index 32d8a43e749d..16316526253c 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -1,7 +1,7 @@ import useActivePolicy from '@hooks/useActivePolicy'; +import useCommuterExclusionGuard from '@hooks/useCommuterExclusionGuard'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; -import useDistanceHomeAddressCheck from '@hooks/useDistanceHomeAddressCheck'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import useLocalize from '@hooks/useLocalize'; import useMoneyRequestPolicyTags from '@hooks/useMoneyRequestPolicyTags'; @@ -325,9 +325,12 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { const transactionIDs = transactions?.map((tx) => tx.transactionID); const [storedTransactions] = useTransactionsByID(transactionIDs); - // Any distance flow that submits through this hook is blocked while the user is missing a home address on a - // homeAndOffice workspace. - const {needsHomeAddressPrompt: distanceNeedsHomeAddress, promptForHomeAddress: promptForDistanceHomeAddress} = useDistanceHomeAddressCheck(policy); + const blockDistanceRequestIfNeeded = useCommuterExclusionGuard({ + policyID: policy?.id, + isDistanceRequest, + isManualDistanceRequest, + isOdometerDistanceRequest, + }); function performPostBatchCleanup({ participant, @@ -894,8 +897,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { } function createTransaction(locationPermissionGranted = false, shouldHandleNavigation = true) { - if (isDistanceRequest && distanceNeedsHomeAddress) { - promptForDistanceHomeAddress(); + if (blockDistanceRequestIfNeeded()) { return; } diff --git a/tests/ui/components/AddExpenseActionButtonTest.tsx b/tests/ui/components/AddExpenseActionButtonTest.tsx index b746940944a9..0e3795ff3639 100644 --- a/tests/ui/components/AddExpenseActionButtonTest.tsx +++ b/tests/ui/components/AddExpenseActionButtonTest.tsx @@ -27,7 +27,7 @@ jest.mock('@components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestRe })); jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: () => ({accountID: 1})})); -jest.mock('@hooks/useLazyAsset', () => ({__esModule: true, useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@hooks/useLazyAsset', () => ({__esModule: true, useMemoizedLazyExpensifyIcons: () => ({}), useMemoizedLazyIllustrations: () => ({})})); jest.mock('@hooks/useLocalize', () => ({__esModule: true, default: () => ({translate: (key: string) => key})})); jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: jest.fn(() => [undefined])})); diff --git a/tests/unit/hooks/useCommuterExclusionGuard.test.ts b/tests/unit/hooks/useCommuterExclusionGuard.test.ts index af4dbd5958f5..813ce4b52887 100644 --- a/tests/unit/hooks/useCommuterExclusionGuard.test.ts +++ b/tests/unit/hooks/useCommuterExclusionGuard.test.ts @@ -19,7 +19,7 @@ jest.mock('@hooks/useLocalize', () => () => ({ })); jest.mock('@hooks/useLazyAsset', () => ({ - useMemoizedLazyIllustrations: () => ({HouseWithMap: 'HouseWithMap'}), + useMemoizedLazyIllustrations: () => ({House: 'House', HouseWithMap: 'HouseWithMap'}), })); describe('useCommuterExclusionGuard', () => { @@ -29,6 +29,7 @@ describe('useCommuterExclusionGuard', () => { beforeEach(async () => { mockShowConfirmModal.mockClear(); + mockShowConfirmModal.mockResolvedValue({action: 'cancel'}); await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -137,4 +138,49 @@ describe('useCommuterExclusionGuard', () => { expect(result.current('policy_forced')).toBe(false); expect(mockShowConfirmModal).not.toHaveBeenCalled(); }); + + it('blocks a distance request when home and office exclusions require a missing home address', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy_home_and_office`, { + id: 'policy_home_and_office', + name: 'Home and office workspace', + commuterExclusions: { + method: 'homeAndOffice', + }, + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => + useCommuterExclusionGuard({ + policyID: 'policy_home_and_office', + isDistanceRequest: true, + }), + ); + + expect(result.current()).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({title: 'iou.homeAddressRequired.title'})); + }); + + it('allows a distance request when the current home address is present', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}policy_home_and_office`, { + id: 'policy_home_and_office', + name: 'Home and office workspace', + commuterExclusions: { + method: 'homeAndOffice', + }, + }); + await Onyx.merge(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, { + addresses: [{street: '123 Main Street', current: true}], + }); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => + useCommuterExclusionGuard({ + policyID: 'policy_home_and_office', + isDistanceRequest: true, + }), + ); + + expect(result.current()).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); }); From b31f361ec0da810d613118638805610a41cd4441 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 11 Aug 2026 10:46:02 +0200 Subject: [PATCH 160/466] parrot --- src/languages/de.ts | 17 ++++++----------- src/languages/el.ts | 38 +++++++++++++++++--------------------- src/languages/es.ts | 18 +++++++----------- src/languages/fr.ts | 19 +++++++------------ src/languages/it.ts | 17 ++++++----------- src/languages/ja.ts | 12 ++++++------ src/languages/nl.ts | 22 +++++++++------------- src/languages/pl.ts | 22 +++++++++------------- src/languages/pt-BR.ts | 18 +++++++----------- src/languages/zh-hans.ts | 20 ++++++++++---------- 10 files changed, 84 insertions(+), 119 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 855a92d76093..9ce6e2052ac6 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -960,12 +960,7 @@ const translations: TranslationDeepObject = { subtitle: 'Wallet', }, validateAccount: {title: 'Bestätigen Sie Ihr Konto', subtitle: 'Konto', cta: 'Bestätigen'}, - addHomeAddress: { - title: 'Fügen Sie eine Privatadresse hinzu', - subtitle: - 'Ihr Administrator hat Pendlerausschlüsse für Entfernungskosten aktiviert. Fügen Sie eine Privatadresse hinzu, damit wir diese auf Ihre Abrechnungen anwenden können.', - cta: 'Adresse hinzufügen', - }, + addHomeAddress: {title: 'Fügen Sie Ihre Privatadresse für die Distanzverfolgung hinzu', subtitle: 'Konto', cta: 'Adresse hinzufügen'}, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Ihr Geschäftskonto wurde gesperrt', @@ -1243,7 +1238,7 @@ const translations: TranslationDeepObject = { title: 'Privatadresse ist erforderlich', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse zu Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` + ? `Bevor Sie eine Entfernung erfassen, müssen Sie Ihre Privatadresse zu Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse zu Ihrem privaten Profil hinzufügen. Dieser Workspace verwendet diese Adresse für Pendlerabzüge.', cta: 'Privatadresse hinzufügen', }, @@ -7108,16 +7103,16 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, summaryDisabled: 'Kein Ausschluss von Pendelstrecken', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `${distance} ${unit} pro Abrechnung ausschließen`, optionDisabledTitle: 'Pendelfahrten nicht ausschließen', - optionDisabledHelp: 'Es wird keine Pendelstrecke von den Abrechnungen abgezogen.', + optionDisabledHelp: 'Es wird keine Pendelstrecke aus den Anträgen entfernt.', optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', distanceLabel: 'Entfernung', - summaryHomeAndOffice: 'Standort für Zuhause und Büro verwenden', + summaryHomeAndOffice: 'Wohn- und Bürostandorte verwenden', optionHomeAndOfficeTitle: 'Nach Zuhause und Büro berechnen', - optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuordnung des Mitglieds, um Pendlerausschlüsse zu berechnen.', + optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuweisung der Mitglieder, um Pendelausschlüsse zu berechnen.', workspaceAddressRequired: { title: 'Nicht so schnell …', - promptStart: 'Sie können die Einstellung „Nach Zuhause und Büro berechnen“ erst aktivieren, wenn Sie zuerst einen Bürostandort in der ', + promptStart: 'Sie können die Einstellung „Nach Zuhause und Büro berechnen“ erst aktivieren, nachdem Sie zuerst einen Bürostandort in hinzugefügt haben', linkText: 'Übersicht', promptEnd: ' hinzugefügt haben.', cta: 'Verstanden', diff --git a/src/languages/el.ts b/src/languages/el.ts index 1c112c9f4cc2..7e06c05c64c9 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -320,7 +320,6 @@ const translations: TranslationDeepObject = { automatic: 'Αυτόματο', showing: 'Εμφανίζονται', of: 'του', - // @context Carousel pagination counter showing the current item's position out of the total (e.g. "3 of 50"). currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} από ${total}`, default: 'Προεπιλογή', update: 'Ενημέρωση', @@ -1002,12 +1001,7 @@ const translations: TranslationDeepObject = { subtitle: 'Λογαριασμός', cta: 'Επικυρώστε', }, - addHomeAddress: { - title: 'Προσθέστε μια διεύθυνση κατοικίας', - subtitle: - 'Ο διαχειριστής σας ενεργοποίησε τις εξαιρέσεις μετακίνησης για τις δαπάνες απόστασης. Προσθέστε μια διεύθυνση κατοικίας, ώστε να μπορούμε να τις εφαρμόζουμε στα αιτήματά σας.', - cta: 'Προσθήκη διεύθυνσης', - }, + addHomeAddress: {title: 'Προσθέστε τη διεύθυνση κατοικίας σας για καταγραφή αποστάσεων', subtitle: 'Λογαριασμός', cta: 'Προσθήκη διεύθυνσης'}, fixFailedBilling: { title: 'Δεν μπορέσαμε να χρεώσουμε την αποθηκευμένη κάρτα σας', subtitle: 'Συνδρομή', @@ -1289,12 +1283,12 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Απαιτείται διεύθυνση κατοικίας', + title: 'Η διεύθυνση κατοικίας είναι υποχρεωτική', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Προτού καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό σας προφίλ. Ο χώρος εργασίας ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για τις εκπτώσεις μετακίνησης προς και από την εργασία.` - : 'Προτού καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό σας προφίλ. Αυτός ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για τις εκπτώσεις μετακίνησης προς και από την εργασία.', - cta: 'Προσθήκη διεύθυνσης κατοικίας', + ? `Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Το ${workspaceName} χρησιμοποιεί αυτήν τη διεύθυνση για φορολογικές εκπτώσεις μετακίνησης.` + : 'Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Ο χώρος εργασίας αυτός χρησιμοποιεί αυτήν τη διεύθυνση για εκπτώσεις μετακίνησης προς και από την εργασία.', + cta: 'Προσθήκη οικιακής διεύθυνσης', }, amount: 'Ποσό', percent: 'Ποσοστό', @@ -7356,24 +7350,24 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ title: 'Εξαίρεση μετακινήσεων από και προς την εργασία', summaryDisabled: 'Χωρίς εξαίρεση μετακίνησης', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Εξαίρεση ${distance} ${unit} ανά αίτημα`, - summaryHomeAndOffice: 'Χρήση τοποθεσιών κατοικίας και γραφείου', + summaryHomeAndOffice: 'Χρησιμοποιήστε τις τοποθεσίες σπιτιού και γραφείου', optionDisabledTitle: 'Να μην εξαιρούνται οι μετακινήσεις από και προς την εργασία', - optionDisabledHelp: 'Καμία μετακίνηση δεν αφαιρείται από τα αιτήματα.', + optionDisabledHelp: 'Καμία μετακίνηση δεν αφαιρείται από τις αξιώσεις.', optionFixedDistanceTitle: 'Εξαίρεση σταθερής απόστασης ανά αίτημα', optionFixedDistanceHelp: 'Αφαιρέστε την ίδια απόσταση μετακίνησης από κάθε αίτημα. Ιδανικό για μέλη που υποβάλλουν ένα αίτημα ανά εργάσιμη ημέρα.', - optionHomeAndOfficeTitle: 'Υπολογισμός βάσει κατοικίας και γραφείου', - optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας του μέλους, το καθεστώς εργασίας και την ανάθεση γραφείου για τον υπολογισμό των εξαιρέσεων μετακίνησης.', + optionHomeAndOfficeTitle: 'Υπολογισμός ανά σπίτι και γραφείο', + optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας μέλους, τη ρύθμιση εργασίας και την ανάθεση γραφείου για τον υπολογισμό των εξαιρέσεων μετακίνησης.', distanceLabel: 'Απόσταση', workspaceAddressRequired: { - title: 'Όχι τόσο γρήγορα...', - promptStart: 'Δεν μπορείτε να ενεργοποιήσετε τη ρύθμιση υπολογισμού βάσει κατοικίας και γραφείου έως ότου προσθέσετε πρώτα μια τοποθεσία γραφείου στην ', + title: 'Όχι και τόσο γρήγορα...', + promptStart: 'Δεν μπορείτε να ενεργοποιήσετε τη ρύθμιση υπολογισμού ανά σπίτι και γραφείο μέχρι να προσθέσετε πρώτα μια τοποθεσία γραφείου στο', linkText: 'Επισκόπηση', promptEnd: '.', - cta: 'Το κατάλαβα', + cta: 'Εντάξει', }, errors: { distanceMustBePositive: 'Η απόσταση πρέπει να είναι ένας θετικός ακέραιος αριθμός.', - invalidAddress: 'Παρακαλούμε εισαγάγετε μια έγκυρη διεύθυνση', + invalidAddress: 'Παρακαλώ εισαγάγετε μια έγκυρη διεύθυνση', distanceTooLarge: 'Η απόσταση είναι πολύ μεγάλη.', }, }, @@ -7522,8 +7516,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, exportAgainModal: { title: 'Προσοχή!', - description: (reportName, connectionName) => - `Οι παρακάτω αναφορές έχουν ήδη εξαχθεί στο ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}. Είστε βέβαιοι ότι θέλετε να τις εξαγάγετε ξανά; + description: ( + reportName, + connectionName, + ) => `Οι παρακάτω αναφορές έχουν ήδη εξαχθεί στο ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectionName]}. Είστε βέβαιοι ότι θέλετε να τις εξαγάγετε ξανά; ${reportName}`, confirmText: 'Ναι, εξαγωγή ξανά', diff --git a/src/languages/es.ts b/src/languages/es.ts index de757bbfed97..4e488c519dbb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -954,11 +954,7 @@ const translations: TranslationDeepObject = { subtitle: 'Cuenta', cta: 'Validar', }, - addHomeAddress: { - title: 'Añade una dirección de casa', - subtitle: 'Tu administrador habilitó exclusiones de desplazamiento para los gastos de distancia. Añade una dirección de casa para que podamos aplicarlas a tus solicitudes.', - cta: 'Añade dirección', - }, + addHomeAddress: {title: 'Añade tu dirección de casa para el seguimiento de distancias', subtitle: 'Cuenta', cta: 'Añade dirección'}, fixFailedBilling: { title: 'No pudimos cobrar a la tarjeta registrada.', subtitle: 'Suscripción', @@ -1237,11 +1233,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'La dirección de casa es obligatoria', + title: 'La dirección de domicilio es obligatoria', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. ${workspaceName} utiliza esta dirección para las deducciones por desplazamiento al trabajo.` - : 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo utiliza esta dirección para las deducciones por desplazamiento.', + ? `Antes de organizar distancias, tienes que añadir tu dirección de casa a tu perfil privado. ${workspaceName} usa esta dirección para las deducciones por desplazamiento al trabajo.` + : 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo usa esta dirección para las deducciones por desplazamiento al trabajo.', cta: 'Añadir dirección de casa', }, amount: 'Importe', @@ -7074,15 +7070,15 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, summaryHomeAndOffice: 'Usar ubicaciones de casa y oficina', optionDisabledTitle: 'No excluir los desplazamientos al trabajo', - optionDisabledHelp: 'No se elimina ningún desplazamiento de las solicitudes.', + optionDisabledHelp: 'Ningún desplazamiento al trabajo se excluye de las reclamaciones.', optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', optionFixedDistanceHelp: 'Resta la misma distancia de desplazamiento de cada solicitud. Ideal para personas que envían una solicitud por día laborable.', optionHomeAndOfficeTitle: 'Calcular por casa y oficina', - optionHomeAndOfficeHelp: 'Usa la dirección de casa del miembro, su modalidad de trabajo y la asignación de oficina para calcular las exclusiones de desplazamiento.', + optionHomeAndOfficeHelp: 'Usa la dirección de casa del miembro, su modalidad de trabajo y su asignación de oficina para calcular las exclusiones de desplazamiento.', distanceLabel: 'Distancia', workspaceAddressRequired: { title: 'No tan rápido...', - promptStart: 'No puedes habilitar el ajuste de cálculo por casa y oficina hasta que primero añadas una ubicación de oficina en ', + promptStart: 'No puedes habilitar la configuración de cálculo por casa y oficina hasta que primero añadas una ubicación de oficina en', linkText: 'Resumen', promptEnd: '.', cta: 'Entendido', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c48fb1d26826..6993d74ca838 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -963,12 +963,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portefeuille', }, validateAccount: {title: 'Validez votre compte', subtitle: 'Compte', cta: 'Valider'}, - addHomeAddress: { - title: 'Ajouter une adresse personnelle', - subtitle: - 'Votre administrateur a activé les exclusions de trajets domicile-travail pour les dépenses de distance. Ajoutez une adresse de domicile pour que nous puissions les appliquer à vos demandes.', - cta: 'Ajouter une adresse', - }, + addHomeAddress: {title: 'Ajoutez votre adresse personnelle pour le suivi des distances', subtitle: 'Compte', cta: 'Ajouter une adresse'}, fixFailedBilling: {title: 'Nous n’avons pas pu débiter votre carte enregistrée', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Votre compte bancaire professionnel a été verrouillé', @@ -1249,7 +1244,7 @@ const translations: TranslationDeepObject = { workspaceName ? `Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions liées aux trajets domicile-travail.` : 'Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions liées aux trajets domicile-travail.', - cta: 'Ajouter une adresse de domicile', + cta: 'Ajouter une adresse personnelle', }, amount: 'Montant', percent: 'Pourcentage', @@ -7136,17 +7131,17 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, summaryDisabled: 'Pas d’exclusion du trajet domicile-travail', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclure ${distance} ${unit} par demande`, optionDisabledTitle: 'Ne pas exclure les trajets domicile-travail', - optionDisabledHelp: 'Aucun trajet domicile-travail n’est retiré des demandes.', + optionDisabledHelp: 'Aucun trajet domicile-travail n’est retiré des déclarations.', optionFixedDistanceTitle: 'Exclure une distance fixe par demande', optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', distanceLabel: 'Distance', - summaryHomeAndOffice: 'Utiliser les adresses domicile et bureau', + summaryHomeAndOffice: 'Utiliser les emplacements domicile et bureau', optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', optionHomeAndOfficeHelp: - 'Utilisez l’adresse du domicile du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet domicile-travail.', + 'Utiliser l’adresse personnelle du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet domicile-travail.', workspaceAddressRequired: { - title: 'Pas si vite…', - promptStart: 'Vous ne pouvez pas activer le paramètre de calcul domicile-bureau tant que vous n’avez pas ajouté un lieu de bureau dans l’', + title: 'Pas si vite...', + promptStart: 'Vous ne pouvez pas activer le paramètre de calcul par domicile et bureau avant d’avoir d’abord ajouté un lieu de bureau dans', linkText: 'Aperçu', promptEnd: '.', cta: 'Compris', diff --git a/src/languages/it.ts b/src/languages/it.ts index 610941191297..a57c5483d4e1 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -961,12 +961,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portafoglio', }, validateAccount: {title: 'Conferma il tuo account', subtitle: 'Account', cta: 'Conferma'}, - addHomeAddress: { - title: 'Aggiungi un indirizzo di casa', - subtitle: - 'Il tuo amministratore ha attivato le esclusioni per i tragitti casa-lavoro nelle spese chilometriche. Aggiungi un indirizzo di casa così potremo applicarle alle tue richieste di rimborso.', - cta: 'Aggiungi indirizzo', - }, + addHomeAddress: {title: 'Aggiungi il tuo indirizzo di casa per il tracciamento della distanza', subtitle: 'Account', cta: 'Aggiungi indirizzo'}, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, unlockBankAccount: { workspaceTitle: 'Il conto bancario della tua azienda è stato bloccato', @@ -1242,8 +1237,8 @@ const translations: TranslationDeepObject = { title: 'L’indirizzo di casa è obbligatorio', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Prima di tenere traccia della distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le detrazioni per pendolarismo.` - : 'Prima di tenere traccia della distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro usa questo indirizzo per le detrazioni per pendolarismo.', + ? `Prima di poter tracciare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} utilizza questo indirizzo per le detrazioni per pendolari.` + : 'Prima di tenere traccia della distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro usa questo indirizzo per le detrazioni pendolari.', cta: 'Aggiungi indirizzo di casa', }, amount: 'Importo', @@ -7079,16 +7074,16 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, summaryDisabled: 'Nessuna esclusione per il tragitto', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Escludi ${distance} ${unit} per rimborso`, optionDisabledTitle: 'Non escludere gli spostamenti casa-lavoro', - optionDisabledHelp: 'Nessun tragitto casa-lavoro viene rimosso dalle richieste.', + optionDisabledHelp: 'Nessun tragitto casa-lavoro viene rimosso dalle note spese.', optionFixedDistanceTitle: 'Escludi una distanza fissa per richiesta', optionFixedDistanceHelp: 'Detrai la stessa distanza del tragitto casa-lavoro da ogni richiesta. Ideale per chi invia una richiesta per ogni giorno lavorativo.', distanceLabel: 'Distanza', summaryHomeAndOffice: 'Usa gli indirizzi di casa e ufficio', optionHomeAndOfficeTitle: 'Calcola per casa e ufficio', - optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, la modalità di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni sul tragitto casa-lavoro.', + optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, l’accordo di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni sul tragitto casa-lavoro.', workspaceAddressRequired: { title: 'Non così in fretta...', - promptStart: 'Non puoi attivare l’impostazione “Calcola per casa e ufficio” finché non aggiungi prima una sede dell’ufficio in ', + promptStart: 'Non puoi abilitare l’impostazione di calcolo per casa e ufficio finché non aggiungi prima una sede dell’ufficio in', linkText: 'Panoramica', promptEnd: '.', cta: 'Capito', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index ae63561a4fc3..b771e16dd55d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -948,7 +948,7 @@ const translations: TranslationDeepObject = { }, fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, validateAccount: {title: 'アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, - addHomeAddress: {title: '自宅住所を追加', subtitle: '管理者が距離経費に通勤分の除外を有効にしました。申請に適用できるよう、自宅住所を追加してください。', cta: '住所を追加'}, + addHomeAddress: {title: '距離の追跡のために自宅住所を追加してください', subtitle: 'アカウント', cta: '住所を追加'}, fixFailedBilling: {title: '登録されているカードから請求できませんでした', subtitle: 'サブスクリプション'}, unlockBankAccount: { workspaceTitle: 'ビジネス用銀行口座がロックされました', @@ -1221,8 +1221,8 @@ const translations: TranslationDeepObject = { title: '自宅住所は必須です', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除にこの住所を使用します。` - : '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースは通勤控除にこの住所を使用します。', + ? `距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除のためにこの住所を使用します。` + : '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースは通勤控除のためにこの住所を使用します。', cta: '自宅住所を追加', }, amount: '金額', @@ -6994,16 +6994,16 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から summaryDisabled: '通勤除外なし', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `申請ごとに ${distance} ${unit} を除外します`, optionDisabledTitle: '通勤を除外しない', - optionDisabledHelp: '申請から通勤分は差し引かれません。', + optionDisabledHelp: '通勤分は申請から除外されていません。', optionFixedDistanceTitle: '申請ごとに一定距離を除外します', optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', distanceLabel: '距離', summaryHomeAndOffice: '自宅と勤務先の所在地を使用する', optionHomeAndOfficeTitle: '自宅とオフィス別に計算', - optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィスの割り当てを使用して、通勤分の除外を計算します。', + optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィスの割り当てを使用して、通勤の除外額を計算します。', workspaceAddressRequired: { title: 'ちょっと待ってください…', - promptStart: '「自宅とオフィスで計算」を有効にするには、まず', + promptStart: '自宅とオフィスで計算する設定は、先にオフィスの所在地を追加しないと有効にできません', linkText: '概要', promptEnd: 'でオフィスの住所を追加してください。', cta: '了解しました', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0a5bd299e44d..a3236a46fa55 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -959,11 +959,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portemonnee', }, validateAccount: {title: 'Valideer je account', subtitle: 'Account', cta: 'Valideren'}, - addHomeAddress: { - title: 'Voeg een huisadres toe', - subtitle: 'Je beheerder heeft woon-werkuitzonderingen ingeschakeld voor afstandskosten. Voeg een huisadres toe zodat we die op je declaraties kunnen toepassen.', - cta: 'Adres toevoegen', - }, + addHomeAddress: {title: 'Voeg je huisadres toe voor afstandsregistratie', subtitle: 'Account', cta: 'Adres toevoegen'}, fixFailedBilling: {title: 'We konden je kaart in ons bestand niet belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Je zakelijke bankrekening is geblokkeerd', @@ -1240,9 +1236,9 @@ const translations: TranslationDeepObject = { title: 'Huisadres is vereist', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werkaftrek.` - : 'Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. Deze workspace gebruikt dit adres voor woon-werkaftrek.', - cta: 'Huisadres toevoegen', + ? `Voordat je een afstand bijhoudt, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werk­aftrek.` + : 'Voordat je afstand kunt registreren, moet je je thuisadres toevoegen aan je privéprofiel. Deze workspace gebruikt dit adres voor woon-werk­aftrek.', + cta: 'Thuisadres toevoegen', }, amount: 'Bedrag', percent: 'Procent', @@ -7066,16 +7062,16 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, summaryDisabled: 'Geen woon-werkuitzondering', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Sluit ${distance} ${unit} per declaratie uit`, optionDisabledTitle: 'Woon-werkverkeer niet uitsluiten', - optionDisabledHelp: 'Er wordt geen woon-werkverkeer uit declaraties verwijderd.', + optionDisabledHelp: 'Er is geen woon-werkverkeer uit declaraties verwijderd.', optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', distanceLabel: 'Afstand', - summaryHomeAndOffice: 'Huis- en kantoorlocaties gebruiken', - optionHomeAndOfficeTitle: 'Bereken op basis van huis en kantoor', - optionHomeAndOfficeHelp: 'Gebruik het huisadres, de werkregeling en de kantoorindeling van het lid om woon-werkuitzonderingen te berekenen.', + summaryHomeAndOffice: 'Gebruik thuis- en kantoorlokaties', + optionHomeAndOfficeTitle: 'Berekenen per thuis en kantoor', + optionHomeAndOfficeHelp: 'Gebruik het woonadres, werkarrangement en kantoorindeling van het lid om woon-werkuitzonderingen te berekenen.', workspaceAddressRequired: { title: 'Niet zo snel...', - promptStart: 'Je kunt de instelling ‘berekenen op basis van huis en kantoor’ pas inschakelen nadat je eerst een kantoorlocatie hebt toegevoegd in ', + promptStart: 'Je kunt de instelling ‘berekenen op basis van thuis en kantoor’ pas inschakelen nadat je eerst een kantoorlocatie hebt toegevoegd in', linkText: 'Overzicht', promptEnd: '.', cta: 'Begrepen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index aeaca4f7018c..898ad239e533 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -961,11 +961,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portfel', }, validateAccount: {title: 'Zweryfikuj swoje konto', subtitle: 'Konto', cta: 'Zatwierdź'}, - addHomeAddress: { - title: 'Dodaj adres domowy', - subtitle: 'Administrator włączył wykluczenia dojazdów dla wydatków odległościowych. Dodaj adres domowy, żebyśmy mogli zastosować je do twoich rozliczeń.', - cta: 'Dodaj adres', - }, + addHomeAddress: {title: 'Dodaj swój adres domowy do śledzenia dystansu', subtitle: 'Konto', cta: 'Dodaj adres'}, fixFailedBilling: {title: 'Nie mogliśmy obciążyć zapisanej karty', subtitle: 'Subskrypcja'}, unlockBankAccount: { workspaceTitle: 'Twoje firmowe konto bankowe zostało zablokowane', @@ -1236,8 +1232,8 @@ const translations: TranslationDeepObject = { title: 'Adres domowy jest wymagany', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy.` - : 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. Ta przestrzeń robocza używa tego adresu do odliczeń za dojazdy.', + ? `Zanim zaczniesz śledzić przejechany dystans, dodaj swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy do pracy.` + : 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. To miejsce pracy używa tego adresu do odliczeń za dojazdy.', cta: 'Dodaj adres domowy', }, amount: 'Kwota', @@ -7044,21 +7040,21 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, summaryDisabled: 'Bez wykluczenia dojazdów', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Wyklucz ${distance} ${unit} na zgłoszenie`, optionDisabledTitle: 'Nie wykluczaj dojazdów', - optionDisabledHelp: 'Żaden dojazd nie jest usuwany z rozliczeń.', + optionDisabledHelp: 'Nie usunięto żadnego dojazdu z rozliczeń.', optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', distanceLabel: 'Dystans', - summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', + summaryHomeAndOffice: 'Używaj lokalizacji domu i biura', optionHomeAndOfficeTitle: 'Oblicz według domu i biura', - optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy i przypisanego biura, aby obliczyć wykluczenia dojazdów.', + optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy i przypisania do biura, aby obliczyć wyłączenia z dojazdów.', workspaceAddressRequired: { - title: 'Nie tak szybko…', - promptStart: 'Nie możesz włączyć ustawienia obliczania według domu i biura, dopóki najpierw nie dodasz lokalizacji biura w ', + title: 'Nie tak prędko…', + promptStart: 'Nie możesz włączyć ustawienia „obliczaj według domu i biura”, dopóki najpierw nie dodasz lokalizacji biura w', linkText: 'Przegląd', promptEnd: '.', cta: 'Rozumiem', }, - errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', invalidAddress: 'Podaj prawidłowy adres', distanceTooLarge: 'Odległość jest zbyt duża.'}, + errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', invalidAddress: 'Wpisz poprawny adres', distanceTooLarge: 'Odległość jest zbyt duża.'}, }, distance: 'Dystans', centrallyManage: 'Centralnie zarządzaj stawkami, śledź w milach lub kilometrach i ustaw domyślną kategorię.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index cde9315dfb4e..e55e55f3b0a5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -959,11 +959,7 @@ const translations: TranslationDeepObject = { subtitle: 'Carteira', }, validateAccount: {title: 'Valide sua conta', subtitle: 'Conta', cta: 'Validar'}, - addHomeAddress: { - title: 'Adicionar um endereço residencial', - subtitle: 'Seu administrador ativou exclusões de deslocamento para despesas de distância. Adicione um endereço residencial para que possamos aplicá-las aos seus reembolsos.', - cta: 'Adicionar endereço', - }, + addHomeAddress: {title: 'Adicione seu endereço residencial para rastrear distâncias', subtitle: 'Conta', cta: 'Adicionar endereço'}, fixFailedBilling: {title: 'Não foi possível cobrar o cartão cadastrado', subtitle: 'Assinatura'}, unlockBankAccount: { workspaceTitle: 'Sua conta bancária comercial foi bloqueada', @@ -1240,8 +1236,8 @@ const translations: TranslationDeepObject = { title: 'Endereço residencial é obrigatório', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Antes de registrar distâncias, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` - : 'Antes de registrar distâncias, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.', + ? `Antes de controlar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` + : 'Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este espaço de trabalho usa esse endereço para deduções de deslocamento.', cta: 'Adicionar endereço residencial', }, amount: 'Valor', @@ -7066,16 +7062,16 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, summaryDisabled: 'Sem exclusão de deslocamento', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por solicitação`, optionDisabledTitle: 'Não excluir deslocamentos de casa para o trabalho', - optionDisabledHelp: 'Nenhum deslocamento é removido das solicitações.', + optionDisabledHelp: 'Nenhum deslocamento é removido dos reembolsos.', optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', distanceLabel: 'Distância', - summaryHomeAndOffice: 'Usar endereços de casa e do escritório', + summaryHomeAndOffice: 'Usar localizações de casa e escritório', optionHomeAndOfficeTitle: 'Calcular por casa e escritório', - optionHomeAndOfficeHelp: 'Use o endereço residencial do membro, o regime de trabalho e a designação de escritório para calcular as exclusões de deslocamento.', + optionHomeAndOfficeHelp: 'Use o endereço residencial do membro, o regime de trabalho e a alocação de escritório para calcular as exclusões de deslocamento.', workspaceAddressRequired: { title: 'Calma lá...', - promptStart: 'Você não pode ativar a configuração de cálculo por casa e escritório até adicionar primeiro um endereço de escritório em ', + promptStart: 'Você não pode ativar a configuração de calcular por casa e escritório até primeiro adicionar um endereço de escritório em', linkText: 'Visão geral', promptEnd: '.', cta: 'Entendi', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 811ee014fbbd..9b702b663637 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -929,7 +929,7 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, validateAccount: {title: '验证您的账户', subtitle: '账户', cta: '验证'}, - addHomeAddress: {title: '添加家庭住址', subtitle: '您的管理员已为里程报销启用了通勤排除。请添加家庭住址,以便我们将其应用到您的报销申请中。', cta: '添加地址'}, + addHomeAddress: {title: '添加家庭住址以进行距离跟踪', subtitle: '账户', cta: '添加地址'}, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, unlockBankAccount: { workspaceTitle: '您的企业银行账户已被锁定', @@ -1184,11 +1184,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: '需要填写家庭住址', + title: '家庭住址为必填项', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `在开始记录里程之前,您需要先在个人资料中添加家庭住址。${workspaceName} 会使用该地址计算通勤扣除。` - : '在开始记录里程之前,您需要先在个人资料中添加家庭住址。此工作区会使用该地址计算通勤扣除。', + ? `在开始记录通勤距离前,您需要在私人资料中添加家庭住址。${workspaceName} 会使用该地址计算通勤扣减。` + : '在开始记录里程前,您需要在私人资料中添加家庭住址。此工作区会使用该地址计算通勤扣除。', cta: '添加家庭住址', }, amount: '金额', @@ -6827,19 +6827,19 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM summaryDisabled: '不排除通勤', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `每次报销排除 ${distance} ${unit}`, optionDisabledTitle: '不要排除通勤', - optionDisabledHelp: '不会从报销申请中扣除任何通勤里程。', + optionDisabledHelp: '未从报销中扣除任何通勤费用。', optionFixedDistanceTitle: '为每笔报销排除固定距离', optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', distanceLabel: '距离', - summaryHomeAndOffice: '使用家庭和办公地点', + summaryHomeAndOffice: '使用家庭和办公室位置', optionHomeAndOfficeTitle: '按家庭和办公室计算', - optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公地点分配来计算通勤扣除。', + optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公室分配来计算通勤排除项。', workspaceAddressRequired: { - title: '别急……', - promptStart: '您需要先在', + title: '先别急……', + promptStart: '在您先在 中添加一个办公地点之前,无法启用“按家庭和办公室计算”设置', linkText: '概览', promptEnd: '中添加办公地点,才能启用“按家庭和办公室计算”设置。', - cta: '知道了', + cta: '明白了', }, errors: {distanceMustBePositive: '距离必须是一个正整数。', invalidAddress: '请输入有效地址', distanceTooLarge: '距离过大。'}, }, From 94c7e0652a40f1a1f125b2a622331da4fdb252ac Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 11 Aug 2026 11:39:50 +0200 Subject: [PATCH 161/466] Rename address --- src/languages/de.ts | 3 +- src/languages/el.ts | 3 +- src/languages/en.ts | 3 +- src/languages/es.ts | 3 +- src/languages/fr.ts | 3 +- src/languages/it.ts | 3 +- src/languages/ja.ts | 3 +- src/languages/nl.ts | 3 +- src/languages/pl.ts | 3 +- src/languages/pt-BR.ts | 3 +- src/languages/zh-hans.ts | 3 +- src/pages/settings/Profile/ProfilePage.tsx | 14 +++++- src/selectors/Policy.ts | 10 +++++ tests/ui/ProfilePageTest.tsx | 50 +++++++++++++++++++++- 14 files changed, 93 insertions(+), 14 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 9ce6e2052ac6..ba8b28d31285 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3539,7 +3539,8 @@ ${amount} für ${merchant} – ${date}`, legalName: 'Rechtlicher Name', legalFirstName: 'Rechtlicher Vorname', legalLastName: 'Rechtlicher Nachname', - address: 'Adresse', + address: 'Privatadresse', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} verwendet diese Adresse für Pendlerausschlüsse.`, error: { dateShouldBeBefore: (dateString: string) => `Datum muss vor dem ${dateString} liegen`, dateShouldBeAfter: (dateString: string) => `Datum muss nach ${dateString} liegen`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 7e06c05c64c9..2ff82e842bb1 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3589,7 +3589,8 @@ ${amount} για ${merchant} - ${date}`, legalName: 'Νομικό όνομα', legalFirstName: 'Επίσημο μικρό όνομα', legalLastName: 'Επώνυμο (όπως αναγράφεται στα επίσημα έγγραφα)', - address: 'Διεύθυνση', + address: 'Διεύθυνση κατοικίας', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `Ο χώρος εργασίας ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για τις εξαιρέσεις μετακίνησης.`, error: { dateShouldBeBefore: (dateString: string) => `Η ημερομηνία πρέπει να είναι πριν από ${dateString}`, dateShouldBeAfter: (dateString: string) => `Η ημερομηνία πρέπει να είναι μετά από ${dateString}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index db59cb8e0d6c..b5fad539c6ee 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3647,7 +3647,8 @@ const translations = { legalName: 'Legal name', legalFirstName: 'Legal first name', legalLastName: 'Legal last name', - address: 'Address', + address: 'Home address', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} uses this address for commuter exclusions.`, error: { dateShouldBeBefore: (dateString: string) => `Date should be before ${dateString}`, dateShouldBeAfter: (dateString: string) => `Date should be after ${dateString}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 4e488c519dbb..ff305cd43ac4 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3452,7 +3452,8 @@ ${amount} para ${merchant} - ${date}`, legalName: 'Nombre completo', legalFirstName: 'Nombre legal', legalLastName: 'Apellidos legales', - address: 'Dirección', + address: 'Dirección de casa', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa esta dirección para las exclusiones de desplazamiento.`, error: { dateShouldBeBefore: (dateString) => `La fecha debe ser anterior a ${dateString}`, dateShouldBeAfter: (dateString) => `La fecha debe ser posterior a ${dateString}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6993d74ca838..2fc99b90cde5 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3546,7 +3546,8 @@ ${amount} pour ${merchant} - ${date}`, legalName: 'Nom légal', legalFirstName: 'Prénom légal', legalLastName: 'Nom de famille légal', - address: 'Adresse', + address: 'Adresse du domicile', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilise cette adresse pour les exclusions de trajet domicile-travail.`, error: { dateShouldBeBefore: (dateString: string) => `La date doit être antérieure au ${dateString}`, dateShouldBeAfter: (dateString: string) => `La date doit être postérieure au ${dateString}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index a57c5483d4e1..170f09b43f18 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3527,7 +3527,8 @@ ${amount} per ${merchant} - ${date}`, legalName: 'Nome legale', legalFirstName: 'Nome legale di battesimo', legalLastName: 'Cognome legale', - address: 'Indirizzo', + address: 'Indirizzo di casa', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa questo indirizzo per le esclusioni sul tragitto casa-lavoro.`, error: { dateShouldBeBefore: (dateString: string) => `La data deve essere precedente a ${dateString}`, dateShouldBeAfter: (dateString: string) => `La data deve essere successiva a ${dateString}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b771e16dd55d..6a8705bfcda5 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3499,7 +3499,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' legalName: '法的氏名', legalFirstName: '法的な名', legalLastName: '法的な姓', - address: '住所', + address: '自宅住所', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} はこの住所を通勤分の除外に使用します。`, error: { dateShouldBeBefore: (dateString: string) => `日付は${dateString}より前である必要があります`, dateShouldBeAfter: (dateString: string) => `日付は${dateString}より後の日付にしてください`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a3236a46fa55..0dfed06a7e88 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3527,7 +3527,8 @@ ${amount} voor ${merchant} - ${date}`, legalName: 'Wettelijke naam', legalFirstName: 'Juridische voornaam', legalLastName: 'Wettelijke achternaam', - address: 'Adres', + address: 'Huisadres', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} gebruikt dit adres voor woon-werkuitsluitingen.`, error: { dateShouldBeBefore: (dateString: string) => `Datum moet vóór ${dateString} zijn`, dateShouldBeAfter: (dateString: string) => `Datum moet na ${dateString} liggen`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 898ad239e533..b0099039a310 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3513,7 +3513,8 @@ ${amount} dla ${merchant} - ${date}`, legalName: 'Imię i nazwisko (pełne)', legalFirstName: 'Imię (zgodnie z dokumentem tożsamości)', legalLastName: 'Nazwisko zgodne z dokumentami', - address: 'Adres', + address: 'Adres domowy', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} używa tego adresu do wykluczeń dojazdów.`, error: { dateShouldBeBefore: (dateString: string) => `Data powinna być wcześniejsza niż ${dateString}`, dateShouldBeAfter: (dateString: string) => `Data powinna być po ${dateString}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e55e55f3b0a5..093c9d036c20 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3517,7 +3517,8 @@ ${amount} para ${merchant} - ${date}`, legalName: 'Nome legal', legalFirstName: 'Primeiro nome legal', legalLastName: 'Sobrenome legal', - address: 'Endereço', + address: 'Endereço residencial', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa este endereço para as exclusões de deslocamento.`, error: { dateShouldBeBefore: (dateString: string) => `A data deve ser anterior a ${dateString}`, dateShouldBeAfter: (dateString: string) => `A data deve ser posterior a ${dateString}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 9b702b663637..60b87fdda1ed 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3414,7 +3414,8 @@ ${amount},商户:${merchant} - 日期:${date}`, legalName: '法定姓名', legalFirstName: '法定名(名)', legalLastName: '法定姓氏', - address: '地址', + address: '家庭住址', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} 会使用该地址计算通勤排除。`, error: { dateShouldBeBefore: (dateString: string) => `日期应早于 ${dateString}`, dateShouldBeAfter: (dateString: string) => `日期应晚于 ${dateString}`, diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index a279839b6d33..355b4ad6911e 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -53,6 +53,7 @@ import type {ScrollView as RNScrollView} from 'react-native'; import type {ValueOf} from 'type-fest'; import {useRoute} from '@react-navigation/native'; +import {homeAndOfficeCommuterExclusionPolicyNameSelector} from '@selectors/Policy'; import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; @@ -92,6 +93,7 @@ function ProfilePage() { const privateDetails = privatePersonalDetails ?? {}; const legalName = `${privateDetails.legalFirstName ?? ''} ${privateDetails.legalLastName ?? ''}`.trim(); const {shouldShowAddHomeAddress: shouldShowAddHomeAddressGBR} = useTimeSensitiveHomeAddress(); + const [commuterExclusionsWorkspaceName] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: homeAndOfficeCommuterExclusionPolicyNameSelector}); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); const {isActingAsDelegate} = useDelegateNoAccessState(); @@ -158,7 +160,15 @@ function ProfilePage() { Navigation.navigate(ROUTES.SETTINGS_PRIVATE_PERSONAL_DETAILS.getRoute(fieldToFocus)); }; - const privateOptions = [ + const privateOptions: Array<{ + description: string; + title: string; + testID: string; + sentryLabel: string; + action: () => void; + brickRoadIndicator?: ValueOf; + furtherDetails?: string; + }> = [ { description: translate('privatePersonalDetails.legalName'), title: legalName, @@ -188,6 +198,7 @@ function ProfilePage() { sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.ADDRESS, action: () => navigateToPrivateDetails(INPUT_IDS.ADDRESS_LINE_1), brickRoadIndicator: shouldShowAddHomeAddressGBR ? CONST.BRICK_ROAD_INDICATOR_STATUS.INFO : undefined, + furtherDetails: commuterExclusionsWorkspaceName ? translate('privatePersonalDetails.commuterExclusionsHint', {workspaceName: commuterExclusionsWorkspaceName}) : undefined, }, ]; @@ -309,6 +320,7 @@ function ProfilePage() { wrapperStyle={styles.sectionMenuItemTopDescription} onPress={detail.action} brickRoadIndicator={detail.brickRoadIndicator} + furtherDetails={detail.furtherDetails} pressableTestID={detail?.testID} sentryLabel={detail.sentryLabel} /> diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index e2d6483f4f9a..c5270f17ec2b 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -275,6 +275,15 @@ const hasOnlyPersonalPoliciesSelector = (policies: OnyxCollection): bool return !Object.values(policies ?? {}).some((policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); }; +/** + * Returns the name of a workspace the member belongs to that calculates commuter exclusions from the member's + * home address, so the private home address row can name the workspace relying on it. + */ +const homeAndOfficeCommuterExclusionPolicyNameSelector = (policies: OnyxCollection): string | undefined => + Object.values(policies ?? {}).find( + (policy) => policy?.commuterExclusions?.method === CONST.POLICY.COMMUTER_EXCLUSION_METHOD.HOME_AND_OFFICE && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + )?.name; + function isAdminPolicyConnectedTo(policy: OnyxEntry, connectionName: ReusablePolicyConnectionName): policy is Policy { return !!policy && policy.role === CONST.POLICY.ROLE.ADMIN && !!policy.connections?.[connectionName]; } @@ -410,6 +419,7 @@ export { hasReusablePoliciesConnectedToSelector, lastWorkspaceNumberSelector, hasOnlyPersonalPoliciesSelector, + homeAndOfficeCommuterExclusionPolicyNameSelector, policyNameSelector, policyTypeSelector, areInvoicesEnabledSelector, diff --git a/tests/ui/ProfilePageTest.tsx b/tests/ui/ProfilePageTest.tsx index afbb6f1f295d..b98c4757ae06 100644 --- a/tests/ui/ProfilePageTest.tsx +++ b/tests/ui/ProfilePageTest.tsx @@ -73,8 +73,21 @@ jest.mock('@react-navigation/native', () => { jest.mock('@components/MenuItemWithTopDescription', () => { const ReactMock = require('react') as typeof React; const {Text} = require('react-native') as {Text: React.ComponentType<{testID: string; children?: React.ReactNode}>}; - return ({pressableTestID, brickRoadIndicator}: {pressableTestID: string; brickRoadIndicator?: ValueOf}) => - ReactMock.createElement(Text, {testID: pressableTestID}, `${brickRoadIndicator ?? 'none'}-brickRoadIndicator`); + return ({ + pressableTestID, + brickRoadIndicator, + furtherDetails, + }: { + pressableTestID: string; + brickRoadIndicator?: ValueOf; + furtherDetails?: string; + }) => + ReactMock.createElement( + ReactMock.Fragment, + null, + ReactMock.createElement(Text, {testID: pressableTestID}, `${brickRoadIndicator ?? 'none'}-brickRoadIndicator`), + furtherDetails ? ReactMock.createElement(Text, {testID: `${pressableTestID}-further-details`}, furtherDetails) : null, + ); }); describe('ProfilePage contact method indicator', () => { @@ -251,6 +264,39 @@ describe('ProfilePage - agent account', () => { expect(screen.getByText('Private')).toBeDefined(); }); + it('names the workspace that calculates commuter exclusions from the home address', async () => { + await setupUser('user@expensify.com'); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, { + id: '1', + name: 'Boulder Development', + commuterExclusions: {method: 'homeAndOffice'}, + }); + }); + + renderPageWithNavigation(SCREENS.SETTINGS.PROFILE.ROOT); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId('address-menu-item-further-details')).toHaveTextContent('Boulder Development uses this address for commuter exclusions.'); + }); + + it('does not mention commuter exclusions when no workspace calculates them from the home address', async () => { + await setupUser('user@expensify.com'); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}1`, { + id: '1', + name: 'Boulder Development', + commuterExclusions: {method: 'fixedDistance', fixedDistance: 1, fixedDistanceUnit: 'mi'}, + }); + }); + + renderPageWithNavigation(SCREENS.SETTINGS.PROFILE.ROOT); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId('address-menu-item')).toBeDefined(); + expect(screen.queryByTestId('address-menu-item-further-details')).toBeNull(); + }); + it('shows contact methods, pronouns, timezone and private section for non-agent account', async () => { await setupUser('user@expensify.com'); From 1f20eef6fcedcf437b67371f87cebd36a5bde70e Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 11 Aug 2026 11:49:45 +0200 Subject: [PATCH 162/466] parrot --- src/languages/de.ts | 20 ++++++++++---------- src/languages/el.ts | 24 ++++++++++++------------ src/languages/es.ts | 16 ++++++++-------- src/languages/fr.ts | 14 +++++++------- src/languages/it.ts | 14 +++++++------- src/languages/ja.ts | 12 ++++++------ src/languages/nl.ts | 20 ++++++++++---------- src/languages/pl.ts | 14 +++++++------- src/languages/pt-BR.ts | 12 ++++++------ src/languages/zh-hans.ts | 22 +++++++++++----------- 10 files changed, 84 insertions(+), 84 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index ba8b28d31285..329ce33187a4 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -960,7 +960,7 @@ const translations: TranslationDeepObject = { subtitle: 'Wallet', }, validateAccount: {title: 'Bestätigen Sie Ihr Konto', subtitle: 'Konto', cta: 'Bestätigen'}, - addHomeAddress: {title: 'Fügen Sie Ihre Privatadresse für die Distanzverfolgung hinzu', subtitle: 'Konto', cta: 'Adresse hinzufügen'}, + addHomeAddress: {title: 'Fügen Sie Ihre Privatadresse für die Entfernungserfassung hinzu', subtitle: 'Konto', cta: 'Adresse hinzufügen'}, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Ihr Geschäftskonto wurde gesperrt', @@ -1235,11 +1235,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Privatadresse ist erforderlich', + title: 'Die Wohnadresse ist erforderlich', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Bevor Sie eine Entfernung erfassen, müssen Sie Ihre Privatadresse zu Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` - : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse zu Ihrem privaten Profil hinzufügen. Dieser Workspace verwendet diese Adresse für Pendlerabzüge.', + ? `Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Fahrtkostenermäßigungen.` + : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. Dieser Workspace verwendet diese Adresse für Pendlerabzüge.', cta: 'Privatadresse hinzufügen', }, amount: 'Betrag', @@ -3540,7 +3540,7 @@ ${amount} für ${merchant} – ${date}`, legalFirstName: 'Rechtlicher Vorname', legalLastName: 'Rechtlicher Nachname', address: 'Privatadresse', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} verwendet diese Adresse für Pendlerausschlüsse.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} verwendet diese Adresse für Pendlerausnahmen.`, error: { dateShouldBeBefore: (dateString: string) => `Datum muss vor dem ${dateString} liegen`, dateShouldBeAfter: (dateString: string) => `Datum muss nach ${dateString} liegen`, @@ -7104,16 +7104,16 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, summaryDisabled: 'Kein Ausschluss von Pendelstrecken', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `${distance} ${unit} pro Abrechnung ausschließen`, optionDisabledTitle: 'Pendelfahrten nicht ausschließen', - optionDisabledHelp: 'Es wird keine Pendelstrecke aus den Anträgen entfernt.', + optionDisabledHelp: 'Kein Arbeitsweg wird aus Abrechnungen entfernt.', optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', distanceLabel: 'Entfernung', - summaryHomeAndOffice: 'Wohn- und Bürostandorte verwenden', + summaryHomeAndOffice: 'Standortangaben für Zuhause und Büro verwenden', optionHomeAndOfficeTitle: 'Nach Zuhause und Büro berechnen', - optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuweisung der Mitglieder, um Pendelausschlüsse zu berechnen.', + optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuordnung des Mitglieds, um Pendelausschlüsse zu berechnen.', workspaceAddressRequired: { - title: 'Nicht so schnell …', - promptStart: 'Sie können die Einstellung „Nach Zuhause und Büro berechnen“ erst aktivieren, nachdem Sie zuerst einen Bürostandort in hinzugefügt haben', + title: 'Nur nicht so schnell …', + promptStart: 'Sie können die Einstellung „Berechnung nach Zuhause und Büro“ nicht aktivieren, bevor Sie zuerst einen Bürostandort hinzugefügt haben in', linkText: 'Übersicht', promptEnd: ' hinzugefügt haben.', cta: 'Verstanden', diff --git a/src/languages/el.ts b/src/languages/el.ts index 2ff82e842bb1..700a86232210 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1001,7 +1001,7 @@ const translations: TranslationDeepObject = { subtitle: 'Λογαριασμός', cta: 'Επικυρώστε', }, - addHomeAddress: {title: 'Προσθέστε τη διεύθυνση κατοικίας σας για καταγραφή αποστάσεων', subtitle: 'Λογαριασμός', cta: 'Προσθήκη διεύθυνσης'}, + addHomeAddress: {title: 'Προσθέστε τη διεύθυνση κατοικίας σας για παρακολούθηση αποστάσεων', subtitle: 'Λογαριασμός', cta: 'Προσθέστε διεύθυνση'}, fixFailedBilling: { title: 'Δεν μπορέσαμε να χρεώσουμε την αποθηκευμένη κάρτα σας', subtitle: 'Συνδρομή', @@ -1286,9 +1286,9 @@ const translations: TranslationDeepObject = { title: 'Η διεύθυνση κατοικίας είναι υποχρεωτική', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Το ${workspaceName} χρησιμοποιεί αυτήν τη διεύθυνση για φορολογικές εκπτώσεις μετακίνησης.` - : 'Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Ο χώρος εργασίας αυτός χρησιμοποιεί αυτήν τη διεύθυνση για εκπτώσεις μετακίνησης προς και από την εργασία.', - cta: 'Προσθήκη οικιακής διεύθυνσης', + ? `Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Το ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για εκπτώσεις μετακίνησης.` + : 'Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για εκπτώσεις μετακίνησης προς και από την εργασία.', + cta: 'Προσθήκη διεύθυνσης κατοικίας', }, amount: 'Ποσό', percent: 'Ποσοστό', @@ -3589,8 +3589,8 @@ ${amount} για ${merchant} - ${date}`, legalName: 'Νομικό όνομα', legalFirstName: 'Επίσημο μικρό όνομα', legalLastName: 'Επώνυμο (όπως αναγράφεται στα επίσημα έγγραφα)', - address: 'Διεύθυνση κατοικίας', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `Ο χώρος εργασίας ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για τις εξαιρέσεις μετακίνησης.`, + address: 'Οικιακή διεύθυνση', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `Το ${workspaceName} χρησιμοποιεί αυτήν τη διεύθυνση για εξαιρέσεις μετακίνησης.`, error: { dateShouldBeBefore: (dateString: string) => `Η ημερομηνία πρέπει να είναι πριν από ${dateString}`, dateShouldBeAfter: (dateString: string) => `Η ημερομηνία πρέπει να είναι μετά από ${dateString}`, @@ -7353,22 +7353,22 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Εξαίρεση ${distance} ${unit} ανά αίτημα`, summaryHomeAndOffice: 'Χρησιμοποιήστε τις τοποθεσίες σπιτιού και γραφείου', optionDisabledTitle: 'Να μην εξαιρούνται οι μετακινήσεις από και προς την εργασία', - optionDisabledHelp: 'Καμία μετακίνηση δεν αφαιρείται από τις αξιώσεις.', + optionDisabledHelp: 'Δεν έχει αφαιρεθεί καμία μετακίνηση από τις αιτήσεις.', optionFixedDistanceTitle: 'Εξαίρεση σταθερής απόστασης ανά αίτημα', optionFixedDistanceHelp: 'Αφαιρέστε την ίδια απόσταση μετακίνησης από κάθε αίτημα. Ιδανικό για μέλη που υποβάλλουν ένα αίτημα ανά εργάσιμη ημέρα.', optionHomeAndOfficeTitle: 'Υπολογισμός ανά σπίτι και γραφείο', - optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας μέλους, τη ρύθμιση εργασίας και την ανάθεση γραφείου για τον υπολογισμό των εξαιρέσεων μετακίνησης.', + optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας του μέλους, τη ρύθμιση εργασίας και την ανάθεση γραφείου για τον υπολογισμό των εξαιρέσεων μετακίνησης.', distanceLabel: 'Απόσταση', workspaceAddressRequired: { - title: 'Όχι και τόσο γρήγορα...', - promptStart: 'Δεν μπορείτε να ενεργοποιήσετε τη ρύθμιση υπολογισμού ανά σπίτι και γραφείο μέχρι να προσθέσετε πρώτα μια τοποθεσία γραφείου στο', + title: 'Όχι τόσο γρήγορα...', + promptStart: 'Δεν μπορείτε να ενεργοποιήσετε τη ρύθμιση υπολογισμού με βάση το σπίτι και το γραφείο μέχρι πρώτα να προσθέσετε τοποθεσία γραφείου στο', linkText: 'Επισκόπηση', promptEnd: '.', - cta: 'Εντάξει', + cta: 'Το κατάλαβα', }, errors: { distanceMustBePositive: 'Η απόσταση πρέπει να είναι ένας θετικός ακέραιος αριθμός.', - invalidAddress: 'Παρακαλώ εισαγάγετε μια έγκυρη διεύθυνση', + invalidAddress: 'Παρακαλούμε εισαγάγετε έγκυρη διεύθυνση', distanceTooLarge: 'Η απόσταση είναι πολύ μεγάλη.', }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index ff305cd43ac4..325ee6a459e0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -954,7 +954,7 @@ const translations: TranslationDeepObject = { subtitle: 'Cuenta', cta: 'Validar', }, - addHomeAddress: {title: 'Añade tu dirección de casa para el seguimiento de distancias', subtitle: 'Cuenta', cta: 'Añade dirección'}, + addHomeAddress: {title: 'Añade tu dirección de casa para el seguimiento de distancias', subtitle: 'Cuenta', cta: 'Añadir dirección'}, fixFailedBilling: { title: 'No pudimos cobrar a la tarjeta registrada.', subtitle: 'Suscripción', @@ -1233,12 +1233,12 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'La dirección de domicilio es obligatoria', + title: 'La dirección particular es obligatoria', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Antes de organizar distancias, tienes que añadir tu dirección de casa a tu perfil privado. ${workspaceName} usa esta dirección para las deducciones por desplazamiento al trabajo.` - : 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo usa esta dirección para las deducciones por desplazamiento al trabajo.', - cta: 'Añadir dirección de casa', + ? `Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. ${workspaceName} usa esta dirección para las deducciones por desplazamiento.` + : 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo usa esa dirección para las deducciones por desplazamientos al trabajo.', + cta: 'Añadir dirección de domicilio', }, amount: 'Importe', percent: 'Porcentaje', @@ -3453,7 +3453,7 @@ ${amount} para ${merchant} - ${date}`, legalFirstName: 'Nombre legal', legalLastName: 'Apellidos legales', address: 'Dirección de casa', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa esta dirección para las exclusiones de desplazamiento.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utiliza esta dirección para las exclusiones de desplazamientos.`, error: { dateShouldBeBefore: (dateString) => `La fecha debe ser anterior a ${dateString}`, dateShouldBeAfter: (dateString) => `La fecha debe ser posterior a ${dateString}`, @@ -7071,14 +7071,14 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, summaryHomeAndOffice: 'Usar ubicaciones de casa y oficina', optionDisabledTitle: 'No excluir los desplazamientos al trabajo', - optionDisabledHelp: 'Ningún desplazamiento al trabajo se excluye de las reclamaciones.', + optionDisabledHelp: 'Ningún desplazamiento al trabajo se elimina de las reclamaciones.', optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', optionFixedDistanceHelp: 'Resta la misma distancia de desplazamiento de cada solicitud. Ideal para personas que envían una solicitud por día laborable.', optionHomeAndOfficeTitle: 'Calcular por casa y oficina', optionHomeAndOfficeHelp: 'Usa la dirección de casa del miembro, su modalidad de trabajo y su asignación de oficina para calcular las exclusiones de desplazamiento.', distanceLabel: 'Distancia', workspaceAddressRequired: { - title: 'No tan rápido...', + title: 'No tan deprisa...', promptStart: 'No puedes habilitar la configuración de cálculo por casa y oficina hasta que primero añadas una ubicación de oficina en', linkText: 'Resumen', promptEnd: '.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2fc99b90cde5..69555f041c99 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1239,12 +1239,12 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'L’adresse du domicile est obligatoire', + title: 'L’adresse domicile est obligatoire', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName ? `Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions liées aux trajets domicile-travail.` : 'Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions liées aux trajets domicile-travail.', - cta: 'Ajouter une adresse personnelle', + cta: 'Ajouter une adresse domicile', }, amount: 'Montant', percent: 'Pourcentage', @@ -3546,8 +3546,8 @@ ${amount} pour ${merchant} - ${date}`, legalName: 'Nom légal', legalFirstName: 'Prénom légal', legalLastName: 'Nom de famille légal', - address: 'Adresse du domicile', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilise cette adresse pour les exclusions de trajet domicile-travail.`, + address: 'Adresse personnelle', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilise cette adresse pour les exclusions liées aux trajets domicile-travail.`, error: { dateShouldBeBefore: (dateString: string) => `La date doit être antérieure au ${dateString}`, dateShouldBeAfter: (dateString: string) => `La date doit être postérieure au ${dateString}`, @@ -7132,17 +7132,17 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, summaryDisabled: 'Pas d’exclusion du trajet domicile-travail', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Exclure ${distance} ${unit} par demande`, optionDisabledTitle: 'Ne pas exclure les trajets domicile-travail', - optionDisabledHelp: 'Aucun trajet domicile-travail n’est retiré des déclarations.', + optionDisabledHelp: 'Aucun trajet domicile-travail n’est retiré des demandes.', optionFixedDistanceTitle: 'Exclure une distance fixe par demande', optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', distanceLabel: 'Distance', - summaryHomeAndOffice: 'Utiliser les emplacements domicile et bureau', + summaryHomeAndOffice: 'Utiliser les adresses domicile et bureau', optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', optionHomeAndOfficeHelp: 'Utiliser l’adresse personnelle du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet domicile-travail.', workspaceAddressRequired: { title: 'Pas si vite...', - promptStart: 'Vous ne pouvez pas activer le paramètre de calcul par domicile et bureau avant d’avoir d’abord ajouté un lieu de bureau dans', + promptStart: 'Vous ne pouvez pas activer le paramètre de calcul par domicile et bureau tant que vous n’avez pas d’abord ajouté une adresse de bureau dans', linkText: 'Aperçu', promptEnd: '.', cta: 'Compris', diff --git a/src/languages/it.ts b/src/languages/it.ts index 170f09b43f18..94862453a3e4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -961,7 +961,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portafoglio', }, validateAccount: {title: 'Conferma il tuo account', subtitle: 'Account', cta: 'Conferma'}, - addHomeAddress: {title: 'Aggiungi il tuo indirizzo di casa per il tracciamento della distanza', subtitle: 'Account', cta: 'Aggiungi indirizzo'}, + addHomeAddress: {title: 'Aggiungi il tuo indirizzo di casa per il monitoraggio delle distanze', subtitle: 'Account', cta: 'Aggiungi indirizzo'}, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, unlockBankAccount: { workspaceTitle: 'Il conto bancario della tua azienda è stato bloccato', @@ -1234,11 +1234,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'L’indirizzo di casa è obbligatorio', + title: "L'indirizzo di casa è obbligatorio", prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Prima di poter tracciare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} utilizza questo indirizzo per le detrazioni per pendolari.` - : 'Prima di tenere traccia della distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro usa questo indirizzo per le detrazioni pendolari.', + ? `Prima di registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le detrazioni per i pendolari.` + : 'Prima di poter registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro usa questo indirizzo per le detrazioni per pendolarismo.', cta: 'Aggiungi indirizzo di casa', }, amount: 'Importo', @@ -3528,7 +3528,7 @@ ${amount} per ${merchant} - ${date}`, legalFirstName: 'Nome legale di battesimo', legalLastName: 'Cognome legale', address: 'Indirizzo di casa', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa questo indirizzo per le esclusioni sul tragitto casa-lavoro.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa questo indirizzo per le esclusioni per pendolari.`, error: { dateShouldBeBefore: (dateString: string) => `La data deve essere precedente a ${dateString}`, dateShouldBeAfter: (dateString: string) => `La data deve essere successiva a ${dateString}`, @@ -7075,13 +7075,13 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, summaryDisabled: 'Nessuna esclusione per il tragitto', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Escludi ${distance} ${unit} per rimborso`, optionDisabledTitle: 'Non escludere gli spostamenti casa-lavoro', - optionDisabledHelp: 'Nessun tragitto casa-lavoro viene rimosso dalle note spese.', + optionDisabledHelp: 'Nessun tragitto casa-lavoro viene rimosso dalle richieste.', optionFixedDistanceTitle: 'Escludi una distanza fissa per richiesta', optionFixedDistanceHelp: 'Detrai la stessa distanza del tragitto casa-lavoro da ogni richiesta. Ideale per chi invia una richiesta per ogni giorno lavorativo.', distanceLabel: 'Distanza', summaryHomeAndOffice: 'Usa gli indirizzi di casa e ufficio', optionHomeAndOfficeTitle: 'Calcola per casa e ufficio', - optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, l’accordo di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni sul tragitto casa-lavoro.', + optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, la modalità di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni per il tragitto casa-lavoro.', workspaceAddressRequired: { title: 'Non così in fretta...', promptStart: 'Non puoi abilitare l’impostazione di calcolo per casa e ufficio finché non aggiungi prima una sede dell’ufficio in', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 6a8705bfcda5..fa43f95f9003 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1221,8 +1221,8 @@ const translations: TranslationDeepObject = { title: '自宅住所は必須です', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除のためにこの住所を使用します。` - : '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースは通勤控除のためにこの住所を使用します。', + ? `距離を記録する前に、まず非公開プロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除のためにこの住所を使用します。` + : '距離を記録する前に、まず自分のホームアドレスを非公開プロフィールに追加する必要があります。このワークスペースでは、その住所を通勤控除に使用します。', cta: '自宅住所を追加', }, amount: '金額', @@ -3500,7 +3500,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' legalFirstName: '法的な名', legalLastName: '法的な姓', address: '自宅住所', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} はこの住所を通勤分の除外に使用します。`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} は通勤手当の除外のためにこの住所を使用します。`, error: { dateShouldBeBefore: (dateString: string) => `日付は${dateString}より前である必要があります`, dateShouldBeAfter: (dateString: string) => `日付は${dateString}より後の日付にしてください`, @@ -6995,13 +6995,13 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から summaryDisabled: '通勤除外なし', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `申請ごとに ${distance} ${unit} を除外します`, optionDisabledTitle: '通勤を除外しない', - optionDisabledHelp: '通勤分は申請から除外されていません。', + optionDisabledHelp: '通勤分は一切申請から除外されていません。', optionFixedDistanceTitle: '申請ごとに一定距離を除外します', optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', distanceLabel: '距離', summaryHomeAndOffice: '自宅と勤務先の所在地を使用する', - optionHomeAndOfficeTitle: '自宅とオフィス別に計算', - optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィスの割り当てを使用して、通勤の除外額を計算します。', + optionHomeAndOfficeTitle: '自宅とオフィスで計算', + optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィスの割り当て情報を使用して、通勤に関する除外額を計算します。', workspaceAddressRequired: { title: 'ちょっと待ってください…', promptStart: '自宅とオフィスで計算する設定は、先にオフィスの所在地を追加しないと有効にできません', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0dfed06a7e88..c6c9bb08d9ea 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -959,7 +959,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portemonnee', }, validateAccount: {title: 'Valideer je account', subtitle: 'Account', cta: 'Valideren'}, - addHomeAddress: {title: 'Voeg je huisadres toe voor afstandsregistratie', subtitle: 'Account', cta: 'Adres toevoegen'}, + addHomeAddress: {title: 'Voeg je thuisadres toe voor afstandsregistratie', subtitle: 'Account', cta: 'Adres toevoegen'}, fixFailedBilling: {title: 'We konden je kaart in ons bestand niet belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Je zakelijke bankrekening is geblokkeerd', @@ -1233,11 +1233,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Huisadres is vereist', + title: 'Huisadres is verplicht', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Voordat je een afstand bijhoudt, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werk­aftrek.` - : 'Voordat je afstand kunt registreren, moet je je thuisadres toevoegen aan je privéprofiel. Deze workspace gebruikt dit adres voor woon-werk­aftrek.', + ? `Voordat je de afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werkaftrek.` + : 'Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. Deze workspace gebruikt dit adres voor woon-werk aftrekposten.', cta: 'Thuisadres toevoegen', }, amount: 'Bedrag', @@ -3528,7 +3528,7 @@ ${amount} voor ${merchant} - ${date}`, legalFirstName: 'Juridische voornaam', legalLastName: 'Wettelijke achternaam', address: 'Huisadres', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} gebruikt dit adres voor woon-werkuitsluitingen.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} gebruikt dit adres voor woon-werkuitzonderingen.`, error: { dateShouldBeBefore: (dateString: string) => `Datum moet vóór ${dateString} zijn`, dateShouldBeAfter: (dateString: string) => `Datum moet na ${dateString} liggen`, @@ -7063,16 +7063,16 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, summaryDisabled: 'Geen woon-werkuitzondering', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Sluit ${distance} ${unit} per declaratie uit`, optionDisabledTitle: 'Woon-werkverkeer niet uitsluiten', - optionDisabledHelp: 'Er is geen woon-werkverkeer uit declaraties verwijderd.', + optionDisabledHelp: 'Er wordt geen woon-werkverkeer uit declaraties verwijderd.', optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', distanceLabel: 'Afstand', - summaryHomeAndOffice: 'Gebruik thuis- en kantoorlokaties', - optionHomeAndOfficeTitle: 'Berekenen per thuis en kantoor', - optionHomeAndOfficeHelp: 'Gebruik het woonadres, werkarrangement en kantoorindeling van het lid om woon-werkuitzonderingen te berekenen.', + summaryHomeAndOffice: 'Thuis- en kantoorlocaties gebruiken', + optionHomeAndOfficeTitle: 'Bereken per thuis en kantoor', + optionHomeAndOfficeHelp: 'Gebruik het woonadres van het lid, de werkafspraak en de kantoorindeling om woon-werkuitsluitingen te berekenen.', workspaceAddressRequired: { title: 'Niet zo snel...', - promptStart: 'Je kunt de instelling ‘berekenen op basis van thuis en kantoor’ pas inschakelen nadat je eerst een kantoorlocatie hebt toegevoegd in', + promptStart: 'Je kunt de instelling ‘berekenen op basis van thuis en kantoor’ pas inschakelen als je eerst een kantoorlocatie hebt toegevoegd in', linkText: 'Overzicht', promptEnd: '.', cta: 'Begrepen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index b0099039a310..fdfdc45d98ea 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1232,8 +1232,8 @@ const translations: TranslationDeepObject = { title: 'Adres domowy jest wymagany', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Zanim zaczniesz śledzić przejechany dystans, dodaj swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy do pracy.` - : 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. To miejsce pracy używa tego adresu do odliczeń za dojazdy.', + ? `Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy do pracy.` + : 'Zanim zaczniesz śledzić dystans, dodaj swój adres domowy do prywatnego profilu. To miejsce pracy użyje tego adresu do odliczeń za dojazdy.', cta: 'Dodaj adres domowy', }, amount: 'Kwota', @@ -3514,7 +3514,7 @@ ${amount} dla ${merchant} - ${date}`, legalFirstName: 'Imię (zgodnie z dokumentem tożsamości)', legalLastName: 'Nazwisko zgodne z dokumentami', address: 'Adres domowy', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} używa tego adresu do wykluczeń dojazdów.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} używa tego adresu do wykluczeń dojazdów do pracy.`, error: { dateShouldBeBefore: (dateString: string) => `Data powinna być wcześniejsza niż ${dateString}`, dateShouldBeAfter: (dateString: string) => `Data powinna być po ${dateString}`, @@ -7041,13 +7041,13 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, summaryDisabled: 'Bez wykluczenia dojazdów', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Wyklucz ${distance} ${unit} na zgłoszenie`, optionDisabledTitle: 'Nie wykluczaj dojazdów', - optionDisabledHelp: 'Nie usunięto żadnego dojazdu z rozliczeń.', + optionDisabledHelp: 'Z żadnego rozliczenia nie usunięto dojazdu do pracy.', optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', distanceLabel: 'Dystans', - summaryHomeAndOffice: 'Używaj lokalizacji domu i biura', + summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', optionHomeAndOfficeTitle: 'Oblicz według domu i biura', - optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy i przypisania do biura, aby obliczyć wyłączenia z dojazdów.', + optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy oraz przypisanego biura, aby obliczyć wyłączenia z dojazdów.', workspaceAddressRequired: { title: 'Nie tak prędko…', promptStart: 'Nie możesz włączyć ustawienia „obliczaj według domu i biura”, dopóki najpierw nie dodasz lokalizacji biura w', @@ -7055,7 +7055,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, promptEnd: '.', cta: 'Rozumiem', }, - errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', invalidAddress: 'Wpisz poprawny adres', distanceTooLarge: 'Odległość jest zbyt duża.'}, + errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', invalidAddress: 'Wpisz prawidłowy adres', distanceTooLarge: 'Odległość jest zbyt duża.'}, }, distance: 'Dystans', centrallyManage: 'Centralnie zarządzaj stawkami, śledź w milach lub kilometrach i ustaw domyślną kategorię.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 093c9d036c20..5016a062b917 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1236,8 +1236,8 @@ const translations: TranslationDeepObject = { title: 'Endereço residencial é obrigatório', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Antes de controlar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` - : 'Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este espaço de trabalho usa esse endereço para deduções de deslocamento.', + ? `Antes de registrar distâncias, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` + : 'Antes de registrar distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.', cta: 'Adicionar endereço residencial', }, amount: 'Valor', @@ -3518,7 +3518,7 @@ ${amount} para ${merchant} - ${date}`, legalFirstName: 'Primeiro nome legal', legalLastName: 'Sobrenome legal', address: 'Endereço residencial', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa este endereço para as exclusões de deslocamento.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa este endereço para exclusões de deslocamento.`, error: { dateShouldBeBefore: (dateString: string) => `A data deve ser anterior a ${dateString}`, dateShouldBeAfter: (dateString: string) => `A data deve ser posterior a ${dateString}`, @@ -7063,13 +7063,13 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, summaryDisabled: 'Sem exclusão de deslocamento', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por solicitação`, optionDisabledTitle: 'Não excluir deslocamentos de casa para o trabalho', - optionDisabledHelp: 'Nenhum deslocamento é removido dos reembolsos.', + optionDisabledHelp: 'Nenhum deslocamento é removido das solicitações.', optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', distanceLabel: 'Distância', - summaryHomeAndOffice: 'Usar localizações de casa e escritório', + summaryHomeAndOffice: 'Usar endereços de casa e trabalho', optionHomeAndOfficeTitle: 'Calcular por casa e escritório', - optionHomeAndOfficeHelp: 'Use o endereço residencial do membro, o regime de trabalho e a alocação de escritório para calcular as exclusões de deslocamento.', + optionHomeAndOfficeHelp: 'Use o endereço residencial da pessoa associada, o regime de trabalho e a designação do escritório para calcular as exclusões de deslocamento.', workspaceAddressRequired: { title: 'Calma lá...', promptStart: 'Você não pode ativar a configuração de calcular por casa e escritório até primeiro adicionar um endereço de escritório em', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 60b87fdda1ed..9be93706672d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -929,7 +929,7 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, validateAccount: {title: '验证您的账户', subtitle: '账户', cta: '验证'}, - addHomeAddress: {title: '添加家庭住址以进行距离跟踪', subtitle: '账户', cta: '添加地址'}, + addHomeAddress: {title: '添加您的家庭住址以进行距离追踪', subtitle: '账户', cta: '添加地址'}, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, unlockBankAccount: { workspaceTitle: '您的企业银行账户已被锁定', @@ -1187,8 +1187,8 @@ const translations: TranslationDeepObject = { title: '家庭住址为必填项', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `在开始记录通勤距离前,您需要在私人资料中添加家庭住址。${workspaceName} 会使用该地址计算通勤扣减。` - : '在开始记录里程前,您需要在私人资料中添加家庭住址。此工作区会使用该地址计算通勤扣除。', + ? `在开始记录出行距离之前,您需要在个人资料中添加家庭住址。${workspaceName} 会使用此地址计算通勤减免。` + : '在开始记录行程前,您需要在个人资料中添加家庭住址。此工作区会使用该地址计算通勤抵扣。', cta: '添加家庭住址', }, amount: '金额', @@ -3415,7 +3415,7 @@ ${amount},商户:${merchant} - 日期:${date}`, legalFirstName: '法定名(名)', legalLastName: '法定姓氏', address: '家庭住址', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} 会使用该地址计算通勤排除。`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} 使用此地址来设置通勤免税项目。`, error: { dateShouldBeBefore: (dateString: string) => `日期应早于 ${dateString}`, dateShouldBeAfter: (dateString: string) => `日期应晚于 ${dateString}`, @@ -6828,19 +6828,19 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM summaryDisabled: '不排除通勤', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `每次报销排除 ${distance} ${unit}`, optionDisabledTitle: '不要排除通勤', - optionDisabledHelp: '未从报销中扣除任何通勤费用。', + optionDisabledHelp: '未从报销中排除任何通勤路程。', optionFixedDistanceTitle: '为每笔报销排除固定距离', optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', distanceLabel: '距离', - summaryHomeAndOffice: '使用家庭和办公室位置', - optionHomeAndOfficeTitle: '按家庭和办公室计算', - optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公室分配来计算通勤排除项。', + summaryHomeAndOffice: '使用家庭和办公室地址', + optionHomeAndOfficeTitle: '按家庭与办公室计算', + optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公地点分配来计算通勤排除项。', workspaceAddressRequired: { - title: '先别急……', - promptStart: '在您先在 中添加一个办公地点之前,无法启用“按家庭和办公室计算”设置', + title: '先别急…', + promptStart: '在先在 中添加办公地点之前,您无法启用“按家庭和办公室计算”设置', linkText: '概览', promptEnd: '中添加办公地点,才能启用“按家庭和办公室计算”设置。', - cta: '明白了', + cta: '知道了', }, errors: {distanceMustBePositive: '距离必须是一个正整数。', invalidAddress: '请输入有效地址', distanceTooLarge: '距离过大。'}, }, From 65709a06d435ce6fabb9effce1da04dc6d315b04 Mon Sep 17 00:00:00 2001 From: marufsharifi Date: Tue, 11 Aug 2026 16:46:19 +0430 Subject: [PATCH 163/466] Enhance report field dropdown to support pinned selection and improve search behavior --- src/libs/ReportFieldOptionsListUtils.ts | 27 ++++++++++++++++--------- src/pages/EditReportFieldDropdown.tsx | 17 +++++++++++++++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/libs/ReportFieldOptionsListUtils.ts b/src/libs/ReportFieldOptionsListUtils.ts index 07e65fea9c76..7cb8fbeb6494 100644 --- a/src/libs/ReportFieldOptionsListUtils.ts +++ b/src/libs/ReportFieldOptionsListUtils.ts @@ -27,42 +27,49 @@ function getReportFieldOptionsSection({ options, recentlyUsedOptions, selectedOptions, + initiallySelectedOptions, searchValue, translate, }: { options: string[]; recentlyUsedOptions: string[]; selectedOptions: Array>; + initiallySelectedOptions: Array>; searchValue: string; translate: LocalizedTranslate; }) { const reportFieldOptionsSections = []; - const selectedOptionKeys = selectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o); - const selectedKeySet = new Set(selectedOptionKeys); + // The live selection drives the checkmark (isSelected)... + const selectedKeySet = new Set(selectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o)); + // ...while the frozen initial selection drives the pinned "Selected" section, so selecting a value marks it without reordering the list until reopen. + const pinnedOptionKeys = initiallySelectedOptions.map(({text, keyForList, name}) => text ?? keyForList ?? name ?? '').filter((o) => !!o); + const pinnedKeySet = new Set(pinnedOptionKeys); if (searchValue) { const searchOptions = tokenizedSearch(options, searchValue, (option) => [option]); + // Keep the pinned option(s) at the top of the search results, matching the "Selected" section shown when not searching. + const orderedSearchOptions = [...searchOptions.filter((option) => pinnedKeySet.has(option)), ...searchOptions.filter((option) => !pinnedKeySet.has(option))]; reportFieldOptionsSections.push({ // "Search" section title: '', sectionIndex: 0, - data: getReportFieldOptions(searchOptions, selectedKeySet), + data: getReportFieldOptions(orderedSearchOptions, selectedKeySet), }); return reportFieldOptionsSections; } const enabledOptionSet = new Set(options); - const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((o) => !selectedKeySet.has(o) && enabledOptionSet.has(o)); - const filteredOptions = options.filter((o) => !selectedKeySet.has(o)); + const filteredRecentlyUsedOptions = recentlyUsedOptions.filter((o) => !pinnedKeySet.has(o) && enabledOptionSet.has(o)); + const filteredOptions = options.filter((o) => !pinnedKeySet.has(o)); - if (selectedOptionKeys.length) { + if (pinnedOptionKeys.length) { reportFieldOptionsSections.push({ - // "Selected" section + // "Selected" (pinned) section title: '', sectionIndex: 1, - data: getReportFieldOptions(selectedOptionKeys, selectedKeySet), + data: getReportFieldOptions(pinnedOptionKeys, selectedKeySet), }); } @@ -71,7 +78,7 @@ function getReportFieldOptionsSection({ // "Recent" section title: translate('common.recent'), sectionIndex: 2, - data: getReportFieldOptions(filteredRecentlyUsedOptions), + data: getReportFieldOptions(filteredRecentlyUsedOptions, selectedKeySet), }); } @@ -79,7 +86,7 @@ function getReportFieldOptionsSection({ // "All" section when items amount more than the threshold title: translate('common.all'), sectionIndex: 3, - data: getReportFieldOptions(filteredOptions), + data: getReportFieldOptions(filteredOptions, selectedKeySet), }); return reportFieldOptionsSections; diff --git a/src/pages/EditReportFieldDropdown.tsx b/src/pages/EditReportFieldDropdown.tsx index 4dcd99012fed..3742b6ff2f28 100644 --- a/src/pages/EditReportFieldDropdown.tsx +++ b/src/pages/EditReportFieldDropdown.tsx @@ -2,6 +2,7 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -34,8 +35,13 @@ function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: const validFieldOptions = fieldOptions?.filter((option) => !!option)?.sort(localeCompare); + // Freeze the value selected when the picker opened so it drives the pinned "Selected" section for the whole open/focus cycle. + // The live value still drives the checkmark, so tapping a row marks it without reordering the list; the reorder happens only on reopen. + const initialFieldValue = useInitialSelection(fieldValue, {resetOnFocus: true}); + const sections = getReportFieldOptionsSection({ searchValue: debouncedSearchValue, + // Live value drives the checkmark, so tapping a row marks it immediately. selectedOptions: [ { keyForList: fieldValue, @@ -43,13 +49,21 @@ function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: text: fieldValue, }, ], + // Frozen value drives the pinned section, so the list doesn't reorder while selecting. + initiallySelectedOptions: [ + { + keyForList: initialFieldValue, + searchText: initialFieldValue, + text: initialFieldValue, + }, + ], options: validFieldOptions, recentlyUsedOptions, translate, }); const policyReportFieldData = sections.at(0)?.data ?? []; - const selectedOptionKey = policyReportFieldData.filter((option) => option.searchText === fieldValue)?.at(0)?.keyForList; + const selectedOptionKey = policyReportFieldData.filter((option) => option.searchText === initialFieldValue)?.at(0)?.keyForList; const textInputOptions = { value: searchValue, @@ -66,6 +80,7 @@ function EditReportFieldDropdown({onSubmit, fieldKey, fieldValue, fieldOptions}: textInputOptions={textInputOptions} onSelectRow={(option) => onSubmit({[fieldKey]: !option?.text || fieldValue === option.text ? '' : option.text})} initiallyFocusedItemKey={selectedOptionKey} + shouldUpdateFocusedIndex /> ); } From 6e1d8668da5a7f4d94a72b2781ba32b696b3c0f5 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 12 Aug 2026 13:08:09 +0200 Subject: [PATCH 164/466] Use the right distance on merge --- tests/actions/MergeTransactionTest.ts | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index adbdada939f9..2b2b5360a651 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -2,6 +2,7 @@ import {getReportPreviewAction} from '@libs/actions/IOU/MoneyRequestBuilder'; import {areTransactionsEligibleForMerge, getTransactionsForMerging, mergeTransactionRequest, setMergeTransactionKey, setupMergeTransactionData} from '@libs/actions/MergeTransaction'; import {addComment, openReport} from '@libs/actions/Report'; import {WRITE_COMMANDS} from '@libs/API/types'; +import {getMergeFieldUpdatedValues} from '@libs/MergeTransactionUtils'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, getReportAction} from '@libs/ReportActionsUtils'; import {buildTransactionThread} from '@libs/ReportUtils'; @@ -1544,6 +1545,44 @@ describe('setMergeTransactionKey', () => { description: 'New Description', // Added }); }); + + it('should not keep the commuter exclusion of a previously selected merchant when another one is selected', async () => { + // Given a merge of two distance expenses, where the merchant of the one on a workspace that excludes + // 1 commuter mile was selected first + const transactionID = 'merge-distance-transaction'; + const selectMerchantOf = async (transaction: Transaction) => { + setMergeTransactionKey( + transactionID, + getMergeFieldUpdatedValues({ + transaction, + field: 'merchant', + fieldValue: transaction.merchant, + getCurrencyDecimals: getCurrencyDecimalsLocal, + mergeTransaction: await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`), + }), + ); + await waitForBatchedUpdates(); + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); + await selectMerchantOf({ + ...createRandomDistanceRequestTransaction(0), + comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 4.49, commuterExclusion: 1, reimbursableDistance: 3.49}}, + }); + + // When the merchant of the expense on the workspace with no commuter exclusion is selected instead + await selectMerchantOf({ + ...createRandomDistanceRequestTransaction(1), + comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 10.2}}, + }); + + // Then only that expense's distance is left, rather than the first one's reimbursable distance, which the + // distance field displays in place of the full distance whenever an exclusion is present + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); + expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBeUndefined(); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBeUndefined(); + }); }); describe('areTransactionsEligibleForMerge', () => { From 6981278b2317383bce2ed7b6eeca661b6a574cdc Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 12 Aug 2026 13:08:39 +0200 Subject: [PATCH 165/466] properly handle exclusions --- src/libs/MergeTransactionUtils.ts | 18 +++++++++++++++--- tests/actions/MergeTransactionTest.ts | 16 ++++++++-------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index a5cf273c0872..833ef1b8ce94 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -7,7 +7,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {MergeTransaction, Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {NullishDeep, OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion} from 'type-fest'; import {SafeString} from 'expensify-common'; @@ -64,7 +64,7 @@ type MergeFieldData = { }; /** Type for merge transaction values that can be null to clear existing values in Onyx */ -type MergeTransactionUpdateValues = Partial>; +type MergeTransactionUpdateValues = Partial | null>>; const MERGE_FIELD_TRANSLATION_KEYS = { amount: 'iou.amount', @@ -674,7 +674,19 @@ function getMergeFieldUpdatedValues({ const transactionDetails = getTransactionDetails(transaction); updatedValues.amount = getMergeFieldValue(transactionDetails, transaction, 'amount') as number; updatedValues.currency = getCurrency(transaction); - updatedValues.customUnit = transaction?.comment?.customUnit; + // Selections are stored with Onyx.merge, which deep merges the custom unit, so the commuter exclusion of the + // surviving expense's workspace stays applied. Its reimbursable distance describes the previously selected + // distance though, and the distance field displays that in place of the full distance, so recompute it here. + const selectedCustomUnit = transaction?.comment?.customUnit; + const commuterExclusion = selectedCustomUnit?.commuterExclusion ?? mergeTransaction?.customUnit?.commuterExclusion; + const selectedQuantity = selectedCustomUnit?.quantity; + const hasDistanceToExclude = !!commuterExclusion && typeof selectedQuantity === 'number'; + updatedValues.customUnit = { + ...selectedCustomUnit, + ...((hasDistanceToExclude || mergeTransaction?.customUnit?.reimbursableDistance !== undefined) && { + reimbursableDistance: hasDistanceToExclude ? Math.max(0, selectedQuantity - commuterExclusion) : null, + }), + }; updatedValues.iouRequestType = transaction?.iouRequestType; // For manual distance requests, set waypoints/routes and receipt to null to clear any existing values updatedValues.receipt = transaction?.receipt ?? null; diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index 2b2b5360a651..bc58927ebde3 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -1546,9 +1546,9 @@ describe('setMergeTransactionKey', () => { }); }); - it('should not keep the commuter exclusion of a previously selected merchant when another one is selected', async () => { - // Given a merge of two distance expenses, where the merchant of the one on a workspace that excludes - // 1 commuter mile was selected first + it('should apply the commuter exclusion to the newly selected distance rather than the previously selected one', async () => { + // Given a merge of two distance expenses onto a workspace that excludes 1 commuter mile, where the merchant of + // the 4.49 mile expense was selected first const transactionID = 'merge-distance-transaction'; const selectMerchantOf = async (transaction: Transaction) => { setMergeTransactionKey( @@ -1570,18 +1570,18 @@ describe('setMergeTransactionKey', () => { comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 4.49, commuterExclusion: 1, reimbursableDistance: 3.49}}, }); - // When the merchant of the expense on the workspace with no commuter exclusion is selected instead + // When the merchant of the 10.2 mile expense is selected instead await selectMerchantOf({ ...createRandomDistanceRequestTransaction(1), comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 10.2}}, }); - // Then only that expense's distance is left, rather than the first one's reimbursable distance, which the - // distance field displays in place of the full distance whenever an exclusion is present + // Then the workspace's exclusion still applies, but to the newly selected distance: the distance field renders + // the reimbursable distance in place of the full one, so keeping the previous 3.49 would show the wrong expense const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); - expect(mergeTransaction?.customUnit?.commuterExclusion).toBeUndefined(); - expect(mergeTransaction?.customUnit?.reimbursableDistance).toBeUndefined(); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); }); }); From 0e68465a8b3b67ec841316056062b02d339c09f0 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 12 Aug 2026 14:22:22 +0200 Subject: [PATCH 166/466] Do not deduct if not needed --- src/libs/MergeTransactionUtils.ts | 47 +++++++++--- .../DynamicDetailsReviewPage.tsx | 6 ++ tests/actions/MergeTransactionTest.ts | 73 +++++++++++++------ 3 files changed, 96 insertions(+), 30 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 833ef1b8ce94..31a96b17ef88 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -6,6 +6,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MergeTransaction, Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; import type {NullishDeep, OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion} from 'type-fest'; @@ -18,6 +19,7 @@ import type {TransactionDetails} from './ReportUtils'; import {getDecodedLeafCategoryName} from './CategoryUtils'; import {convertToBackendAmount} from './CurrencyUtils'; import Parser from './Parser'; +import {isCommuterExclusionEnabled} from './PolicyDistanceRatesUtils'; import {getCommaSeparatedTagNameWithSanitizedColons} from './PolicyUtils'; import {constructReceiptSourceFromFilename} from './ReceiptUtils'; import {getIOUActionForReportID} from './ReportActionsUtils'; @@ -47,6 +49,7 @@ import { // Define the specific merge fields we want to handle const MERGE_FIELDS = ['amount', 'merchant', 'created', 'category', 'tag', 'description', 'taxValue', 'reimbursable', 'billable', 'attendees', 'reportID'] as const; +const COMMUTER_EXCLUSION_CUSTOM_UNIT_KEYS = ['commuterExclusion', 'reimbursableDistance', 'commuterExclusionType', 'commuterExclusionMethod'] as const; // Some fields are dependant on others. We need to automatically derive the correct field values depending on user selection. const DERIVED_MERGE_FIELDS = [...MERGE_FIELDS, 'taxCode', 'taxAmount'] as const; type MergeFieldKey = TupleToUnion; @@ -638,8 +641,36 @@ type GetMergeFieldUpdatedValuesParams = { mergeTransaction?: OnyxEntry; searchReports?: Array>; policy?: OnyxEntry; + + /** Workspace of the report the merged expense will live on, which is the one whose rules apply to it */ + destinationPolicy?: OnyxEntry; }; +/** + * Build the custom unit's commuter exclusion for a merge selection. + * + * A commuter exclusion belongs to the workspace the surviving expense ends up on, and always describes the distance + * that is selected. Selections are stored with Onyx.merge, which deep merges the custom unit, so both the exclusion of + * a workspace that is no longer the destination and the reimbursable distance of a previously selected merchant would + * otherwise survive — and the distance field shows the reimbursable distance in place of the full distance. + */ +function getCommuterExclusionCustomUnitUpdate( + selectedCustomUnit: TransactionCustomUnit | undefined, + previousCustomUnit: TransactionCustomUnit | undefined, + destinationPolicy: OnyxEntry, +): Record { + const commuterExclusion = selectedCustomUnit?.commuterExclusion ?? previousCustomUnit?.commuterExclusion; + const quantity = selectedCustomUnit?.quantity ?? previousCustomUnit?.quantity; + + if (isCommuterExclusionEnabled(destinationPolicy) && !!commuterExclusion && typeof quantity === 'number') { + return {commuterExclusion, reimbursableDistance: Math.max(0, quantity - commuterExclusion)}; + } + + // Null the exclusion keys that hold a value so Onyx removes them, leaving the ones that were never set out of the update + const keysToClear = COMMUTER_EXCLUSION_CUSTOM_UNIT_KEYS.filter((key) => selectedCustomUnit?.[key] !== undefined || previousCustomUnit?.[key] !== undefined); + return Object.fromEntries(keysToClear.map((key) => [key, null])); +} + /** * Build updated values for merge transaction field selection * Handles special cases like currency for amount field, report name, tax value and additional fields for distance requests @@ -652,6 +683,7 @@ function getMergeFieldUpdatedValues({ mergeTransaction, searchReports, policy, + destinationPolicy, }: GetMergeFieldUpdatedValuesParams): MergeTransactionUpdateValues { const updatedValues: MergeTransactionUpdateValues = { [field]: fieldValue, @@ -668,24 +700,21 @@ function getMergeFieldUpdatedValues({ if (field === 'reportID') { const reportName = transaction?.reportName?.length ? transaction?.reportName : getReportName(getReportOrDraftReport(getReportIDForExpense(transaction), searchReports)); updatedValues.reportName = reportName.length ? reportName : null; + + // Moving the expense to another workspace changes whether that workspace excludes commuter distance from it + if (isDistanceRequest(transaction)) { + updatedValues.customUnit = getCommuterExclusionCustomUnitUpdate(undefined, mergeTransaction?.customUnit, destinationPolicy); + } } if (field === 'merchant' && isDistanceRequest(transaction)) { const transactionDetails = getTransactionDetails(transaction); updatedValues.amount = getMergeFieldValue(transactionDetails, transaction, 'amount') as number; updatedValues.currency = getCurrency(transaction); - // Selections are stored with Onyx.merge, which deep merges the custom unit, so the commuter exclusion of the - // surviving expense's workspace stays applied. Its reimbursable distance describes the previously selected - // distance though, and the distance field displays that in place of the full distance, so recompute it here. const selectedCustomUnit = transaction?.comment?.customUnit; - const commuterExclusion = selectedCustomUnit?.commuterExclusion ?? mergeTransaction?.customUnit?.commuterExclusion; - const selectedQuantity = selectedCustomUnit?.quantity; - const hasDistanceToExclude = !!commuterExclusion && typeof selectedQuantity === 'number'; updatedValues.customUnit = { ...selectedCustomUnit, - ...((hasDistanceToExclude || mergeTransaction?.customUnit?.reimbursableDistance !== undefined) && { - reimbursableDistance: hasDistanceToExclude ? Math.max(0, selectedQuantity - commuterExclusion) : null, - }), + ...getCommuterExclusionCustomUnitUpdate(selectedCustomUnit, mergeTransaction?.customUnit, destinationPolicy), }; updatedValues.iouRequestType = transaction?.iouRequestType; // For manual distance requests, set waypoints/routes and receipt to null to clear any existing values diff --git a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx index 4e3262563ead..fb6d86790fd7 100644 --- a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx @@ -109,6 +109,11 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { return newErrors; }); + // Selecting a report is what moves the merged expense to a workspace, so it decides which workspace's rules + // apply to it. Any other selection leaves it on the report already chosen. + const destinationReportID = field === 'reportID' ? transaction.reportID : mergeTransaction?.reportID; + const destinationPolicy = destinationReportID === sourceTransactionReport?.reportID ? sourceTransactionPolicy : targetTransactionPolicy; + // Update both the field value and track which transaction was selected (persisted in Onyx) const currentSelections = mergeTransaction?.selectedTransactionByField ?? {}; const updatedValues = getMergeFieldUpdatedValues({ @@ -119,6 +124,7 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { mergeTransaction, searchReports: [targetTransactionReport, sourceTransactionReport], policy: transaction.transactionID === targetTransaction?.transactionID ? targetTransactionPolicy : sourceTransactionPolicy, + destinationPolicy: destinationReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? undefined : destinationPolicy, }); setMergeTransactionKey(transactionID, { diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index bc58927ebde3..660e4b2ca9a5 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -12,6 +12,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type { MergeTransaction as MergeTransactionType, OriginalMessageIOU, + Policy, Report, ReportAction, ReportActions, @@ -27,6 +28,7 @@ import Onyx from 'react-native-onyx'; import type {MockFetch} from '../utils/TestHelper'; import createRandomMergeTransaction from '../utils/collections/mergeTransaction'; +import createRandomPolicy from '../utils/collections/policies'; import createRandomReportAction from '../utils/collections/reportActions'; import {createExpenseReport, createRandomReport} from '../utils/collections/reports'; import createRandomTransaction, {createRandomDistanceRequestTransaction} from '../utils/collections/transaction'; @@ -1546,42 +1548,71 @@ describe('setMergeTransactionKey', () => { }); }); - it('should apply the commuter exclusion to the newly selected distance rather than the previously selected one', async () => { - // Given a merge of two distance expenses onto a workspace that excludes 1 commuter mile, where the merchant of - // the 4.49 mile expense was selected first + describe('commuter exclusion on a distance merge', () => { const transactionID = 'merge-distance-transaction'; - const selectMerchantOf = async (transaction: Transaction) => { + const plainWorkspaceReportID = 'reportOnPlainWorkspace'; + const excludingWorkspace: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, fixedDistance: 1, fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }; + const plainWorkspace = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); + const fourMileExpense = { + ...createRandomDistanceRequestTransaction(0), + comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 4.49, commuterExclusion: 1, reimbursableDistance: 3.49}}, + }; + const tenMileExpense = { + ...createRandomDistanceRequestTransaction(1), + reportID: plainWorkspaceReportID, + comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 10.2}}, + }; + + const select = async (transaction: Transaction, field: 'merchant' | 'reportID', fieldValue: string, destinationPolicy: Policy) => { setMergeTransactionKey( transactionID, getMergeFieldUpdatedValues({ transaction, - field: 'merchant', - fieldValue: transaction.merchant, + field, + fieldValue, getCurrencyDecimals: getCurrencyDecimalsLocal, mergeTransaction: await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`), + destinationPolicy, }), ); await waitForBatchedUpdates(); }; - await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); - await selectMerchantOf({ - ...createRandomDistanceRequestTransaction(0), - comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 4.49, commuterExclusion: 1, reimbursableDistance: 3.49}}, + it('should apply the exclusion to the newly selected distance rather than the previously selected one', async () => { + // Given a merge onto a workspace that excludes 1 commuter mile, where the merchant of the 4.49 mile expense + // was selected first + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); + await select(fourMileExpense, 'merchant', fourMileExpense.merchant, excludingWorkspace); + + // When the merchant of the 10.2 mile expense is selected instead + await select(tenMileExpense, 'merchant', tenMileExpense.merchant, excludingWorkspace); + + // Then the exclusion still applies, but to the newly selected distance: the distance field renders the + // reimbursable distance in place of the full one, so keeping the previous 3.49 would show the wrong expense + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); + expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); }); - // When the merchant of the 10.2 mile expense is selected instead - await selectMerchantOf({ - ...createRandomDistanceRequestTransaction(1), - comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 10.2}}, - }); + it('should drop the exclusion when the report of a workspace that has none is selected', async () => { + // Given the same merge, with the exclusion applied to the selected 10.2 mile distance + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); + await select(fourMileExpense, 'merchant', fourMileExpense.merchant, excludingWorkspace); + await select(tenMileExpense, 'merchant', tenMileExpense.merchant, excludingWorkspace); + + // When the report on the workspace that excludes nothing is selected for the merged expense + await select(tenMileExpense, 'reportID', plainWorkspaceReportID, plainWorkspace); - // Then the workspace's exclusion still applies, but to the newly selected distance: the distance field renders - // the reimbursable distance in place of the full one, so keeping the previous 3.49 would show the wrong expense - const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); - expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); - expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); - expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); + // Then nothing is deducted from it anymore, because the exclusion belongs to the workspace the expense left + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); + expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBeUndefined(); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBeUndefined(); + }); }); }); From 079d8a05262724fb439c91341ee162dfd1ac7723 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 12 Aug 2026 15:16:40 +0200 Subject: [PATCH 167/466] revamp of tests --- src/libs/DistanceRequestUtils.ts | 30 ++- src/libs/MergeTransactionUtils.ts | 78 ++++++- .../DynamicDetailsReviewPage.tsx | 18 +- tests/actions/MergeTransactionTest.ts | 23 +- .../ui/MergeTransactionDetailsReviewTest.tsx | 203 ++++++++++++++++++ 5 files changed, 325 insertions(+), 27 deletions(-) create mode 100644 tests/ui/MergeTransactionDetailsReviewTest.tsx diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 7748d3bc0a3d..c933e379a850 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -337,6 +337,25 @@ function getCommuterExclusionDisplayData(customUnit: TransactionCustomUnit | und }; } +/** + * Returns the distance a workspace excludes from a distance of `distance` units, expressed in that same unit. + * + * Returns 0 when the workspace excludes nothing, so callers can treat it as "no exclusion applies". The exclusion never + * exceeds the distance itself, which is what keeps a reimbursable distance from going negative. + */ +function getPolicyCommuterExclusionForDistance(policy: OnyxEntry, distance: number, distanceUnit: Unit): number { + const commuterExclusions = policy?.commuterExclusions; + if (commuterExclusions?.method !== CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE) { + return 0; + } + + const fixedDistanceUnit: Unit = + commuterExclusions.fixedDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const fixedDistanceInRequestUnit = convertDistanceUnit(convertToDistanceInMeters(commuterExclusions.fixedDistance ?? 0, fixedDistanceUnit), distanceUnit); + + return Math.max(0, Math.min(fixedDistanceInRequestUnit, distance)); +} + function getTransactionCommuterExclusionData({ transaction, policy, @@ -385,15 +404,7 @@ function getTransactionCommuterExclusionData({ return; } - const fixedDistanceUnit: Unit = - policyCommuterExclusions.fixedDistanceUnit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS : CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - const fixedDistanceInRequestUnit = convertDistanceUnit(convertToDistanceInMeters(policyCommuterExclusions.fixedDistance ?? 0, fixedDistanceUnit), requestDistanceUnit); - - if (fixedDistanceInRequestUnit <= 0) { - return; - } - - const commuterExclusion = Math.min(fixedDistanceInRequestUnit, routeDistance); + const commuterExclusion = getPolicyCommuterExclusionForDistance(policy, routeDistance, requestDistanceUnit); if (commuterExclusion <= 0) { return; } @@ -849,6 +860,7 @@ export default { getDistanceMerchant, getDistanceRequestAmount, getCommuterExclusionDisplayData, + getPolicyCommuterExclusionForDistance, getTransactionCommuterExclusionData, getDistanceDisplayDetailsWithCommuter, getFormattedRateValue, diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 31a96b17ef88..dcfb34810c55 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -18,8 +18,8 @@ import type {TransactionDetails} from './ReportUtils'; import {getDecodedLeafCategoryName} from './CategoryUtils'; import {convertToBackendAmount} from './CurrencyUtils'; +import DistanceRequestUtils from './DistanceRequestUtils'; import Parser from './Parser'; -import {isCommuterExclusionEnabled} from './PolicyDistanceRatesUtils'; import {getCommaSeparatedTagNameWithSanitizedColons} from './PolicyUtils'; import {constructReceiptSourceFromFilename} from './ReceiptUtils'; import {getIOUActionForReportID} from './ReportActionsUtils'; @@ -50,6 +50,8 @@ import { // Define the specific merge fields we want to handle const MERGE_FIELDS = ['amount', 'merchant', 'created', 'category', 'tag', 'description', 'taxValue', 'reimbursable', 'billable', 'attendees', 'reportID'] as const; const COMMUTER_EXCLUSION_CUSTOM_UNIT_KEYS = ['commuterExclusion', 'reimbursableDistance', 'commuterExclusionType', 'commuterExclusionMethod'] as const; +/** The custom unit's commuter exclusion fields, where null clears the stored value through Onyx.merge */ +type CommuterExclusionCustomUnitUpdate = {[Key in TupleToUnion]?: TransactionCustomUnit[Key] | null}; // Some fields are dependant on others. We need to automatically derive the correct field values depending on user selection. const DERIVED_MERGE_FIELDS = [...MERGE_FIELDS, 'taxCode', 'taxAmount'] as const; type MergeFieldKey = TupleToUnion; @@ -658,17 +660,54 @@ function getCommuterExclusionCustomUnitUpdate( selectedCustomUnit: TransactionCustomUnit | undefined, previousCustomUnit: TransactionCustomUnit | undefined, destinationPolicy: OnyxEntry, -): Record { - const commuterExclusion = selectedCustomUnit?.commuterExclusion ?? previousCustomUnit?.commuterExclusion; +): CommuterExclusionCustomUnitUpdate { const quantity = selectedCustomUnit?.quantity ?? previousCustomUnit?.quantity; + const distanceUnit = selectedCustomUnit?.distanceUnit ?? previousCustomUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + // The destination workspace's setting decides the exclusion, so it applies to an expense that arrives from a + // workspace excluding nothing, and stops applying to one that leaves a workspace that excludes a distance + const commuterExclusion = typeof quantity === 'number' ? DistanceRequestUtils.getPolicyCommuterExclusionForDistance(destinationPolicy, quantity, distanceUnit) : 0; - if (isCommuterExclusionEnabled(destinationPolicy) && !!commuterExclusion && typeof quantity === 'number') { - return {commuterExclusion, reimbursableDistance: Math.max(0, quantity - commuterExclusion)}; + if (commuterExclusion > 0 && typeof quantity === 'number') { + return { + commuterExclusion, + reimbursableDistance: Math.max(0, quantity - commuterExclusion), + commuterExclusionMethod: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, + }; } // Null the exclusion keys that hold a value so Onyx removes them, leaving the ones that were never set out of the update - const keysToClear = COMMUTER_EXCLUSION_CUSTOM_UNIT_KEYS.filter((key) => selectedCustomUnit?.[key] !== undefined || previousCustomUnit?.[key] !== undefined); - return Object.fromEntries(keysToClear.map((key) => [key, null])); + const clearedUpdate: CommuterExclusionCustomUnitUpdate = {}; + for (const key of COMMUTER_EXCLUSION_CUSTOM_UNIT_KEYS) { + if (selectedCustomUnit?.[key] === undefined && previousCustomUnit?.[key] === undefined) { + continue; + } + clearedUpdate[key] = null; + } + + return clearedUpdate; +} + +/** + * Scales an amount that pays for `billedDistance` so that it pays for `newBilledDistance` instead, keeping a merged + * distance expense's amount on the distance its workspace reimburses once a commuter exclusion is applied or dropped. + */ +function getAmountForBilledDistance(amount: number, billedDistance: number | undefined, newBilledDistance: number | undefined): number { + if (!amount || !billedDistance || newBilledDistance === undefined || billedDistance === newBilledDistance) { + return amount; + } + + return Math.round((amount / billedDistance) * newBilledDistance); +} + +/** + * The distance an amount pays for: the reimbursable distance when a commuter exclusion applies, otherwise the whole distance. + */ +function getBilledDistance(quantity: number | null | undefined, reimbursableDistance: number | null | undefined): number | undefined { + if (typeof reimbursableDistance === 'number') { + return reimbursableDistance; + } + + return typeof quantity === 'number' ? quantity : undefined; } /** @@ -701,21 +740,38 @@ function getMergeFieldUpdatedValues({ const reportName = transaction?.reportName?.length ? transaction?.reportName : getReportName(getReportOrDraftReport(getReportIDForExpense(transaction), searchReports)); updatedValues.reportName = reportName.length ? reportName : null; - // Moving the expense to another workspace changes whether that workspace excludes commuter distance from it + // Moving the expense to another workspace changes whether that workspace excludes commuter distance from it, + // and the amount has to follow the distance that is left to reimburse if (isDistanceRequest(transaction)) { - updatedValues.customUnit = getCommuterExclusionCustomUnitUpdate(undefined, mergeTransaction?.customUnit, destinationPolicy); + const previousCustomUnit = mergeTransaction?.customUnit; + const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate(undefined, previousCustomUnit, destinationPolicy); + updatedValues.customUnit = commuterExclusionUpdate; + if (typeof mergeTransaction?.amount === 'number') { + updatedValues.amount = getAmountForBilledDistance( + mergeTransaction.amount, + getBilledDistance(previousCustomUnit?.quantity, previousCustomUnit?.reimbursableDistance), + getBilledDistance(previousCustomUnit?.quantity, commuterExclusionUpdate.reimbursableDistance), + ); + } } } if (field === 'merchant' && isDistanceRequest(transaction)) { const transactionDetails = getTransactionDetails(transaction); - updatedValues.amount = getMergeFieldValue(transactionDetails, transaction, 'amount') as number; updatedValues.currency = getCurrency(transaction); const selectedCustomUnit = transaction?.comment?.customUnit; + const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate(selectedCustomUnit, mergeTransaction?.customUnit, destinationPolicy); updatedValues.customUnit = { ...selectedCustomUnit, - ...getCommuterExclusionCustomUnitUpdate(selectedCustomUnit, mergeTransaction?.customUnit, destinationPolicy), + ...commuterExclusionUpdate, }; + // The selected expense's amount pays for the distance its own workspace reimbursed, so it is re-scaled to the + // distance the destination workspace reimburses + updatedValues.amount = getAmountForBilledDistance( + getMergeFieldValue(transactionDetails, transaction, 'amount') as number, + getBilledDistance(selectedCustomUnit?.quantity, selectedCustomUnit?.reimbursableDistance), + getBilledDistance(selectedCustomUnit?.quantity, commuterExclusionUpdate.reimbursableDistance), + ); updatedValues.iouRequestType = transaction?.iouRequestType; // For manual distance requests, set waypoints/routes and receipt to null to clear any existing values updatedValues.receipt = transaction?.receipt ?? null; diff --git a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx index fb6d86790fd7..d120d24726ec 100644 --- a/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DynamicDetailsReviewPage.tsx @@ -62,6 +62,11 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { const sourceReportOwnerAsAttendee = useReportOwnerAsAttendee(sourceTransaction); const targetReportOwnerAsAttendee = useReportOwnerAsAttendee(targetTransaction); + // The workspace of the report the merged expense is currently headed to, which is the one whose rules apply to it. + // An unreported expense lands in the self-DM, which has no workspace, so nothing resolves here. + const [chosenReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(mergeTransaction?.reportID)}`); + const [chosenReportPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(chosenReport?.policyID)}`); + const [hasErrors, setHasErrors] = useState>>({}); const conflictFields = useMemo(() => { @@ -109,10 +114,7 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { return newErrors; }); - // Selecting a report is what moves the merged expense to a workspace, so it decides which workspace's rules - // apply to it. Any other selection leaves it on the report already chosen. - const destinationReportID = field === 'reportID' ? transaction.reportID : mergeTransaction?.reportID; - const destinationPolicy = destinationReportID === sourceTransactionReport?.reportID ? sourceTransactionPolicy : targetTransactionPolicy; + const selectedTransactionPolicy = transaction.transactionID === targetTransaction?.transactionID ? targetTransactionPolicy : sourceTransactionPolicy; // Update both the field value and track which transaction was selected (persisted in Onyx) const currentSelections = mergeTransaction?.selectedTransactionByField ?? {}; @@ -123,8 +125,11 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { getCurrencyDecimals, mergeTransaction, searchReports: [targetTransactionReport, sourceTransactionReport], - policy: transaction.transactionID === targetTransaction?.transactionID ? targetTransactionPolicy : sourceTransactionPolicy, - destinationPolicy: destinationReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? undefined : destinationPolicy, + policy: selectedTransactionPolicy, + // Selecting a report is what moves the merged expense to a workspace, so it decides which workspace's + // rules apply to it: the workspace of the expense whose report was picked. Any other selection leaves + // the expense on the report already chosen, whose workspace is resolved from Onyx above. + destinationPolicy: field === 'reportID' ? selectedTransactionPolicy : chosenReportPolicy, }); setMergeTransactionKey(transactionID, { @@ -143,6 +148,7 @@ function DynamicDetailsReviewPage({route}: DynamicDetailsReviewPageProps) { targetTransaction?.transactionID, targetTransactionPolicy, sourceTransactionPolicy, + chosenReportPolicy, getCurrencyDecimals, ], ); diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index 660e4b2ca9a5..115e66cbeb63 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -1558,10 +1558,12 @@ describe('setMergeTransactionKey', () => { const plainWorkspace = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); const fourMileExpense = { ...createRandomDistanceRequestTransaction(0), + amount: -449, comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 4.49, commuterExclusion: 1, reimbursableDistance: 3.49}}, }; const tenMileExpense = { ...createRandomDistanceRequestTransaction(1), + amount: -1020, reportID: plainWorkspaceReportID, comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 10.2}}, }; @@ -1598,6 +1600,23 @@ describe('setMergeTransactionKey', () => { expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); }); + it('should apply the exclusion to an expense arriving from a workspace that excludes nothing', async () => { + // Given a merge where the merchant of the 10.2 mile expense, which carries no exclusion of its own, is selected + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); + await select(tenMileExpense, 'merchant', tenMileExpense.merchant, plainWorkspace); + + // When the report on the workspace that excludes 1 commuter mile is selected for the merged expense + await select(fourMileExpense, 'reportID', 'reportOnExcludingWorkspace', excludingWorkspace); + + // Then that workspace's exclusion is deducted from the selected distance, and the amount pays for what is + // left of it rather than for the whole trip + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); + expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); + expect(mergeTransaction?.amount).toBe(920); + }); + it('should drop the exclusion when the report of a workspace that has none is selected', async () => { // Given the same merge, with the exclusion applied to the selected 10.2 mile distance await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); @@ -1607,11 +1626,13 @@ describe('setMergeTransactionKey', () => { // When the report on the workspace that excludes nothing is selected for the merged expense await select(tenMileExpense, 'reportID', plainWorkspaceReportID, plainWorkspace); - // Then nothing is deducted from it anymore, because the exclusion belongs to the workspace the expense left + // Then nothing is deducted from it anymore, because the exclusion belongs to the workspace the expense left, + // and the amount pays for the whole trip again const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); expect(mergeTransaction?.customUnit?.commuterExclusion).toBeUndefined(); expect(mergeTransaction?.customUnit?.reimbursableDistance).toBeUndefined(); + expect(mergeTransaction?.amount).toBe(1020); }); }); }); diff --git a/tests/ui/MergeTransactionDetailsReviewTest.tsx b/tests/ui/MergeTransactionDetailsReviewTest.tsx new file mode 100644 index 000000000000..ff45717d8131 --- /dev/null +++ b/tests/ui/MergeTransactionDetailsReviewTest.tsx @@ -0,0 +1,203 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import navigationRef from '@libs/Navigation/navigationRef'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; + +import DynamicConfirmationPage from '@pages/TransactionMerge/DynamicConfirmationPage'; +import DynamicDetailsReviewPage from '@pages/TransactionMerge/DynamicDetailsReviewPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; + +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import {createRandomReport} from '../utils/collections/reports'; +import {createRandomDistanceRequestTransaction} from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => '')); + +const Stack = createPlatformStackNavigator>(); + +// Expose each field row's description and title so the rendered distance can be read back +jest.mock('@components/MenuItemWithTopDescription', () => { + const RN = jest.requireActual>>('react-native'); + return ({description, title}: {description?: string; title?: string}) => ( + + {title} + + ); +}); + +TestHelper.setupGlobalFetchMock(); + +const MERGE_TRANSACTION_ID = 'mergeDistanceTransaction'; +const EXCLUDING_POLICY_ID = 'policyThatExcludesCommuterDistance'; +const PLAIN_POLICY_ID = 'policyThatExcludesNothing'; +const EXCLUDING_REPORT_ID = '4444'; +const PLAIN_REPORT_ID = '5555'; + +const excludingPolicy: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + id: EXCLUDING_POLICY_ID, + name: 'Workspace that excludes 1 mile', + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, fixedDistance: 1, fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, +}; +const plainPolicy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), id: PLAIN_POLICY_ID, name: 'Workspace that excludes nothing'}; + +const buildReport = (reportID: string, policyID: string, reportName: string): Report => ({ + ...createRandomReport(Number(reportID)), + reportID, + policyID, + reportName, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, +}); + +const buildDistanceExpense = (transactionID: string, reportID: string, merchant: string, quantity: number, commuterExclusion?: number): Transaction => ({ + ...createRandomDistanceRequestTransaction(quantity), + transactionID, + reportID, + merchant, + modifiedMerchant: merchant, + amount: -1020, + currency: CONST.CURRENCY.USD, + created: '2026-08-01', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, + comment: { + type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + ...(commuterExclusion !== undefined && {commuterExclusion, reimbursableDistance: quantity - commuterExclusion}), + }, + waypoints: {waypoint0: {address: `${merchant} start`}, waypoint1: {address: `${merchant} end`}}, + }, +}); + +// The expense being merged into lives on the workspace that excludes nothing, and the other one on the workspace that +// excludes a commuter mile, which is the combination that decides whether the merged expense gets a deduction +const targetExpense = buildDistanceExpense('targetTransaction', PLAIN_REPORT_ID, '10.20 mi @ $0.67 / mi', 10.2); +const sourceExpense = buildDistanceExpense('sourceTransaction', EXCLUDING_REPORT_ID, '4.49 mi @ $0.67 / mi', 4.49, 1); + +describe('Merging distance expenses across workspaces', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.clear(); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${EXCLUDING_POLICY_ID}`, excludingPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${PLAIN_POLICY_ID}`, plainPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${EXCLUDING_REPORT_ID}`, buildReport(EXCLUDING_REPORT_ID, EXCLUDING_POLICY_ID, 'Report that deducts')); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${PLAIN_REPORT_ID}`, buildReport(PLAIN_REPORT_ID, PLAIN_POLICY_ID, 'Report that does not deduct')); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${targetExpense.transactionID}`, targetExpense); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${sourceExpense.transactionID}`, sourceExpense); + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${MERGE_TRANSACTION_ID}`, { + targetTransactionID: targetExpense.transactionID, + sourceTransactionID: sourceExpense.transactionID, + eligibleTransactions: [targetExpense, sourceExpense], + }); + }); + await waitForBatchedUpdatesWithAct(); + }); + + const renderPage = async () => { + render( + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + }; + + const press = async (label: string) => { + const option = screen.getAllByLabelText(label).at(0); + if (!option) { + throw new Error(`No option to select for ${label}`); + } + fireEvent.press(option); + await waitForBatchedUpdatesWithAct(); + }; + + it("applies the destination workspace's commuter exclusion to the distance that was selected", async () => { + await renderPage(); + + // When the distance of the expense from the workspace that excludes nothing is selected, and the merged expense + // is put on the report of the workspace that excludes a commuter mile + await press(targetExpense.merchant); + await press('Report that deducts'); + + // Then that workspace's exclusion is deducted from the selected 10.2 mile distance + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${MERGE_TRANSACTION_ID}`); + expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); + // And the amount pays for the 9.2 miles that are left rather than the whole trip + expect(mergeTransaction?.amount).toBe(920); + }); + + it('deducts nothing when the merged expense is put on the report of a workspace that excludes nothing', async () => { + await renderPage(); + + // When the distance of the expense from the workspace that excludes a commuter mile is selected, but the merged + // expense is put on the report of the workspace that excludes nothing + await press(sourceExpense.merchant); + await press('Report that does not deduct'); + + // Then nothing is deducted from it + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${MERGE_TRANSACTION_ID}`); + expect(mergeTransaction?.customUnit?.quantity).toBe(4.49); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBeUndefined(); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBeUndefined(); + }); + + it('shows the deducted distance on the confirmation page', async () => { + // Given the distance of the expense from the workspace that excludes nothing selected, on the report of the + // workspace that excludes a commuter mile + await renderPage(); + await press(targetExpense.merchant); + await press('Report that deducts'); + + // When the confirmation page is opened for that merge + render( + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the distance field shows the reimbursable distance, alongside the distance it was deducted from + expect(screen.getByTestId('field-Distance • Original: 10.20 mi')).toHaveTextContent('9.20 mi'); + }); +}); From 16da227b3652f9c9fb61a9f1a3a8c9ede912ac7c Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 12 Aug 2026 15:23:55 +0200 Subject: [PATCH 168/466] test simplification --- tests/actions/MergeTransactionTest.ts | 90 +++++-------------- .../ui/MergeTransactionDetailsReviewTest.tsx | 50 +++++------ 2 files changed, 44 insertions(+), 96 deletions(-) diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index 115e66cbeb63..22e99fcff21f 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -1548,92 +1548,50 @@ describe('setMergeTransactionKey', () => { }); }); - describe('commuter exclusion on a distance merge', () => { + it('should apply the commuter exclusion to the newly selected distance rather than the previously selected one', async () => { + // Given a merge onto a workspace that excludes 1 commuter mile, where the merchant of the 4.49 mile expense was + // selected first const transactionID = 'merge-distance-transaction'; - const plainWorkspaceReportID = 'reportOnPlainWorkspace'; const excludingWorkspace: Policy = { ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, fixedDistance: 1, fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, }; - const plainWorkspace = createRandomPolicy(1, CONST.POLICY.TYPE.TEAM); - const fourMileExpense = { - ...createRandomDistanceRequestTransaction(0), - amount: -449, - comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 4.49, commuterExclusion: 1, reimbursableDistance: 3.49}}, - }; - const tenMileExpense = { - ...createRandomDistanceRequestTransaction(1), - amount: -1020, - reportID: plainWorkspaceReportID, - comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 10.2}}, - }; - - const select = async (transaction: Transaction, field: 'merchant' | 'reportID', fieldValue: string, destinationPolicy: Policy) => { + const selectMerchantOf = async (transaction: Transaction) => { setMergeTransactionKey( transactionID, getMergeFieldUpdatedValues({ transaction, - field, - fieldValue, + field: 'merchant', + fieldValue: transaction.merchant, getCurrencyDecimals: getCurrencyDecimalsLocal, mergeTransaction: await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`), - destinationPolicy, + destinationPolicy: excludingWorkspace, }), ); await waitForBatchedUpdates(); }; - it('should apply the exclusion to the newly selected distance rather than the previously selected one', async () => { - // Given a merge onto a workspace that excludes 1 commuter mile, where the merchant of the 4.49 mile expense - // was selected first - await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); - await select(fourMileExpense, 'merchant', fourMileExpense.merchant, excludingWorkspace); - - // When the merchant of the 10.2 mile expense is selected instead - await select(tenMileExpense, 'merchant', tenMileExpense.merchant, excludingWorkspace); - - // Then the exclusion still applies, but to the newly selected distance: the distance field renders the - // reimbursable distance in place of the full one, so keeping the previous 3.49 would show the wrong expense - const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); - expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); - expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); - expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); + await selectMerchantOf({ + ...createRandomDistanceRequestTransaction(0), + amount: -349, + comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 4.49, commuterExclusion: 1, reimbursableDistance: 3.49}}, }); - it('should apply the exclusion to an expense arriving from a workspace that excludes nothing', async () => { - // Given a merge where the merchant of the 10.2 mile expense, which carries no exclusion of its own, is selected - await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); - await select(tenMileExpense, 'merchant', tenMileExpense.merchant, plainWorkspace); - - // When the report on the workspace that excludes 1 commuter mile is selected for the merged expense - await select(fourMileExpense, 'reportID', 'reportOnExcludingWorkspace', excludingWorkspace); - - // Then that workspace's exclusion is deducted from the selected distance, and the amount pays for what is - // left of it rather than for the whole trip - const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); - expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); - expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); - expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); - expect(mergeTransaction?.amount).toBe(920); + // When the merchant of the 10.2 mile expense is selected instead + await selectMerchantOf({ + ...createRandomDistanceRequestTransaction(1), + amount: -1020, + comment: {customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE, quantity: 10.2}}, }); - it('should drop the exclusion when the report of a workspace that has none is selected', async () => { - // Given the same merge, with the exclusion applied to the selected 10.2 mile distance - await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, {targetTransactionID: transactionID}); - await select(fourMileExpense, 'merchant', fourMileExpense.merchant, excludingWorkspace); - await select(tenMileExpense, 'merchant', tenMileExpense.merchant, excludingWorkspace); - - // When the report on the workspace that excludes nothing is selected for the merged expense - await select(tenMileExpense, 'reportID', plainWorkspaceReportID, plainWorkspace); - - // Then nothing is deducted from it anymore, because the exclusion belongs to the workspace the expense left, - // and the amount pays for the whole trip again - const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); - expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); - expect(mergeTransaction?.customUnit?.commuterExclusion).toBeUndefined(); - expect(mergeTransaction?.customUnit?.reimbursableDistance).toBeUndefined(); - expect(mergeTransaction?.amount).toBe(1020); - }); + // Then the exclusion still applies, but to the newly selected distance: the distance field renders the + // reimbursable distance in place of the full one, so keeping the previous 3.49 would show the wrong expense + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`); + expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); + expect(mergeTransaction?.amount).toBe(920); }); }); diff --git a/tests/ui/MergeTransactionDetailsReviewTest.tsx b/tests/ui/MergeTransactionDetailsReviewTest.tsx index ff45717d8131..edf26feb5173 100644 --- a/tests/ui/MergeTransactionDetailsReviewTest.tsx +++ b/tests/ui/MergeTransactionDetailsReviewTest.tsx @@ -72,7 +72,8 @@ const buildDistanceExpense = (transactionID: string, reportID: string, merchant: reportID, merchant, modifiedMerchant: merchant, - amount: -1020, + // A dollar per billed mile, so the amount and the distance stay readable against each other + amount: -Math.round((commuterExclusion === undefined ? quantity : quantity - commuterExclusion) * 100), currency: CONST.CURRENCY.USD, created: '2026-08-01', iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, @@ -150,38 +151,14 @@ describe('Merging distance expenses across workspaces', () => { await press(targetExpense.merchant); await press('Report that deducts'); - // Then that workspace's exclusion is deducted from the selected 10.2 mile distance + // Then that workspace's exclusion is deducted from the selected 10.2 mile distance, and the amount pays for the + // 9.2 miles that are left rather than for the whole trip const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${MERGE_TRANSACTION_ID}`); - expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); - // And the amount pays for the 9.2 miles that are left rather than the whole trip expect(mergeTransaction?.amount).toBe(920); - }); - - it('deducts nothing when the merged expense is put on the report of a workspace that excludes nothing', async () => { - await renderPage(); - - // When the distance of the expense from the workspace that excludes a commuter mile is selected, but the merged - // expense is put on the report of the workspace that excludes nothing - await press(sourceExpense.merchant); - await press('Report that does not deduct'); - - // Then nothing is deducted from it - const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${MERGE_TRANSACTION_ID}`); - expect(mergeTransaction?.customUnit?.quantity).toBe(4.49); - expect(mergeTransaction?.customUnit?.commuterExclusion).toBeUndefined(); - expect(mergeTransaction?.customUnit?.reimbursableDistance).toBeUndefined(); - }); - - it('shows the deducted distance on the confirmation page', async () => { - // Given the distance of the expense from the workspace that excludes nothing selected, on the report of the - // workspace that excludes a commuter mile - await renderPage(); - await press(targetExpense.merchant); - await press('Report that deducts'); - // When the confirmation page is opened for that merge + // And the confirmation page shows the reimbursable distance, alongside the distance it was deducted from render( @@ -196,8 +173,21 @@ describe('Merging distance expenses across workspaces', () => { , ); await waitForBatchedUpdatesWithAct(); - - // Then the distance field shows the reimbursable distance, alongside the distance it was deducted from expect(screen.getByTestId('field-Distance • Original: 10.20 mi')).toHaveTextContent('9.20 mi'); }); + + it('deducts nothing when the merged expense is put on the report of a workspace that excludes nothing', async () => { + await renderPage(); + + // When the distance of the expense from the workspace that excludes a commuter mile is selected, but the merged + // expense is put on the report of the workspace that excludes nothing + await press(sourceExpense.merchant); + await press('Report that does not deduct'); + + // Then nothing is deducted from it, and the amount pays for the whole trip + const mergeTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${MERGE_TRANSACTION_ID}`); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBeUndefined(); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBeUndefined(); + expect(mergeTransaction?.amount).toBe(449); + }); }); From c0115722a46e081554b8fe04527d5f203cf94849 Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 12 Aug 2026 17:15:32 +0200 Subject: [PATCH 169/466] better comment --- src/libs/MergeTransactionUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index dcfb34810c55..0eba00eaecac 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -654,7 +654,7 @@ type GetMergeFieldUpdatedValuesParams = { * A commuter exclusion belongs to the workspace the surviving expense ends up on, and always describes the distance * that is selected. Selections are stored with Onyx.merge, which deep merges the custom unit, so both the exclusion of * a workspace that is no longer the destination and the reimbursable distance of a previously selected merchant would - * otherwise survive — and the distance field shows the reimbursable distance in place of the full distance. + * otherwise survive. The distance field then shows the reimbursable distance in place of the full distance. */ function getCommuterExclusionCustomUnitUpdate( selectedCustomUnit: TransactionCustomUnit | undefined, From fe2504ee0f533c6cccd84569564d7fdb87613d1a Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:00:50 +0700 Subject: [PATCH 170/466] Extract body text component --- src/components/FeatureTraining/index.tsx | 2 + .../FeatureTraining/primitives/Body.tsx | 30 +++----------- .../FeatureTraining/primitives/BodyText.tsx | 40 +++++++++++++++++++ 3 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 src/components/FeatureTraining/primitives/BodyText.tsx diff --git a/src/components/FeatureTraining/index.tsx b/src/components/FeatureTraining/index.tsx index 5b6865dc9e93..6e3225cd6fb4 100644 --- a/src/components/FeatureTraining/index.tsx +++ b/src/components/FeatureTraining/index.tsx @@ -2,6 +2,7 @@ import FeatureTrainingBase from './FeatureTraining'; import FeatureTrainingCarousel from './FeatureTrainingCarousel'; import BackButton from './primitives/BackButton'; import Body from './primitives/Body'; +import BodyText from './primitives/BodyText'; import ButtonRow from './primitives/ButtonRow'; import ConfirmButton from './primitives/ConfirmButton'; import Description from './primitives/Description'; @@ -17,6 +18,7 @@ const FeatureTraining = Object.assign(FeatureTrainingBase, { Page, Illustration, Body, + BodyText, Title, Subtitle, Description, diff --git a/src/components/FeatureTraining/primitives/Body.tsx b/src/components/FeatureTraining/primitives/Body.tsx index f13c592080f9..bf24d651ac0c 100644 --- a/src/components/FeatureTraining/primitives/Body.tsx +++ b/src/components/FeatureTraining/primitives/Body.tsx @@ -1,9 +1,7 @@ -import {useFeatureTrainingState} from '@components/FeatureTraining/context'; - import useThemeStyles from '@hooks/useThemeStyles'; import type {ReactNode} from 'react'; -import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; import {View} from 'react-native'; @@ -11,30 +9,14 @@ import {View} from 'react-native'; type BodyProps = { children?: ReactNode; - /** Style applied to the outer body container */ - outerStyle?: StyleProp; - - /** Style applied to the inner text container */ - innerStyle?: StyleProp; - - /** onLayout used by the carousel probe layer to measure page heights */ - onLayout?: (event: LayoutChangeEvent) => void; + /** Style applied to the body container */ + style?: StyleProp; }; -function Body({children, outerStyle, innerStyle, onLayout}: BodyProps) { +function Body({children, style}: BodyProps) { const styles = useThemeStyles(); - const {contentMinHeight} = useFeatureTrainingState(); - - return ( - - - {children} - - - ); + + return {children}; } Body.displayName = 'FeatureTraining.Body'; diff --git a/src/components/FeatureTraining/primitives/BodyText.tsx b/src/components/FeatureTraining/primitives/BodyText.tsx new file mode 100644 index 000000000000..4341db98fb0f --- /dev/null +++ b/src/components/FeatureTraining/primitives/BodyText.tsx @@ -0,0 +1,40 @@ +import {useFeatureTrainingState} from '@components/FeatureTraining/context'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +type BodyTextProps = { + children?: ReactNode; + + /** Style applied to the text container */ + style?: StyleProp; + + /** onLayout used by the carousel probe layer to measure page heights */ + onLayout?: (event: LayoutChangeEvent) => void; +}; + +function BodyText({children, style, onLayout}: BodyTextProps) { + const styles = useThemeStyles(); + const {contentMinHeight} = useFeatureTrainingState(); + const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout(); + + return ( + + {children} + + ); +} + +BodyText.displayName = 'FeatureTraining.BodyText'; + +export default BodyText; +export type {BodyTextProps}; From aa82bba910362c4ffd87c483027a60b67ebaef41 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:01:09 +0700 Subject: [PATCH 171/466] Fix fmt From 9b131de78fb36387209fa41fc113931a4a2a6ad6 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:01:54 +0700 Subject: [PATCH 172/466] Probe measure body text instead --- .../FeatureTrainingCarousel.tsx | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx index 556c346825df..8ffa81a2123b 100644 --- a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx +++ b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx @@ -1,3 +1,5 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + import variables from '@styles/variables'; import type {ReactElement, ReactNode} from 'react'; @@ -13,6 +15,7 @@ import type {FeatureTrainingCarouselProps} from './types'; import {FeatureTrainingActionsContext, FeatureTrainingStateContext} from './context'; import useScrollableWrapper from './hooks/useScrollableWrapper'; import Body from './primitives/Body'; +import BodyText from './primitives/BodyText'; import CloseButton from './primitives/CloseButton'; import Illustration from './primitives/Illustration'; import Page from './primitives/Page'; @@ -23,12 +26,16 @@ const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThresh const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start', scrollSnapStop: 'always'} as ViewStyle) : {}; type BodyElement = ReactElement>; +type BodyTextElement = ReactElement>; type IllustrationElement = ReactElement; type PageElement = ReactElement>; function isBodyElement(child: ReactElement): child is BodyElement { return child.type === Body; } +function isBodyTextElement(child: ReactElement): child is BodyTextElement { + return child.type === BodyText; +} function isIllustrationElement(child: ReactElement): child is IllustrationElement { return child.type === Illustration; } @@ -39,11 +46,13 @@ function isPageElement(child: ReactElement): child is PageElement { type SplitPage = { illustration: IllustrationElement | null; body: BodyElement | null; + bodyText: BodyTextElement | null; }; function splitPageChildren(pageChildren: ReactNode): SplitPage { let illustration: IllustrationElement | null = null; let body: BodyElement | null = null; + let bodyText: BodyTextElement | null = null; React.Children.forEach(pageChildren, (child) => { if (!isValidElement(child)) { return; @@ -54,9 +63,15 @@ function splitPageChildren(pageChildren: ReactNode): SplitPage { } if (isBodyElement(child)) { body = child; + React.Children.forEach(child.props.children, (bodyChild) => { + if (!isValidElement(bodyChild) || !isBodyTextElement(bodyChild)) { + return; + } + bodyText = bodyChild; + }); } }); - return {illustration, body}; + return {illustration, body, bodyText}; } function FeatureTrainingCarousel({ @@ -202,13 +217,13 @@ function FeatureTrainingCarousel({ style={probeStyle} > {pages.map((probePage, index) => - probePage.body == null ? null : ( + probePage.bodyText == null ? null : ( ), @@ -255,13 +270,18 @@ FeatureTrainingCarousel.displayName = 'FeatureTraining.Carousel'; type ProbePageProps = { index: number; - body: BodyElement; + bodyText: BodyTextElement; onMeasure: (index: number, height: number) => void; }; -function ProbePage({index, body, onMeasure}: ProbePageProps) { +// The probe measures only the BodyText block (title/subtitle/description) — buttons and checkboxes are +// excluded so the locked minHeight matches the area it is later applied to. The mh5 wrapper mirrors +// Body's horizontal margins so the probe text wraps at the same width as the visible page. +function ProbePage({index, bodyText, onMeasure}: ProbePageProps) { + const styles = useThemeStyles(); const onLayout = useCallback((event: LayoutChangeEvent) => onMeasure(index, event.nativeEvent.layout.height), [index, onMeasure]); - return cloneElement(body, {onLayout}); + + return {cloneElement(bodyText, {onLayout})}; } export default FeatureTrainingCarousel; From bcd54c3ebafe72c25f314bbbed769f183cbe9b1d Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:02:16 +0700 Subject: [PATCH 173/466] Update AIFeaturesPromoModal usage --- src/components/AIFeaturesPromoModal.tsx | 30 +++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx index 8d8cb7c8c93b..5fb2ac32de36 100644 --- a/src/components/AIFeaturesPromoModal.tsx +++ b/src/components/AIFeaturesPromoModal.tsx @@ -71,10 +71,12 @@ function AIFeaturesPromoModal() { outerContainerStyle={styles.p0} aspectRatio={illustrationAspectRatio} /> - - {subtitle} - {translate('aiFeaturesPromoModal.spendAnalysis.title')} - {translate('aiFeaturesPromoModal.spendAnalysis.description')} + + + {subtitle} + {translate('aiFeaturesPromoModal.spendAnalysis.title')} + {translate('aiFeaturesPromoModal.spendAnalysis.description')} + {translate('common.next')} @@ -87,10 +89,12 @@ function AIFeaturesPromoModal() { outerContainerStyle={styles.p0} aspectRatio={illustrationAspectRatio} /> - - {subtitle} - {translate('aiFeaturesPromoModal.expenseAssistant.title')} - {translate('aiFeaturesPromoModal.expenseAssistant.description')} + + + {subtitle} + {translate('aiFeaturesPromoModal.expenseAssistant.title')} + {translate('aiFeaturesPromoModal.expenseAssistant.description')} + {canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText')} @@ -104,10 +108,12 @@ function AIFeaturesPromoModal() { outerContainerStyle={styles.p0} aspectRatio={illustrationAspectRatio} /> - - {subtitle} - {customAgentPromoTitle} - {translate('aiFeaturesPromoModal.customAgents.description')} + + + {subtitle} + {customAgentPromoTitle} + {translate('aiFeaturesPromoModal.customAgents.description')} + {translate('aiFeaturesPromoModal.confirmText')} From 0ec357273cc4a7c7e1347d24d904179c7368d98f Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:03:24 +0700 Subject: [PATCH 174/466] Update AutoSubmitModal usage --- src/components/AutoSubmitModal.tsx | 40 ++++++++++++++++-------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx index c18d37a79351..255033cfd529 100644 --- a/src/components/AutoSubmitModal.tsx +++ b/src/components/AutoSubmitModal.tsx @@ -94,26 +94,28 @@ function AutoSubmitModal() { innerContainerStyle={[styles.alignItemsCenter, styles.justifyContentCenter, StyleUtils.getBackgroundColorStyle(colors.green700), styles.p8]} outerContainerStyle={styles.p0} /> - - {translate('autoSubmitModal.title')} - {translate('autoSubmitModal.description')} - {menuSections.map((section) => ( - - - - {translate(section.titleTranslationKey as TranslationPaths)} - {translate(section.descriptionTranslationKey as TranslationPaths)} + + + {translate('autoSubmitModal.title')} + {translate('autoSubmitModal.description')} + {menuSections.map((section) => ( + + + + {translate(section.titleTranslationKey as TranslationPaths)} + {translate(section.descriptionTranslationKey as TranslationPaths)} + - - ))} + ))} + {shouldShowDismissOption && } {translate('common.buttonConfirm')} From 6c40276ed8754e9e038cd6b1cd6b4d8d487eb731 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:06:51 +0700 Subject: [PATCH 175/466] Update FeatureTrainingModal usages --- .../HoldOrRejectEducationalModal.tsx | 38 ++++++++++--------- .../HoldSubmitterEducationalModal.tsx | 10 +++-- .../components/MultiScanEducationalModal.tsx | 8 ++-- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/components/HoldOrRejectEducationalModal.tsx b/src/components/HoldOrRejectEducationalModal.tsx index deb7c331c76a..8a5f05a430c2 100644 --- a/src/components/HoldOrRejectEducationalModal.tsx +++ b/src/components/HoldOrRejectEducationalModal.tsx @@ -74,25 +74,27 @@ function HoldOrRejectEducationalModal({onClose, onConfirm}: HoldOrRejectEducatio aspectRatio={CONST.ILLUSTRATION_ASPECT_RATIO} outerContainerStyle={styles.p0} /> - - {translate('iou.reject.educationalTitle')} - {translate('iou.reject.educationalText')} - {menuSections.map((section) => ( - - - - {translate(section.titleTranslationKey)} + + + {translate('iou.reject.educationalTitle')} + {translate('iou.reject.educationalText')} + {menuSections.map((section) => ( + + + + {translate(section.titleTranslationKey)} + - - ))} + ))} + {translate('common.buttonConfirm')} diff --git a/src/components/HoldSubmitterEducationalModal.tsx b/src/components/HoldSubmitterEducationalModal.tsx index e5d05ed8fc86..3206f37d4403 100644 --- a/src/components/HoldSubmitterEducationalModal.tsx +++ b/src/components/HoldSubmitterEducationalModal.tsx @@ -46,10 +46,12 @@ function HoldSubmitterEducationalModal({onClose, onConfirm, isDM}: HoldSubmitter aspectRatio={CONST.ILLUSTRATION_ASPECT_RATIO} outerContainerStyle={styles.p0} /> - - {translate(isDM ? 'iou.whatIsHoldExplainDM' : 'iou.whatIsHoldExplain')} - {translate('iou.whatIsHoldExplain')} - + + + {translate('iou.holdEducationalTitle')} + {translate(isDM ? 'iou.whatIsHoldExplainDM' : 'iou.whatIsHoldExplain')} + + {translate('common.buttonConfirm')} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx index d45db7b65a7d..6833f314ffde 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx @@ -34,9 +34,11 @@ function MultiScanEducationalModal() { imageHeight={220} outerContainerStyle={styles.multiScanEducationalPopupImage} /> - - {translate('iou.scanMultipleReceipts')} - {translate('iou.scanMultipleReceiptsDescription')} + + + {translate('iou.scanMultipleReceipts')} + {translate('iou.scanMultipleReceiptsDescription')} + {translate('common.buttonConfirm')} From 3b7713df5fafd695d5d299aeae263a2ed8d90711 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:15:39 +0700 Subject: [PATCH 176/466] Remove ButtonRow in 1-button pages --- src/components/AIFeaturesPromoModal.tsx | 14 ++++++++------ src/components/AutoSubmitModal.tsx | 4 +--- .../FeatureTraining/primitives/BackButton.tsx | 14 ++++++++++---- .../FeatureTraining/primitives/ConfirmButton.tsx | 10 ++++++---- src/components/HoldOrRejectEducationalModal.tsx | 4 +--- src/components/HoldSubmitterEducationalModal.tsx | 4 +--- src/pages/TrackTrainingPage.tsx | 4 +--- .../components/MultiScanEducationalModal.tsx | 4 +--- 8 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx index 5fb2ac32de36..e45bcf3d6c03 100644 --- a/src/components/AIFeaturesPromoModal.tsx +++ b/src/components/AIFeaturesPromoModal.tsx @@ -78,8 +78,8 @@ function AIFeaturesPromoModal() { {translate('aiFeaturesPromoModal.spendAnalysis.description')} - - {translate('common.next')} + + {translate('common.next')} @@ -96,8 +96,10 @@ function AIFeaturesPromoModal() { {translate('aiFeaturesPromoModal.expenseAssistant.description')} - - {canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText')} + + + {canUseCustomAgent ? translate('common.next') : translate('aiFeaturesPromoModal.confirmText')} + @@ -115,8 +117,8 @@ function AIFeaturesPromoModal() { {translate('aiFeaturesPromoModal.customAgents.description')} - - {translate('aiFeaturesPromoModal.confirmText')} + + {translate('aiFeaturesPromoModal.confirmText')} diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx index 255033cfd529..a4be9c74e24b 100644 --- a/src/components/AutoSubmitModal.tsx +++ b/src/components/AutoSubmitModal.tsx @@ -117,9 +117,7 @@ function AutoSubmitModal() { ))} {shouldShowDismissOption && } - - {translate('common.buttonConfirm')} - + {translate('common.buttonConfirm')} diff --git a/src/components/FeatureTraining/primitives/BackButton.tsx b/src/components/FeatureTraining/primitives/BackButton.tsx index 206fdaca70d9..6a2446ec6586 100644 --- a/src/components/FeatureTraining/primitives/BackButton.tsx +++ b/src/components/FeatureTraining/primitives/BackButton.tsx @@ -2,14 +2,19 @@ import Button from '@components/ButtonComposed'; import {useFeatureTrainingActions, useFeatureTrainingState} from '@components/FeatureTraining/context'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import type {StyleProp, ViewStyle} from 'react-native'; + import React from 'react'; -function BackButton() { - const styles = useThemeStyles(); +type BackButtonProps = { + /** Style for the button, e.g. flex1 when sharing a ButtonRow */ + style?: StyleProp; +}; + +function BackButton({style}: BackButtonProps) { const {translate} = useLocalize(); const {isCarousel, currentPage} = useFeatureTrainingState(); const {goBack} = useFeatureTrainingActions(); @@ -23,7 +28,7 @@ function BackButton() { size={CONST.BUTTON_SIZE.LARGE} onPress={goBack} sentryLabel={CONST.SENTRY_LABEL.FEATURE_TRAINING.BACK_BUTTON} - style={styles.flex1} + style={style} > {translate('common.back')} @@ -33,3 +38,4 @@ function BackButton() { BackButton.displayName = 'FeatureTraining.BackButton'; export default BackButton; +export type {BackButtonProps}; diff --git a/src/components/FeatureTraining/primitives/ConfirmButton.tsx b/src/components/FeatureTraining/primitives/ConfirmButton.tsx index 4c7300280928..a2bd2ae31fb8 100644 --- a/src/components/FeatureTraining/primitives/ConfirmButton.tsx +++ b/src/components/FeatureTraining/primitives/ConfirmButton.tsx @@ -1,17 +1,19 @@ import {useFeatureTrainingActions, useFeatureTrainingState} from '@components/FeatureTraining/context'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; -import useThemeStyles from '@hooks/useThemeStyles'; +import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; type ConfirmButtonProps = { children: string; sentryLabel?: string; + + /** Style for the button container, e.g. flex1 when sharing a ButtonRow */ + style?: StyleProp; }; -function ConfirmButton({children, sentryLabel}: ConfirmButtonProps) { - const styles = useThemeStyles(); +function ConfirmButton({children, sentryLabel, style}: ConfirmButtonProps) { const {confirmSentryLabel, shouldShowLoadingImmediatelyOnPress, isCarousel, isLastPage} = useFeatureTrainingState(); const {handleConfirm, advance} = useFeatureTrainingActions(); @@ -34,7 +36,7 @@ function ConfirmButton({children, sentryLabel}: ConfirmButtonProps) { enabledWhenOffline shouldShowLoadingImmediatelyOnPress={loading} sentryLabel={sentryLabel ?? confirmSentryLabel} - containerStyles={styles.flex1} + containerStyles={style} /> ); } diff --git a/src/components/HoldOrRejectEducationalModal.tsx b/src/components/HoldOrRejectEducationalModal.tsx index 8a5f05a430c2..c37657c4dd95 100644 --- a/src/components/HoldOrRejectEducationalModal.tsx +++ b/src/components/HoldOrRejectEducationalModal.tsx @@ -95,9 +95,7 @@ function HoldOrRejectEducationalModal({onClose, onConfirm}: HoldOrRejectEducatio ))} - - {translate('common.buttonConfirm')} - + {translate('common.buttonConfirm')} ); diff --git a/src/components/HoldSubmitterEducationalModal.tsx b/src/components/HoldSubmitterEducationalModal.tsx index 3206f37d4403..39c7e429ce7c 100644 --- a/src/components/HoldSubmitterEducationalModal.tsx +++ b/src/components/HoldSubmitterEducationalModal.tsx @@ -52,9 +52,7 @@ function HoldSubmitterEducationalModal({onClose, onConfirm, isDM}: HoldSubmitter {translate(isDM ? 'iou.whatIsHoldExplainDM' : 'iou.whatIsHoldExplain')} - - {translate('common.buttonConfirm')} - + {translate('common.buttonConfirm')} ); diff --git a/src/pages/TrackTrainingPage.tsx b/src/pages/TrackTrainingPage.tsx index bb86b554b87c..b58a7a45a45d 100644 --- a/src/pages/TrackTrainingPage.tsx +++ b/src/pages/TrackTrainingPage.tsx @@ -51,9 +51,7 @@ function TrackTrainingPage() { {translate('common.learnMore')} - - {translate('common.buttonConfirm')} - + {translate('common.buttonConfirm')} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx index 6833f314ffde..5db57e42467b 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/MultiScanEducationalModal.tsx @@ -39,9 +39,7 @@ function MultiScanEducationalModal() { {translate('iou.scanMultipleReceipts')} {translate('iou.scanMultipleReceiptsDescription')} - - {translate('common.buttonConfirm')} - + {translate('common.buttonConfirm')} ); From 297eab899ee0e0c79a56932338a33dbd8db081f6 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:22:03 +0700 Subject: [PATCH 177/466] Update MigratedUserWelcomeModal usage --- src/components/MigratedUserWelcomeModal.tsx | 53 ++++++++++----------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/src/components/MigratedUserWelcomeModal.tsx b/src/components/MigratedUserWelcomeModal.tsx index 40bdba7710c9..42df12320243 100644 --- a/src/components/MigratedUserWelcomeModal.tsx +++ b/src/components/MigratedUserWelcomeModal.tsx @@ -104,36 +104,33 @@ function MigratedUserWelcomeModal() { outerContainerStyle={styles.p0} /> )} - - {translate('migratedUserWelcomeModal.title')} - {translate('migratedUserWelcomeModal.subtitle')} - - {ExpensifyFeatures.map(({translationKey, icon}) => ( - - - - ${convertToLTR(translate(translationKey))}`} /> + + + {translate('migratedUserWelcomeModal.title')} + {translate('migratedUserWelcomeModal.subtitle')} + + {ExpensifyFeatures.map(({translationKey, icon}) => ( + + + + ${convertToLTR(translate(translationKey))}`} /> + - - ))} - + ))} + + {translate('migratedUserWelcomeModal.helpText')} - - {translate('migratedUserWelcomeModal.confirmText')} - + {translate('migratedUserWelcomeModal.confirmText')} From 8f1a6253bc6014ff25966b7fa6cacfc9c4fc5475 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:26:03 +0700 Subject: [PATCH 178/466] Update SubmitPlanWelcomeModal usage --- src/components/SubmitPlanWelcomeModal.tsx | 37 +++++++++++------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/components/SubmitPlanWelcomeModal.tsx b/src/components/SubmitPlanWelcomeModal.tsx index 637e1b7f4b11..5ad15b48c2b3 100644 --- a/src/components/SubmitPlanWelcomeModal.tsx +++ b/src/components/SubmitPlanWelcomeModal.tsx @@ -68,27 +68,24 @@ function SubmitPlanWelcomeModal() { innerContainerStyle={[styles.alignItemsCenter, styles.justifyContentCenter, styles.p5, StyleUtils.getBackgroundColorStyle(colors.yellow400)]} outerContainerStyle={styles.p0} /> - - {translate('submitPlanWelcomeModal.title')} - {translate('submitPlanWelcomeModal.description')} - - {FEATURE_TRANSLATION_KEYS.map((translationKey) => ( - - {'• '} - {translate(translationKey)} - - ))} - + + + {translate('submitPlanWelcomeModal.title')} + {translate('submitPlanWelcomeModal.description')} + + {FEATURE_TRANSLATION_KEYS.map((translationKey) => ( + + {'• '} + {translate(translationKey)} + + ))} + + {translate('submitPlanWelcomeModal.dismissText')} - - {translate('submitPlanWelcomeModal.confirmText')} - + {translate('submitPlanWelcomeModal.confirmText')} From c46c150d2fc5b0a7dbd9dc0032df55c3c111d9c6 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:27:38 +0700 Subject: [PATCH 179/466] Update ChangePolicyEducationalModal usage --- src/pages/ChangePolicyEducationalModal.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pages/ChangePolicyEducationalModal.tsx b/src/pages/ChangePolicyEducationalModal.tsx index 988e29355120..79ac4d92539a 100644 --- a/src/pages/ChangePolicyEducationalModal.tsx +++ b/src/pages/ChangePolicyEducationalModal.tsx @@ -54,13 +54,13 @@ function ChangePolicyEducationalModal() { innerContainerStyle={[styles.alignItemsCenter, styles.justifyContentCenter, StyleUtils.getBackgroundColorStyle(colors.blue700)]} outerContainerStyle={styles.p0} /> - - {translate('iou.changePolicyEducational.title')} - {translate('iou.changePolicyEducational.description')} - - - {translate('common.buttonConfirm')} - + + + {translate('iou.changePolicyEducational.title')} + {translate('iou.changePolicyEducational.description')} + + + {translate('common.buttonConfirm')} From 624e04b953c10ab319f7ced81f47532b391fe2f9 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:27:56 +0700 Subject: [PATCH 180/466] Remove ButtonRow from UI test --- tests/ui/components/FeatureTrainingModalTest.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/ui/components/FeatureTrainingModalTest.tsx b/tests/ui/components/FeatureTrainingModalTest.tsx index b79c072c7734..722b294fced0 100644 --- a/tests/ui/components/FeatureTrainingModalTest.tsx +++ b/tests/ui/components/FeatureTrainingModalTest.tsx @@ -63,9 +63,7 @@ describe('FeatureTrainingModal', () => { - - {CONFIRM_TEXT} - + {CONFIRM_TEXT} , @@ -81,9 +79,7 @@ describe('FeatureTrainingModal', () => { - - {CONFIRM_TEXT} - + {CONFIRM_TEXT} ); @@ -98,9 +94,7 @@ describe('FeatureTrainingModal', () => { - - {CONFIRM_TEXT} - + {CONFIRM_TEXT} , ), From 85562f583f02505938c0fa5664de7e7fea03b7cf Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:36:01 +0700 Subject: [PATCH 181/466] Fix knip check --- src/components/FeatureTraining/index.tsx | 1 - src/components/FeatureTraining/primitives/BackButton.tsx | 1 - src/components/FeatureTraining/primitives/Body.tsx | 1 - src/components/FeatureTraining/primitives/BodyText.tsx | 1 - .../FeatureTraining/primitives/ConfirmButton.tsx | 1 - src/components/FeatureTraining/primitives/Description.tsx | 1 - src/components/FeatureTraining/primitives/HelpButton.tsx | 1 - src/components/FeatureTraining/primitives/Page.tsx | 1 - src/components/FeatureTraining/primitives/Subtitle.tsx | 1 - src/components/FeatureTraining/primitives/Title.tsx | 1 - src/components/FeatureTraining/types.ts | 8 +------- 11 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/components/FeatureTraining/index.tsx b/src/components/FeatureTraining/index.tsx index 6e3225cd6fb4..2b2fc2551f7e 100644 --- a/src/components/FeatureTraining/index.tsx +++ b/src/components/FeatureTraining/index.tsx @@ -30,4 +30,3 @@ const FeatureTraining = Object.assign(FeatureTrainingBase, { }); export default FeatureTraining; -export type {FeatureTrainingProps, FeatureTrainingCarouselProps, FeatureTrainingModalProps} from './types'; diff --git a/src/components/FeatureTraining/primitives/BackButton.tsx b/src/components/FeatureTraining/primitives/BackButton.tsx index 6a2446ec6586..83e694074e8f 100644 --- a/src/components/FeatureTraining/primitives/BackButton.tsx +++ b/src/components/FeatureTraining/primitives/BackButton.tsx @@ -38,4 +38,3 @@ function BackButton({style}: BackButtonProps) { BackButton.displayName = 'FeatureTraining.BackButton'; export default BackButton; -export type {BackButtonProps}; diff --git a/src/components/FeatureTraining/primitives/Body.tsx b/src/components/FeatureTraining/primitives/Body.tsx index bf24d651ac0c..5ee2b5690f75 100644 --- a/src/components/FeatureTraining/primitives/Body.tsx +++ b/src/components/FeatureTraining/primitives/Body.tsx @@ -22,4 +22,3 @@ function Body({children, style}: BodyProps) { Body.displayName = 'FeatureTraining.Body'; export default Body; -export type {BodyProps}; diff --git a/src/components/FeatureTraining/primitives/BodyText.tsx b/src/components/FeatureTraining/primitives/BodyText.tsx index 4341db98fb0f..2c4bfb871fbd 100644 --- a/src/components/FeatureTraining/primitives/BodyText.tsx +++ b/src/components/FeatureTraining/primitives/BodyText.tsx @@ -37,4 +37,3 @@ function BodyText({children, style, onLayout}: BodyTextProps) { BodyText.displayName = 'FeatureTraining.BodyText'; export default BodyText; -export type {BodyTextProps}; diff --git a/src/components/FeatureTraining/primitives/ConfirmButton.tsx b/src/components/FeatureTraining/primitives/ConfirmButton.tsx index a2bd2ae31fb8..e88843d3cf04 100644 --- a/src/components/FeatureTraining/primitives/ConfirmButton.tsx +++ b/src/components/FeatureTraining/primitives/ConfirmButton.tsx @@ -44,4 +44,3 @@ function ConfirmButton({children, sentryLabel, style}: ConfirmButtonProps) { ConfirmButton.displayName = 'FeatureTraining.ConfirmButton'; export default ConfirmButton; -export type {ConfirmButtonProps}; diff --git a/src/components/FeatureTraining/primitives/Description.tsx b/src/components/FeatureTraining/primitives/Description.tsx index 823155e34a7d..2a835ee94545 100644 --- a/src/components/FeatureTraining/primitives/Description.tsx +++ b/src/components/FeatureTraining/primitives/Description.tsx @@ -32,4 +32,3 @@ function Description({children, shouldRenderHTML = false}: DescriptionProps) { Description.displayName = 'FeatureTraining.Description'; export default Description; -export type {DescriptionProps}; diff --git a/src/components/FeatureTraining/primitives/HelpButton.tsx b/src/components/FeatureTraining/primitives/HelpButton.tsx index 6c4bcb0d575a..66a5404b03f1 100644 --- a/src/components/FeatureTraining/primitives/HelpButton.tsx +++ b/src/components/FeatureTraining/primitives/HelpButton.tsx @@ -30,4 +30,3 @@ function HelpButton({children, onPress, sentryLabel}: HelpButtonProps) { HelpButton.displayName = 'FeatureTraining.HelpButton'; export default HelpButton; -export type {HelpButtonProps}; diff --git a/src/components/FeatureTraining/primitives/Page.tsx b/src/components/FeatureTraining/primitives/Page.tsx index 259257578512..408cdad5d5a1 100644 --- a/src/components/FeatureTraining/primitives/Page.tsx +++ b/src/components/FeatureTraining/primitives/Page.tsx @@ -23,4 +23,3 @@ function Page({children}: PageProps) { Page.displayName = 'FeatureTraining.Page'; export default Page; -export type {PageProps}; diff --git a/src/components/FeatureTraining/primitives/Subtitle.tsx b/src/components/FeatureTraining/primitives/Subtitle.tsx index 2878ab822415..0cf71f04d5ad 100644 --- a/src/components/FeatureTraining/primitives/Subtitle.tsx +++ b/src/components/FeatureTraining/primitives/Subtitle.tsx @@ -23,4 +23,3 @@ function Subtitle({children}: SubtitleProps) { Subtitle.displayName = 'FeatureTraining.Subtitle'; export default Subtitle; -export type {SubtitleProps}; diff --git a/src/components/FeatureTraining/primitives/Title.tsx b/src/components/FeatureTraining/primitives/Title.tsx index 712ae0ad2944..763b0f0b4266 100644 --- a/src/components/FeatureTraining/primitives/Title.tsx +++ b/src/components/FeatureTraining/primitives/Title.tsx @@ -33,4 +33,3 @@ function Title({children, style}: TitleProps) { Title.displayName = 'FeatureTraining.Title'; export default Title; -export type {TitleProps}; diff --git a/src/components/FeatureTraining/types.ts b/src/components/FeatureTraining/types.ts index da24e4d68618..6dfaf9493b8d 100644 --- a/src/components/FeatureTraining/types.ts +++ b/src/components/FeatureTraining/types.ts @@ -1,5 +1,4 @@ import type {ReactNode} from 'react'; -import type {ViewStyle} from 'react-native'; type FeatureTrainingProps = { /** Called when the user confirms the tutorial. `willShowAgain` reflects the DismissOption checkbox (true by default). */ @@ -32,9 +31,4 @@ type FeatureTrainingCarouselProps = FeatureTrainingProps & { onPageChange?: (index: number) => void; }; -type FeatureTrainingModalProps = FeatureTrainingProps & { - /** Style for the outer Modal inner container */ - modalInnerContainerStyle?: ViewStyle; -}; - -export type {FeatureTrainingProps, FeatureTrainingCarouselProps, FeatureTrainingModalProps}; +export type {FeatureTrainingProps, FeatureTrainingCarouselProps}; From 3ca752d59bbf2106eb0306fdd15631f99eea54c8 Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:37:41 +0700 Subject: [PATCH 182/466] Fix https://github.com/Expensify/App/issues/95853 --- src/components/AIFeaturesPromoModal.tsx | 1 + .../FeatureTraining/FeatureTrainingCarousel.tsx | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/AIFeaturesPromoModal.tsx b/src/components/AIFeaturesPromoModal.tsx index e45bcf3d6c03..ae7de9beb61e 100644 --- a/src/components/AIFeaturesPromoModal.tsx +++ b/src/components/AIFeaturesPromoModal.tsx @@ -64,6 +64,7 @@ function AIFeaturesPromoModal() { onClose={closeModal} width={variables.aiFeaturesPromoModalWidth} confirmSentryLabel={CONST.SENTRY_LABEL.AI_FEATURES_PROMO_MODAL.CONFIRM_BUTTON} + shouldUseScrollView > (undefined); const measuredHeightsRef = useRef>({}); - const {Wrapper, wrapperProps, setContainerHeight, shouldUseScrollView: usingScrollView} = useScrollableWrapper({shouldUseScrollView, width}); + const {Wrapper, wrapperProps, setContainerHeight, shouldUseScrollView: usingScrollView, isInLandscapeMode} = useScrollableWrapper({shouldUseScrollView, width}); const pages = useMemo(() => { const pageList: SplitPage[] = []; @@ -136,6 +136,16 @@ function FeatureTrainingCarousel({ pageCountRef.current = pages.length; }, [pages.length]); + // Re-align the horizontal offset to the current page whenever the viewport width changes (e.g. device + // rotation or window resize). Without this the old pixel offset no longer lands on a page boundary and + // the carousel gets stuck between pages. + useEffect(() => { + if (carouselViewportWidth <= 0) { + return; + } + horizontalListRef.current?.scrollToOffset({offset: lastReportedPage.current * carouselViewportWidth, animated: false}); + }, [carouselViewportWidth]); + const recordPageHeight = useCallback((index: number, measured: number) => { if (measuredHeightsRef.current[index] === measured) { return; @@ -208,6 +218,7 @@ function FeatureTrainingCarousel({ {carouselViewportWidth > 0 && contentMinHeight === undefined && ( `FeatureTrainingCarousel-page-${index}`} horizontal pagingEnabled + initialScrollIndex={currentPage} disableIntervalMomentum snapToInterval={carouselViewportWidth} decelerationRate="fast" From 35bb35c6b3058c64d67c81e2a37b488af6952e9c Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 13 Aug 2026 00:23:51 +0200 Subject: [PATCH 183/466] fix test --- tests/ui/MergeTransactionDetailsReviewTest.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ui/MergeTransactionDetailsReviewTest.tsx b/tests/ui/MergeTransactionDetailsReviewTest.tsx index edf26feb5173..7d0c6499dda3 100644 --- a/tests/ui/MergeTransactionDetailsReviewTest.tsx +++ b/tests/ui/MergeTransactionDetailsReviewTest.tsx @@ -173,7 +173,8 @@ describe('Merging distance expenses across workspaces', () => { , ); await waitForBatchedUpdatesWithAct(); - expect(screen.getByTestId('field-Distance • Original: 10.20 mi')).toHaveTextContent('9.20 mi'); + // Matched loosely because the unit label reads as either "mi" or "miles", depending on the field's short form flag + expect(screen.getByTestId('field-Distance • Original: 10.20 mi')).toHaveTextContent(/^9\.20 (mi|miles)$/); }); it('deducts nothing when the merged expense is put on the report of a workspace that excludes nothing', async () => { From 3d6b170867f56001add1782c47a50ede9afc5889 Mon Sep 17 00:00:00 2001 From: "Chuck Dries (via MelvinBot)" Date: Wed, 12 Aug 2026 22:39:18 +0000 Subject: [PATCH 184/466] Allow linking an existing card feed to a non-USD workspace Co-authored-by: Chuck Dries --- .../expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index 9580bb5c2385..aab73017b5f0 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -176,7 +176,9 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif showLockedAccountModal(); return; } - if (!(policy?.outputCurrency === CONST.CURRENCY.USD || isUkEuCurrencySupported)) { + // The USD requirement only applies to enrolling a brand-new card program (which needs a USD bank account). + // Linking an existing feed doesn't require it, so skip the currency prompt when the workspace has accessible feeds. + if (!hasAccessibleFeeds && !(policy?.outputCurrency === CONST.CURRENCY.USD || isUkEuCurrencySupported)) { promptCurrencyChangeAndStartFlow(); return; } From d0c96ef78dffdaab0d5bc4941c6d44ba8d76c1e6 Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 13 Aug 2026 00:48:18 +0200 Subject: [PATCH 185/466] better identical merge handling --- src/libs/MergeTransactionUtils.ts | 30 +++++++++++--- tests/unit/MergeTransactionUtilsTest.ts | 53 +++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 0eba00eaecac..5be44e21fc1c 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -235,6 +235,8 @@ function getMergeableDataAndConflictFields( ) { const conflictFields: string[] = []; const mergeableData: Record = {}; + // The same object, typed for the field builders that read the values merged so far + const mergedSoFar = mergeableData as MergeTransaction; // Resolve the report-owner fallback the same way the display path (buildMergeFieldsData) does, so an expense // with no stored attendee is compared as [owner] instead of [] and doesn't produce a false attendee conflict @@ -287,7 +289,16 @@ function getMergeableDataAndConflictFields( // We allow user to select unreported report if (field === 'reportID') { if (targetValue === sourceValue) { - const updatedValues = getMergeFieldUpdatedValues({transaction: targetTransaction, field, fieldValue: SafeString(targetValue), getCurrencyDecimals, searchReports}); + const updatedValues = getMergeFieldUpdatedValues({ + transaction: targetTransaction, + field, + fieldValue: SafeString(targetValue), + getCurrencyDecimals, + mergeTransaction: mergedSoFar, + searchReports, + // Both expenses share the report, so the merged expense stays on that report's workspace + destinationPolicy: targetTransactionPolicy, + }); Object.assign(mergeableData, updatedValues); } else { conflictFields.push(field); @@ -323,9 +334,11 @@ function getMergeableDataAndConflictFields( field, fieldValue: selectedFieldValue as MergeTransaction[typeof field], getCurrencyDecimals, - mergeTransaction: mergeableData as MergeTransaction, + mergeTransaction: mergedSoFar, searchReports, policy: selectedPolicy, + // Nothing conflicts, so the merged expense stays on the report it is already on + destinationPolicy: selectedPolicy, }); Object.assign(mergeableData, updatedValues); } else { @@ -644,8 +657,11 @@ type GetMergeFieldUpdatedValuesParams = { searchReports?: Array>; policy?: OnyxEntry; - /** Workspace of the report the merged expense will live on, which is the one whose rules apply to it */ - destinationPolicy?: OnyxEntry; + /** + * Workspace of the report the merged expense will live on, which is the one whose rules apply to it. Required so + * that an expense with no workspace, which no rule applies to, has to be passed as undefined rather than omitted. + */ + destinationPolicy: OnyxEntry; }; /** @@ -745,7 +761,11 @@ function getMergeFieldUpdatedValues({ if (isDistanceRequest(transaction)) { const previousCustomUnit = mergeTransaction?.customUnit; const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate(undefined, previousCustomUnit, destinationPolicy); - updatedValues.customUnit = commuterExclusionUpdate; + // An empty update means the expense has no exclusion to apply or clear, and writing it would wipe the + // custom unit the merchant selection stored + if (Object.keys(commuterExclusionUpdate).length > 0) { + updatedValues.customUnit = commuterExclusionUpdate; + } if (typeof mergeTransaction?.amount === 'number') { updatedValues.amount = getAmountForBilledDistance( mergeTransaction.amount, diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 360d8c373265..1587f04d2063 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -17,10 +17,12 @@ import {isFromCreditCardImport} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; import createRandomMergeTransaction from '../utils/collections/mergeTransaction'; +import createRandomPolicy from '../utils/collections/policies'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction, {createRandomDistanceRequestTransaction} from '../utils/collections/transaction'; import {convertToDisplayString, translateLocal} from '../utils/TestHelper'; @@ -358,6 +360,49 @@ describe('MergeTransactionUtils', () => { }); }); + it('should keep the commuter exclusion when two identical distance expenses are merged without conflicts', () => { + // Given two identical distance expenses on a report of a workspace that excludes 1 commuter mile, so that + // nothing conflicts and every field is merged automatically + const excludingWorkspace: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, fixedDistance: 1, fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }; + const distanceExpense = { + ...createRandomDistanceRequestTransaction(0), + reportID: '9999', + merchant: '10.20 mi @ $1.00 / mi', + modifiedMerchant: '10.20 mi @ $1.00 / mi', + amount: -920, + comment: { + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + quantity: 10.2, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + commuterExclusion: 1, + reimbursableDistance: 9.2, + }, + }, + }; + + // When they are merged + const result = getMergeableDataAndConflictFields( + distanceExpense, + {...distanceExpense, transactionID: 'secondTransaction'}, + mockLocaleCompare, + mockGetCurrencyDecimals, + [], + excludingWorkspace, + excludingWorkspace, + ); + + // Then the workspace's exclusion survives, and the amount still pays for the reimbursable distance only + expect(result.conflictFields).not.toContain('merchant'); + expect(result.mergeableData).toMatchObject({ + amount: 920, + customUnit: expect.objectContaining({commuterExclusion: 1, reimbursableDistance: 9.2}) as unknown, + }); + }); + it('should merge amount field correctly when they are same', () => { const targetTransaction = { ...createRandomTransaction(1), @@ -1022,7 +1067,7 @@ describe('MergeTransactionUtils', () => { const fieldValue = 'New Merchant Name'; // When we get updated values for merchant field - const result = getMergeFieldUpdatedValues({transaction, field: 'merchant', fieldValue, getCurrencyDecimals: mockGetCurrencyDecimals}); + const result = getMergeFieldUpdatedValues({transaction, field: 'merchant', fieldValue, getCurrencyDecimals: mockGetCurrencyDecimals, destinationPolicy: undefined}); // Then it should return an object with the field value expect(result).toEqual({ @@ -1039,7 +1084,7 @@ describe('MergeTransactionUtils', () => { const fieldValue = 2500; // When we get updated values for amount field - const result = getMergeFieldUpdatedValues({transaction, field: 'amount', fieldValue, getCurrencyDecimals: mockGetCurrencyDecimals}); + const result = getMergeFieldUpdatedValues({transaction, field: 'amount', fieldValue, getCurrencyDecimals: mockGetCurrencyDecimals, destinationPolicy: undefined}); // Then it should include both amount and currency expect(result).toEqual({ @@ -1058,7 +1103,7 @@ describe('MergeTransactionUtils', () => { const fieldValue = '456'; // When we get updated values for reportID field - const result = getMergeFieldUpdatedValues({transaction, field: 'reportID', fieldValue, getCurrencyDecimals: mockGetCurrencyDecimals}); + const result = getMergeFieldUpdatedValues({transaction, field: 'reportID', fieldValue, getCurrencyDecimals: mockGetCurrencyDecimals, destinationPolicy: undefined}); // Then it should include both reportID and reportName expect(result).toEqual({ @@ -1093,7 +1138,7 @@ describe('MergeTransactionUtils', () => { const fieldValue = 'New Distance Merchant'; // When we get updated values for merchant field - const result = getMergeFieldUpdatedValues({transaction, field: 'merchant', fieldValue, getCurrencyDecimals: mockGetCurrencyDecimals}); + const result = getMergeFieldUpdatedValues({transaction, field: 'merchant', fieldValue, getCurrencyDecimals: mockGetCurrencyDecimals, destinationPolicy: undefined}); // Then it should include merchant plus all distance-specific fields expect(result).toEqual({ From d03e4ac2d730fab94ac5b7686d6c5006e660d63f Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 13 Aug 2026 03:16:20 +0200 Subject: [PATCH 186/466] comment --- tests/unit/MergeTransactionUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 1587f04d2063..a318ff226722 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -399,7 +399,7 @@ describe('MergeTransactionUtils', () => { expect(result.conflictFields).not.toContain('merchant'); expect(result.mergeableData).toMatchObject({ amount: 920, - customUnit: expect.objectContaining({commuterExclusion: 1, reimbursableDistance: 9.2}) as unknown, + customUnit: expect.objectContaining({commuterExclusion: 1, reimbursableDistance: 9.2}), }); }); From 5387f786ac13f27ee6f20a0d416e3e9bb15c460b Mon Sep 17 00:00:00 2001 From: alberto Date: Thu, 13 Aug 2026 03:20:34 +0200 Subject: [PATCH 187/466] types --- tests/unit/NextStepUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/NextStepUtilsTest.ts b/tests/unit/NextStepUtilsTest.ts index 48d6eea75ad3..f21d805c6db5 100644 --- a/tests/unit/NextStepUtilsTest.ts +++ b/tests/unit/NextStepUtilsTest.ts @@ -1106,7 +1106,7 @@ describe('libs/NextStepUtils', () => { return translateLocal(path, ...parameters); }; - const message = buildNextStepMessage(nextStep, translateWithDepositCurrency, currentUserAccountID, formatPhoneNumber); + const message = buildNextStepMessage(nextStep, translateWithDepositCurrency, undefined, currentUserAccountID, formatPhoneNumber); expect(message).toBe(`Waiting for you to add a ${expectedAccount}.`); }); From aa226e314bbf2a24ca0f5faef50b92a50937ce8b Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:32:49 +0700 Subject: [PATCH 188/466] hide the CREATE_NEW_EXPENSE and TRACK_DISTANCE_EXPENSE options for a Teachers Unite report --- .../SearchMoneyRequestReportEmptyState.tsx | 69 +++++++------- src/libs/ReportUtils.ts | 94 +++++++++---------- tests/unit/ReportUtilsTest.ts | 20 ++++ 3 files changed, 103 insertions(+), 80 deletions(-) diff --git a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx index b2b5087d6c1a..2d1ab8551661 100644 --- a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx +++ b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx @@ -6,7 +6,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {canAddTransaction, getMoneyRequestIOUType, isArchivedReport} from '@libs/ReportUtils'; +import {canAddTransaction, isArchivedReport, isTeachersUniteReport} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {cancelSpan} from '@libs/telemetry/activeSpans'; @@ -44,38 +44,43 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const isReportArchived = isArchivedReport(reportNameValuePairs); const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); const canAddTransactionToReport = canAddTransaction(report, isReportArchived); - const moneyRequestIOUType = getMoneyRequestIOUType(report.policyID); + const isReportTeachersUnite = isTeachersUniteReport(report); const addExpenseDropdownOptions = [ - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, - text: translate('iou.createExpense'), - icon: expensifyIcons.Plus, - onSelected: () => { - if (!reportId) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startMoneyRequest(moneyRequestIOUType, reportId, draftTransactionIDs); - }, - }, - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, - text: translate('iou.trackDistance'), - icon: expensifyIcons.Location, - onSelected: () => { - if (!reportId) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startDistanceRequest(moneyRequestIOUType, reportId, draftTransactionIDs, lastDistanceExpenseType); - }, - }, + // Teachers Unite doesn't support reimbursement, so "Create expense" and "Track distance" are hidden for those reports. + ...(isReportTeachersUnite + ? [] + : [ + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, + text: translate('iou.createExpense'), + icon: expensifyIcons.Plus, + onSelected: () => { + if (!reportId) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs); + }, + }, + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, + text: translate('iou.trackDistance'), + icon: expensifyIcons.Location, + onSelected: () => { + if (!reportId) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startDistanceRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs, lastDistanceExpenseType); + }, + }, + ]), { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE, text: translate('iou.addExistingExpense'), diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c43135cbb673..2dbce377f9ad 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3174,46 +3174,51 @@ function getAddExpenseDropdownOptions({ currentUserAccountID, }: GetAddExpenseDropdownOptionsParams): Array>> { // The iouReport being added to may belong to a different policy than the chat surrounding it is rendered in - // (e.g. a Teachers Unite expense report preview shown inside a non-Teachers-Unite chat), so derive the IOU - // type from the iouReport's own policy rather than from the surrounding `policy`. - const moneyRequestIOUType = getMoneyRequestIOUType(getReportOrDraftReport(iouReportID)?.policyID ?? policy?.id); + // (e.g. a Teachers Unite expense report preview shown inside a non-Teachers-Unite chat), so check the + // iouReport's own policy rather than the surrounding `policy`. + const isReportTeachersUnite = isTeachersUnitePolicyID(getReportOrDraftReport(iouReportID)?.policyID ?? policy?.id); return [ - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, - text: translate('iou.createExpense'), - icon: icons.Plus, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_CREATE, - onSelected: () => { - if (!iouReportID) { - return; - } - if ( - policy && - policy.type !== CONST.POLICY.TYPE.PERSONAL && - shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) - ) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startMoneyRequest(moneyRequestIOUType, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); - }, - }, - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, - text: translate('iou.trackDistance'), - icon: icons.Location, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_TRACK_DISTANCE, - onSelected: () => { - if (!iouReportID) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startDistanceRequest(moneyRequestIOUType, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); - }, - }, + // Teachers Unite doesn't support reimbursement, so "Create expense" and "Track distance" are hidden for those reports. + ...(isReportTeachersUnite + ? [] + : [ + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, + text: translate('iou.createExpense'), + icon: icons.Plus, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_CREATE, + onSelected: () => { + if (!iouReportID) { + return; + } + if ( + policy && + policy.type !== CONST.POLICY.TYPE.PERSONAL && + shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) + ) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); + }, + }, + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, + text: translate('iou.trackDistance'), + icon: icons.Location, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_TRACK_DISTANCE, + onSelected: () => { + if (!iouReportID) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startDistanceRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); + }, + }, + ]), { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE, text: translate('iou.addExistingExpense'), @@ -10444,14 +10449,6 @@ function isTeachersUniteReport(report: OnyxEntry): boolean { return isTeachersUnitePolicyID(report?.policyID); } -/** - * The Teachers Unite workspace doesn't support reimbursement, so "Add expense" entry points must start a - * TRACK request instead of a SUBMIT request when targeting a report on that policy. - */ -function getMoneyRequestIOUType(policyID: string | undefined): IOUType { - return isTeachersUnitePolicyID(policyID) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; -} - /** * Helper method to define what expense options we want to show for particular method. * There are 4 expense options: Submit, Split, Pay and Track expense: @@ -14181,7 +14178,8 @@ export { getRouteFromLink, canDeleteCardTransactionByLiabilityType, getAddExpenseDropdownOptions, - getMoneyRequestIOUType, + isTeachersUnitePolicyID, + isTeachersUniteReport, getTaskAssigneeChatOnyxData, getTransactionDetails, getTransactionReportName, diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 42393cb095f9..404c0480d423 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -19553,6 +19553,26 @@ describe('ReportUtils', () => { result.at(2)?.onSelected?.(); expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(mockPolicy.id)); }); + + it('should hide the CREATE_NEW_EXPENSE and TRACK_DISTANCE_EXPENSE options for a Teachers Unite report', () => { + const mockPolicy = createRandomPolicy(0); + mockPolicy.id = CONST.TEACHERS_UNITE.TEST_POLICY_ID; + + const result = getAddExpenseDropdownOptions({ + translate: mockTranslate, + icons: mockIcons, + iouReportID: mockIouReportID, + policy: mockPolicy, + userBillingGracePeriodEnds: undefined, + draftTransactionIDs: undefined, + amountOwed: 0, + ownerBillingGracePeriodEnd: undefined, + currentUserAccountID, + }); + + expect(result).toHaveLength(1); + expect(result.at(0)?.value).toBe(CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE); + }); }); describe('GBR: draft report with delayed submission off then on (issue #69891)', () => { const policyID = 'policy-delayed-submit'; From 6cf83d3e364b8946b2236967bc33dceefaafcbf3 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:53:01 +0700 Subject: [PATCH 189/466] remove the unused export --- src/libs/ReportUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 2dbce377f9ad..44817a40a860 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14178,7 +14178,6 @@ export { getRouteFromLink, canDeleteCardTransactionByLiabilityType, getAddExpenseDropdownOptions, - isTeachersUnitePolicyID, isTeachersUniteReport, getTaskAssigneeChatOnyxData, getTransactionDetails, From f12901d5c71c852685699d9d4dde524ddf7b032e Mon Sep 17 00:00:00 2001 From: "Chuck Dries (via MelvinBot)" Date: Thu, 13 Aug 2026 03:09:56 +0000 Subject: [PATCH 190/466] Suppress new-program setup branch on unsupported currencies in feed selector Co-authored-by: Chuck Dries --- ...WorkspaceExpensifyCardFeedSelectorPage.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx index 184d969d17e3..56d16158e310 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx @@ -13,6 +13,7 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultFundID from '@hooks/useDefaultFundID'; import useExpensifyCardFeedsForFeedSelector from '@hooks/useExpensifyCardFeedsForFeedSelector'; +import useExpensifyCardUkEuSupported from '@hooks/useExpensifyCardUkEuSupported'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -83,6 +84,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const policy = usePolicy(policyID); const canWriteExpensifyCard = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD); + const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policyID); const getIssueCardFundID = () => { if (primaryFeeds.length === 0) { @@ -200,14 +202,20 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const primaryListData = primaryFeeds.map((entry) => toListItem(entry, false)); + // Suppress the new-program (bank-account setup) branch on unsupported currencies: setting up a brand-new card program requires a USD/UK/EU workspace. + // Issuing a card on an existing feed (issueCardFundID !== undefined) is safe on any currency, so keep the button in that case. + const shouldShowIssueCardButton = issueCardFundID !== undefined || policy?.outputCurrency === CONST.CURRENCY.USD || isUkEuCurrencySupported; + const issueNewCardAndOtherFeedsFooter = canWriteExpensifyCard ? ( - + {shouldShowIssueCardButton && ( + + )} {otherFeeds.length > 0 && ( <> {translate('workspace.companyCards.fromOtherWorkspaces')} From 89acc1de5674d5ed9699e73b680b0a67c46fc439 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 13 Aug 2026 11:29:41 +0530 Subject: [PATCH 191/466] Thread delegateAccountID through the task-comment optimistic chain Signed-off-by: krishna2323 --- src/libs/ReportUtils.ts | 30 +++++++++++++++---- src/libs/actions/Task.ts | 8 ++++- .../ReportActionCompose/useComposerSubmit.ts | 1 + src/pages/tasks/DynamicNewTaskDetailsPage.tsx | 3 ++ src/pages/tasks/DynamicNewTaskPage.tsx | 3 ++ .../DynamicTaskAssigneeSelectorModal.tsx | 3 ++ tests/actions/TaskTest.ts | 15 ++++++++++ tests/unit/ReportUtilsTest.ts | 15 +++++++++- 8 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 560cb89df250..996958405db0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6844,6 +6844,7 @@ function updateOptimisticParentReportAction( * @param taskAssigneeAccountID - AccountID of the person assigned to the task * @param text - Text of the comment * @param parentReportID - Report ID of the parent report + * @param delegateAccountID - AccountID of the delegate acting on behalf of the current user * @param createdOffset - The offset for task's created time that created via a loop */ function buildOptimisticTaskCommentReportAction( @@ -6852,13 +6853,20 @@ function buildOptimisticTaskCommentReportAction( taskAssigneeAccountID: number, text: string, parentReportID: string | undefined, + delegateAccountID: number | undefined, actorAccountID?: number, createdOffset = 0, currentUserEmail?: string, currentUserAccountID?: number, ): OptimisticReportAction { - // delegateAccountIDParam: will be threaded in PR 14; buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) - const reportAction = buildOptimisticAddCommentReportAction({text, reportID: taskReportID, createdOffset, currentUserEmail, currentUserAccountID, delegateAccountIDParam: undefined}); + const reportAction = buildOptimisticAddCommentReportAction({ + text, + reportID: taskReportID, + createdOffset, + currentUserEmail, + currentUserAccountID, + delegateAccountIDParam: delegateAccountID, + }); if (Array.isArray(reportAction.reportAction.message)) { const message = reportAction.reportAction.message.at(0); if (message) { @@ -10906,6 +10914,7 @@ function getTaskAssigneeChatOnyxData( assigneeChatReport: OnyxEntry, currentUserEmail: string, currentUserAccountID: number, + delegateAccountID: number | undefined, isOptimisticAssigneeChatReport?: boolean, ): OnyxDataTaskAssigneeChat { // Set if we need to add a comment to the assignee chat notifying them that they have been assigned a task @@ -11027,6 +11036,7 @@ function getTaskAssigneeChatOnyxData( assigneeAccountID, `assigned to ${displayname}`, parentReportID, + delegateAccountID, undefined, 0, currentUserEmail, @@ -12320,7 +12330,7 @@ function prepareOnboardingOnyxData({ // Text message const message = typeof onboardingMessage.message === 'function' ? onboardingMessage.message(onboardingTaskParams) : onboardingMessage.message; - // delegateAccountIDParam: will be threaded in PR 14; buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) + // delegateAccountIDParam: will be threaded in PR 15b; buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) const textComment = buildOptimisticAddCommentReportAction({text: message, actorAccountID, createdOffset: 1, delegateAccountIDParam: undefined}); const textCommentAction: OptimisticAddCommentReportAction = textComment.reportAction; const textMessage: AddCommentOrAttachmentParams = { @@ -12373,7 +12383,17 @@ function prepareOnboardingOnyxData({ ); const emailCreatingAction = CONST.EMAIL.CONCIERGE; const taskCreatedAction = buildOptimisticCreatedReportAction({emailCreatingAction}); - const taskReportAction = buildOptimisticTaskCommentReportAction(currentTask.reportID, taskTitle, 0, `task for ${taskTitle}`, targetChatReportID, actorAccountID, index + 3); + // delegateAccountID: will be threaded in PR 15b; buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) + const taskReportAction = buildOptimisticTaskCommentReportAction( + currentTask.reportID, + taskTitle, + 0, + `task for ${taskTitle}`, + targetChatReportID, + undefined, + actorAccountID, + index + 3, + ); currentTask.parentReportActionID = taskReportAction.reportAction.reportActionID; let isTaskAutoCompleted: boolean = task.autoCompleted; @@ -12418,7 +12438,7 @@ function prepareOnboardingOnyxData({ // Sign-off welcome message const welcomeSignOffText = engagementChoice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM ? translateLocal('onboarding.welcomeSignOffTitleManageTeam') : translateLocal('onboarding.welcomeSignOffTitle'); - // delegateAccountIDParam: will be threaded in PR 14; buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) + // delegateAccountIDParam: will be threaded in PR 15b; buildOptimisticAddCommentReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) const welcomeSignOffComment = buildOptimisticAddCommentReportAction({text: welcomeSignOffText, actorAccountID, createdOffset: tasksData.length + 3, delegateAccountIDParam: undefined}); const welcomeSignOffCommentAction: OptimisticAddCommentReportAction = welcomeSignOffComment.reportAction; const welcomeSignOffMessage = { diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 97674ebf1492..fa3eb1c8d328 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -49,6 +49,7 @@ type EditTaskAssigneeOptions = { currentUserAccountID: number; hasOutstandingChildTask: boolean; delegateEmail: string | undefined; + delegateAccountID: number | undefined; assigneeAccountID?: number | null; assigneeChatReport?: OnyxEntry; isOptimisticReport?: boolean; @@ -76,6 +77,7 @@ type CreateTaskAndNavigateParams = { assigneeEmail: string; currentUserAccountID: number; currentUserEmail: string; + delegateAccountID: number | undefined; assigneeAccountID?: number; assigneeChatReport?: OnyxEntry; policyID?: string; @@ -128,6 +130,7 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { currentUserEmail, currentUserDisplayName, currentUserAvatar, + delegateAccountID, assigneeAccountID = 0, assigneeChatReport, policyID = CONST.POLICY.OWNER_EMAIL_FAKE, @@ -164,7 +167,7 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { currentUserEmail, currentUserAvatar, }); - const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `task for ${title}`, parentReportID); + const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assigneeAccountID, `task for ${title}`, parentReportID, delegateAccountID); optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID; const currentTime = getDBTimeWithSkew(); @@ -268,6 +271,7 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { assigneeChatReport, currentUserEmail, currentUserAccountID, + delegateAccountID, ); optimisticData.push(...assigneeChatReportOnyxData.optimisticData); @@ -791,6 +795,7 @@ function editTaskAssignee({ currentUserAccountID, hasOutstandingChildTask, delegateEmail, + delegateAccountID, assigneeAccountID = 0, assigneeChatReport, isOptimisticReport, @@ -917,6 +922,7 @@ function editTaskAssignee({ assigneeChatReport, currentUserEmail, currentUserAccountID, + delegateAccountID, isOptimisticReport, ); diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 07fde54d87ad..9c114a1ca191 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -146,6 +146,7 @@ function useComposerSubmit(reportID: string) { currentUserEmail, currentUserDisplayName: currentUserPersonalDetails.displayName, currentUserAvatar: currentUserPersonalDetails.avatar, + delegateAccountID, assigneeAccountID: assignee?.accountID, assigneeChatReport, policyID: report?.policyID, diff --git a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx index bd666348d3dc..2fc9ff740d98 100644 --- a/src/pages/tasks/DynamicNewTaskDetailsPage.tsx +++ b/src/pages/tasks/DynamicNewTaskDetailsPage.tsx @@ -8,6 +8,7 @@ import TextInput from '@components/TextInput'; import useAncestors from '@hooks/useAncestors'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -38,6 +39,7 @@ function DynamicNewTaskDetailsPage() { const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${task?.parentReportID}`); const ancestors = useAncestors(parentReport); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); const [taskCreatorAndAssigneeDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { selector: personalDetailsListSelector([currentUserPersonalDetails.accountID, task?.assigneeAccountID]), }); @@ -89,6 +91,7 @@ function DynamicNewTaskDetailsPage() { currentUserEmail: currentUserPersonalDetails.email ?? '', currentUserDisplayName: currentUserPersonalDetails.displayName, currentUserAvatar: currentUserPersonalDetails.avatar, + delegateAccountID, assigneeAccountID: task.assigneeAccountID, assigneeChatReport: task.assigneeChatReport, policyID: CONST.POLICY.OWNER_EMAIL_FAKE, diff --git a/src/pages/tasks/DynamicNewTaskPage.tsx b/src/pages/tasks/DynamicNewTaskPage.tsx index a420f48cf6d6..1e77a2facdc9 100644 --- a/src/pages/tasks/DynamicNewTaskPage.tsx +++ b/src/pages/tasks/DynamicNewTaskPage.tsx @@ -9,6 +9,7 @@ import ScrollView from '@components/ScrollView'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -42,6 +43,7 @@ function DynamicNewTaskPage() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const reportAttributes = useReportAttributes(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); const [taskCreatorAndAssigneeDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { selector: personalDetailsListSelector([currentUserPersonalDetails.accountID, task?.assigneeAccountID]), }); @@ -115,6 +117,7 @@ function DynamicNewTaskPage() { currentUserEmail: currentUserPersonalDetails.email ?? '', currentUserDisplayName: currentUserPersonalDetails.displayName, currentUserAvatar: currentUserPersonalDetails.avatar, + delegateAccountID, assigneeAccountID: task.assigneeAccountID, assigneeChatReport: task.assigneeChatReport, policyID: parentReport?.policyID, diff --git a/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx b/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx index 6689291f1d62..6344ff7fc961 100644 --- a/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx +++ b/src/pages/tasks/DynamicTaskAssigneeSelectorModal.tsx @@ -7,6 +7,7 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import type {ListItem} from '@components/SelectionList/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useHasOutstandingChildTask from '@hooks/useHasOutstandingChildTask'; import useLocalize from '@hooks/useLocalize'; @@ -54,6 +55,7 @@ function DynamicTaskAssigneeSelectorModal() { const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const [delegateEmail] = useOnyx(ONYXKEYS.ACCOUNT, { selector: delegateEmailSelector, @@ -173,6 +175,7 @@ function DynamicTaskAssigneeSelectorModal() { currentUserAccountID: currentUserPersonalDetails.accountID, hasOutstandingChildTask, delegateEmail, + delegateAccountID, assigneeAccountID: option?.accountID, assigneeChatReport, isOptimisticReport, diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index 1e78844dbbf9..26d9552cb62f 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -479,6 +479,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, assigneeChatReport: mockAssigneeChatReport, policyID: mockPolicyID, @@ -528,6 +529,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, policyID: mockPolicyID, isCreatedUsingMarkdown: false, @@ -559,6 +561,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, policyID: mockPolicyID, isCreatedUsingMarkdown: false, @@ -594,6 +597,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, assigneeChatReport: undefined, policyID: mockPolicyID, @@ -656,6 +660,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, assigneeChatReport: mockAssigneeChatReport, policyID: mockPolicyID, @@ -697,6 +702,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, assigneeChatReport: mockAssigneeChatReport, policyID: CONST.POLICY.OWNER_EMAIL_FAKE, @@ -745,6 +751,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockCurrentUserAccountID, // assignee is current user assigneeChatReport: mockAssigneeChatReport, policyID: mockPolicyID, @@ -806,6 +813,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, assigneeChatReport: mockAssigneeChatReport, policyID: mockPolicyID, @@ -844,6 +852,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, assigneeChatReport: mockAssigneeChatReport, policyID: mockPolicyID, @@ -886,6 +895,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, policyID: mockPolicyID, isCreatedUsingMarkdown: false, @@ -915,6 +925,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: undefined, currentUserAvatar: undefined, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, policyID: mockPolicyID, isCreatedUsingMarkdown: false, @@ -944,6 +955,7 @@ describe('actions/Task', () => { currentUserEmail: '', currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, policyID: mockPolicyID, isCreatedUsingMarkdown: false, @@ -975,6 +987,7 @@ describe('actions/Task', () => { currentUserEmail: mockCurrentUserEmail, currentUserDisplayName: mockCurrentUserDisplayName, currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, assigneeAccountID: mockAssigneeAccountID, policyID: mockPolicyID, isCreatedUsingMarkdown: false, @@ -1383,6 +1396,7 @@ describe('actions/Task', () => { currentUserAccountID: CURRENT_USER_ACCOUNT_ID, hasOutstandingChildTask: false, delegateEmail: DELEGATE_EMAIL, + delegateAccountID: undefined, assigneeAccountID: ASSIGNEE_ACCOUNT_ID, formatPhoneNumber, }); @@ -1414,6 +1428,7 @@ describe('actions/Task', () => { currentUserAccountID: CURRENT_USER_ACCOUNT_ID, hasOutstandingChildTask: false, delegateEmail: undefined, + delegateAccountID: undefined, assigneeAccountID: ASSIGNEE_ACCOUNT_ID, formatPhoneNumber, }); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 42393cb095f9..ccd480717317 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -667,6 +667,7 @@ describe('ReportUtils', () => { createMock>({}), passedCurrentUserEmail, passedCurrentUserAccountID, + undefined, ); expect(optimisticAssigneeAddComment).toBeDefined(); @@ -691,6 +692,7 @@ describe('ReportUtils', () => { createMock>({}), passedCurrentUserEmail, passedCurrentUserAccountID, + undefined, ); const reportAction = result.optimisticAssigneeAddComment?.reportAction as ReportAction | undefined; @@ -701,7 +703,18 @@ describe('ReportUtils', () => { }); it('does not create optimistic assignee comment when assigneeChatReportID equals parentReportID', () => { - const result = getTaskAssigneeChatOnyxData(1, 2, 'taskReportID', 'sameReportID', 'sameReportID', 'Task title', createMock>({}), 'email@user.com', 50); + const result = getTaskAssigneeChatOnyxData( + 1, + 2, + 'taskReportID', + 'sameReportID', + 'sameReportID', + 'Task title', + createMock>({}), + 'email@user.com', + 50, + undefined, + ); expect(result.optimisticAssigneeAddComment).toBeUndefined(); }); From 64635627dcde13577c4e9a3997cc3324504b2c67 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 13 Aug 2026 11:38:27 +0530 Subject: [PATCH 192/466] add tests. Signed-off-by: krishna2323 --- src/libs/ReportUtils.ts | 2 +- tests/actions/TaskTest.ts | 48 ++++++++++++++++++++++++++++++++ tests/unit/ReportUtilsTest.ts | 52 +++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 996958405db0..a5194134d0a0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -12409,7 +12409,7 @@ function prepareOnboardingOnyxData({ } const completedTaskReportAction = isTaskAutoCompleted - ? // delegateEmailParam: will be threaded in PR 14; buildOptimisticTaskReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) + ? // delegateEmailParam: will be threaded in PR 17; buildOptimisticTaskReportAction falls back to module-level Onyx.connect value (https://github.com/Expensify/App/issues/66425) buildOptimisticTaskReportAction(currentTask.reportID, CONST.REPORT.ACTIONS.TYPE.TASK_COMPLETED, undefined, 'marked as complete', actorAccountID, 2) : null; if (task.type === CONST.ONBOARDING_TASK_TYPE.CREATE_WORKSPACE) { diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index 26d9552cb62f..66e869d08eb5 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -1009,6 +1009,54 @@ describe('actions/Task', () => { }), ); }); + + it('should set the passed delegateAccountID on the optimistic task comment in the parent report', () => { + const DELEGATE_ACCOUNT_ID = 999; + + createTaskAndNavigate({ + parentReport: {reportID: mockParentReportID}, + title: mockTitle, + description: mockDescription, + assigneeEmail: mockAssigneeEmail, + currentUserAccountID: mockCurrentUserAccountID, + currentUserEmail: mockCurrentUserEmail, + currentUserDisplayName: mockCurrentUserDisplayName, + currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: DELEGATE_ACCOUNT_ID, + assigneeAccountID: mockAssigneeAccountID, + policyID: mockPolicyID, + isCreatedUsingMarkdown: false, + quickAction: {}, + taskCreatorAndAssigneeDetails: undefined, + }); + + const reportActionsUpdate = getRequiredOnyxUpdate(getRequiredWriteOnyxData(), 'optimisticData', `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockParentReportID}`); + const reportAction = getRequiredReportAction(reportActionsUpdate, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + expect(reportAction.delegateAccountID).toBe(DELEGATE_ACCOUNT_ID); + }); + + it('should leave delegateAccountID unset on the optimistic task comment when no delegate is passed', () => { + createTaskAndNavigate({ + parentReport: {reportID: mockParentReportID}, + title: mockTitle, + description: mockDescription, + assigneeEmail: mockAssigneeEmail, + currentUserAccountID: mockCurrentUserAccountID, + currentUserEmail: mockCurrentUserEmail, + currentUserDisplayName: mockCurrentUserDisplayName, + currentUserAvatar: mockCurrentUserAvatar, + delegateAccountID: undefined, + assigneeAccountID: mockAssigneeAccountID, + policyID: mockPolicyID, + isCreatedUsingMarkdown: false, + quickAction: {}, + taskCreatorAndAssigneeDetails: undefined, + }); + + const reportActionsUpdate = getRequiredOnyxUpdate(getRequiredWriteOnyxData(), 'optimisticData', `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mockParentReportID}`); + const reportAction = getRequiredReportAction(reportActionsUpdate, CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT); + expect(reportAction.delegateAccountID).toBeUndefined(); + }); }); describe('completeTask', () => { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index ccd480717317..72c218eeea4f 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -718,6 +718,58 @@ describe('ReportUtils', () => { expect(result.optimisticAssigneeAddComment).toBeUndefined(); }); + + describe('delegateAccountID', () => { + const MODULE_DELEGATE_EMAIL = 'module-delegate@vikings.net'; + const MODULE_DELEGATE_ACCOUNT_ID = 900; + const PASSED_DELEGATE_ACCOUNT_ID = 901; + + const buildAssigneeChatOnyxData = (delegateAccountID: number | undefined) => + getTaskAssigneeChatOnyxData( + 1, + 2, + 'taskReportID', + 'assigneeChatReportID', + 'parentReportID', + 'Task title', + createMock>({}), + 'email@user.com', + 50, + delegateAccountID, + ); + + beforeEach(async () => { + // The module-level delegate is what `buildOptimisticAddCommentReportAction` falls back to, so it has to + // differ from the passed value for these tests to prove which one wins. + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [MODULE_DELEGATE_ACCOUNT_ID]: {accountID: MODULE_DELEGATE_ACCOUNT_ID, login: MODULE_DELEGATE_EMAIL, displayName: 'Module Delegate'}, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess: {delegate: MODULE_DELEGATE_EMAIL}}); + await waitForBatchedUpdates(); + }); + + afterEach(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess: {delegate: null}}); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[MODULE_DELEGATE_ACCOUNT_ID]: null}); + await waitForBatchedUpdates(); + }); + + it('uses the passed delegateAccountID instead of the module-level delegate', () => { + const {optimisticAssigneeAddComment} = buildAssigneeChatOnyxData(PASSED_DELEGATE_ACCOUNT_ID); + + const reportAction = optimisticAssigneeAddComment?.reportAction as ReportAction | undefined; + expect(reportAction?.delegateAccountID).toBe(PASSED_DELEGATE_ACCOUNT_ID); + }); + + // TODO: the fallback assertion below flips to `toBeUndefined()` once the module-level Onyx.connect is + // removed (https://github.com/Expensify/App/issues/66425). + it('falls back to the module-level delegate when no delegateAccountID is passed', () => { + const {optimisticAssigneeAddComment} = buildAssigneeChatOnyxData(undefined); + + const reportAction = optimisticAssigneeAddComment?.reportAction as ReportAction | undefined; + expect(reportAction?.delegateAccountID).toBe(MODULE_DELEGATE_ACCOUNT_ID); + }); + }); }); describe('prepareOnboardingOnyxData', () => { From b05f023dc883e084a3ddbf671ebeff597b888bf1 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:14:51 +0700 Subject: [PATCH 193/466] migrate SPLIT_EXPENSE_EDIT --- src/ROUTES.ts | 22 +++++++++---------- src/SCREENS.ts | 2 +- .../ModalStackNavigators/index.tsx | 2 +- .../Navigation/linkingConfig/OldRoutes.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 5 +---- src/libs/Navigation/types.ts | 13 +++++------ src/libs/actions/SplitExpenses.ts | 5 +++-- ...ge.tsx => DynamicSplitExpenseEditPage.tsx} | 14 +++++++----- src/pages/iou/SplitExpensePage.tsx | 5 +++-- tests/navigation/getMatchingNewRouteTest.ts | 8 +++++++ 10 files changed, 42 insertions(+), 35 deletions(-) rename src/pages/iou/{SplitExpenseEditPage.tsx => DynamicSplitExpenseEditPage.tsx} (98%) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 931f63c53dee..f82006e94785 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -310,13 +310,23 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, ], // `orderWeight` is stringified explicitly because the first tag list has index 0, which `getUrlWithParams` would drop as falsy. getRoute: (action: IOUAction, iouType: IOUType, orderWeight: number, transactionID: string, reportID?: string, reportActionID?: string) => getUrlWithParams('expense-tag', {action, iouType, orderWeight: String(orderWeight), transactionID, reportID, reportActionID}), queryParams: ['action', 'iouType', 'orderWeight', 'transactionID', 'reportID', 'reportActionID'], }, + SPLIT_EXPENSE_EDIT: { + path: 'split-expense/edit/:reportID/:splitExpenseTransactionID?', + entryScreens: [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE, SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_SEARCH], + getRoute: (reportID: string, splitExpenseTransactionID?: string) => { + if (!splitExpenseTransactionID) { + return `split-expense/edit/${reportID}` as const; + } + return `split-expense/edit/${reportID}/${splitExpenseTransactionID}` as const; + }, + }, MONEY_REQUEST_STEP_DESTINATION: { path: 'per-diem-destination', entryScreens: [SCREENS.MONEY_REQUEST.CREATE], @@ -2212,16 +2222,6 @@ const ROUTES = { return getUrlWithBackToParam(`create/split-expense/create-date-range/${reportID}/${transactionID}`, backTo); }, }, - SPLIT_EXPENSE_EDIT: { - route: 'edit/split-expense/overview/:reportID/:transactionID/:splitExpenseTransactionID?', - getRoute: (reportID: string | undefined, originalTransactionID: string | undefined, splitExpenseTransactionID?: string, backTo?: string) => { - if (!reportID || !originalTransactionID) { - Log.warn(`Invalid ${reportID}(reportID) or ${originalTransactionID}(transactionID) is used to build the SPLIT_EXPENSE_EDIT route`); - } - - return getUrlWithBackToParam(`edit/split-expense/overview/${reportID}/${originalTransactionID}${splitExpenseTransactionID ? `/${splitExpenseTransactionID}` : ''}`, backTo); - }, - }, MONEY_REQUEST_HOLD_REASON: { route: ':type/edit/reason/:transactionID?/:searchHash?', getRoute: (type: ValueOf, transactionID: string, reportID: string | undefined, backTo: string, searchHash?: number) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index a78b17f002b8..139bd34a2480 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -365,7 +365,7 @@ const SCREENS = { SPLIT_EXPENSE: 'Money_Request_Split_Expense', SPLIT_EXPENSE_SEARCH: 'Money_Request_Split_Expense_Search', SPLIT_EXPENSE_CREATE_DATE_RANGE: 'Money_Request_Split_Expense_Create_Date_Range', - SPLIT_EXPENSE_EDIT: 'Money_Request_Split_Expense_Edit', + DYNAMIC_SPLIT_EXPENSE_EDIT: 'Dynamic_Money_Request_Split_Expense_Edit', DISTANCE_CREATE: 'Money_Request_Distance_Create', STEP_DISTANCE_MAP: 'Money_Request_Step_Distance_Map', STEP_DISTANCE_MANUAL: 'Money_Request_Step_Distance_Manual', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index ea641049e2b3..7e9dd6f196e3 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -217,7 +217,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/SplitExpensePage').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_SEARCH]: () => require('../../../../pages/iou/SplitExpensePage').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_CREATE_DATE_RANGE]: () => require('../../../../pages/iou/SplitExpenseCreateDateRagePage').default, - [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: () => require('../../../../pages/iou/SplitExpenseEditPage').default, + [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT]: () => require('../../../../pages/iou/DynamicSplitExpenseEditPage').default, [SCREENS.MONEY_REQUEST.DISTANCE_CREATE]: () => require('../../../../pages/iou/request/DistanceRequestStartPage').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MAP]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceMap').default, [SCREENS.MONEY_REQUEST.STEP_DISTANCE_MANUAL]: () => require('../../../../pages/iou/request/step/IOURequestStepDistanceManual').default, diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index c09dca6afb5a..326debc32e5b 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -151,6 +151,7 @@ const oldRoutes: Record = { '/*/*/taxRate/*/*': '/r/$4/taxRate?action=$1&iouType=$2&transactionID=$3&reportID=$4', '/*/*/taxAmount/*/*': '/r/$4/taxAmount?action=$1&iouType=$2&transactionID=$3&reportID=$4', '/*/*/attendees/*/*': '/r/$4/attendees?action=$1&iouType=$2&transactionID=$3&reportID=$4', + '/edit/split-expense/overview/*/*/*': '/create/split-expense/overview/$1/$2/0/split-expense/edit/$1/$3', /* eslint-enable @typescript-eslint/naming-convention */ }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index af25cbd5777c..a3566330575a 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1842,10 +1842,7 @@ const config: LinkingOptions['config'] = { path: ROUTES.SPLIT_EXPENSE_CREATE_DATE_RANGE.route, exact: true, }, - [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: { - path: ROUTES.SPLIT_EXPENSE_EDIT.route, - exact: true, - }, + [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT]: DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.path, [SCREENS.SET_DEFAULT_WORKSPACE]: ROUTES.SET_DEFAULT_WORKSPACE.route, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 0b48993d3d3b..1188a7cf617d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2742,7 +2742,11 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.DEBUG]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.AVATAR_CROP]: NavigatorScreenParams; [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE]: NavigatorScreenParams; - [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: NavigatorScreenParams; + [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT]: { + reportID: string; + transactionID: string; + splitExpenseTransactionID?: string; + }; [SCREENS.RIGHT_MODAL.ADD_EXISTING_EXPENSE]: NavigatorScreenParams<{reportId: string | undefined}>; [SCREENS.RIGHT_MODAL.SCHEDULE_CALL]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.REPORT_CHANGE_APPROVER]: NavigatorScreenParams; @@ -3362,13 +3366,6 @@ type SplitExpenseParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; }; - [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: { - reportID: string; - transactionID: string; - splitExpenseTransactionID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; }; type AddExistingExpensesParamList = { diff --git a/src/libs/actions/SplitExpenses.ts b/src/libs/actions/SplitExpenses.ts index 5c26803eca53..568c9455f6d2 100644 --- a/src/libs/actions/SplitExpenses.ts +++ b/src/libs/actions/SplitExpenses.ts @@ -1,6 +1,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import {calculateAmount} from '@libs/IOUUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; @@ -9,7 +10,7 @@ import {buildOptimisticTransaction, getChildTransactions, getOriginalTransaction import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Policy, Report, Transaction} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; @@ -131,7 +132,7 @@ function initSplitExpense( ? ROUTES.SPLIT_EXPENSE_SEARCH.getRoute(reportID, originalTransactionID, undefined, Navigation.getActiveRoute()) : ROUTES.SPLIT_EXPENSE.getRoute(reportID, originalTransactionID, undefined, Navigation.getActiveRoute()); initDraftSplitExpenseDataForEdit(draftTransaction, transaction.transactionID, reportID); - Navigation.navigate(ROUTES.SPLIT_EXPENSE_EDIT.getRoute(reportID, originalTransactionID, transaction.transactionID, splitExpenseOverviewRoute)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.getRoute(reportID, transaction.transactionID), splitExpenseOverviewRoute)); return; } if (isSearchTopmostFullScreenRoute()) { diff --git a/src/pages/iou/SplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx similarity index 98% rename from src/pages/iou/SplitExpenseEditPage.tsx rename to src/pages/iou/DynamicSplitExpenseEditPage.tsx index 1d6ffb2c3557..8c52e0ac94b1 100644 --- a/src/pages/iou/SplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -11,6 +11,7 @@ import {useSearchResultsContext} from '@components/Search/SearchContext'; import useAllTransactions from '@hooks/useAllTransactions'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -33,7 +34,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SplitExpenseParamList} from '@libs/Navigation/types'; +import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import {arePolicyRulesEnabled, getDistanceRateCustomUnitRate, getTagLists, hasAnyPaidPolicy, isGroupPolicyByType} from '@libs/PolicyUtils'; @@ -54,16 +55,17 @@ import {policyTypeSelector} from '@selectors/Policy'; import React, {useCallback, useEffect, useMemo} from 'react'; import {View} from 'react-native'; -type SplitExpensePageProps = PlatformStackScreenProps; +type DynamicSplitExpenseEditPageProps = PlatformStackScreenProps; -function SplitExpenseEditPage({route}: SplitExpensePageProps) { +function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {translate, toLocaleDigit} = useLocalize(); const {convertToDisplayString, getCurrencySymbol} = useCurrencyListActions(); const {currentSearchResults} = useSearchResultsContext(); - const {reportID, transactionID, splitExpenseTransactionID = '', backTo} = route.params; + const {reportID, transactionID, splitExpenseTransactionID = ''} = route.params; + const backTo = useDynamicBackPath(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.path); const [splitExpenseDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`); const [originalTransactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${splitExpenseDraftTransaction?.comment?.originalTransactionID}`); @@ -312,7 +314,7 @@ function SplitExpenseEditPage({route}: SplitExpensePageProps) { ) : null; return ( - + { initDraftSplitExpenseDataForEdit(draftTransaction, item.transactionID, item.reportID ?? reportID); - Navigation.navigate(ROUTES.SPLIT_EXPENSE_EDIT.getRoute(item.reportID ?? reportID, originalTransactionID, item.transactionID, Navigation.getActiveRoute())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.getRoute(item.reportID ?? reportID, item.transactionID))); }, }); }; diff --git a/tests/navigation/getMatchingNewRouteTest.ts b/tests/navigation/getMatchingNewRouteTest.ts index 8a77c32ac696..1e4cad9bd114 100644 --- a/tests/navigation/getMatchingNewRouteTest.ts +++ b/tests/navigation/getMatchingNewRouteTest.ts @@ -106,6 +106,14 @@ describe('getBestMatchingPath', () => { expect(getMatchingNewRoute('/settings/subscription/downgrade')).toBe(undefined); }); + it('redirects old split expense edit path to the dynamic split expense edit route', () => { + expect(getMatchingNewRoute('/edit/split-expense/overview/123/456/789')).toBe('/create/split-expense/overview/123/456/0/split-expense/edit/123/789'); + }); + + it('preserves query params when redirecting the old split expense edit path', () => { + expect(getMatchingNewRoute('/edit/split-expense/overview/123/456/789?backTo=/r/123')).toBe('/create/split-expense/overview/123/456/0/split-expense/edit/123/789?backTo=/r/123'); + }); + it('redirects old card reconciliation account path with two wildcards', () => { expect(getMatchingNewRoute('/workspaces/abc/accounting/xero/card-reconciliation/account')).toBe( '/workspaces/abc/accounting/xero/card-reconciliation/account-reconciliation-settings', From 9e32e74a6da03b9b5d3017ec4fadff8a9362cecc Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:45:26 +0700 Subject: [PATCH 194/466] update entry screens --- src/ROUTES.ts | 2 +- src/libs/actions/SplitExpenses.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e11440ee3c31..dd9924678709 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1545,7 +1545,7 @@ const DYNAMIC_ROUTES = { SCREENS.MONEY_REQUEST.STEP_CONFIRMATION, SCREENS.MONEY_REQUEST.CREATE, SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.REPORT, SCREENS.REPORT_DETAILS.DYNAMIC_ROOT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, diff --git a/src/libs/actions/SplitExpenses.ts b/src/libs/actions/SplitExpenses.ts index 568c9455f6d2..58466a9e54bd 100644 --- a/src/libs/actions/SplitExpenses.ts +++ b/src/libs/actions/SplitExpenses.ts @@ -132,6 +132,7 @@ function initSplitExpense( ? ROUTES.SPLIT_EXPENSE_SEARCH.getRoute(reportID, originalTransactionID, undefined, Navigation.getActiveRoute()) : ROUTES.SPLIT_EXPENSE.getRoute(reportID, originalTransactionID, undefined, Navigation.getActiveRoute()); initDraftSplitExpenseDataForEdit(draftTransaction, transaction.transactionID, reportID); + Navigation.navigate(splitExpenseOverviewRoute); Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.getRoute(reportID, transaction.transactionID), splitExpenseOverviewRoute)); return; } From 9a2781e43cf368959c83d937aaab43c9f6a3f889 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:09:29 +0700 Subject: [PATCH 195/466] update eslint.seatbelt --- config/eslint/eslint.seatbelt.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 40e7798df4f1..ccd587a56c11 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1141,7 +1141,7 @@ "../../src/pages/inbox/sidebar/SidebarLinksData.tsx" "react-hooks/refs" 1 "../../src/pages/iou/MoneyRequestAmountForm.tsx" "no-restricted-imports" 1 "../../src/pages/iou/MoneyRequestAmountForm.tsx" "react-hooks/set-state-in-effect" 3 -"../../src/pages/iou/SplitExpenseEditPage.tsx" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 +"../../src/pages/iou/DynamicSplitExpenseEditPage.tsx" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 "../../src/pages/iou/SplitExpensePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/SplitExpensePage.tsx" "react-hooks/set-state-in-effect" 3 "../../src/pages/iou/SplitList/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 From 77b86f8ba0412384c2c13824039c53169d8aa65b Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:23:32 +0700 Subject: [PATCH 196/466] Let consumers call onClose in onConfirm --- src/components/AutoSubmitModal.tsx | 1 + src/components/FeatureTraining/FeatureTraining.tsx | 7 ++----- .../FeatureTraining/FeatureTrainingCarousel.tsx | 6 +----- src/components/FeatureTraining/types.ts | 3 --- src/components/FeatureTrainingModal.tsx | 9 +++++++-- src/components/MigratedUserWelcomeModal.tsx | 1 + src/components/SubmitPlanWelcomeModal.tsx | 1 - src/pages/ChangePolicyEducationalModal.tsx | 1 + src/pages/TrackTrainingPage.tsx | 1 + 9 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/components/AutoSubmitModal.tsx b/src/components/AutoSubmitModal.tsx index a4be9c74e24b..283479e6f26a 100644 --- a/src/components/AutoSubmitModal.tsx +++ b/src/components/AutoSubmitModal.tsx @@ -69,6 +69,7 @@ function AutoSubmitModal() { const onConfirm = (willShowAgain: boolean) => { willShowAgainRef.current = willShowAgain; + handleClose(); }; const shouldShowDismissOption = dismissedASAPSubmitExplanation === false; diff --git a/src/components/FeatureTraining/FeatureTraining.tsx b/src/components/FeatureTraining/FeatureTraining.tsx index 2a58f144b1a3..822755be5721 100644 --- a/src/components/FeatureTraining/FeatureTraining.tsx +++ b/src/components/FeatureTraining/FeatureTraining.tsx @@ -12,7 +12,6 @@ function FeatureTraining({ onConfirm, onClose, onWillShowAgainChange, - shouldCloseOnConfirm = true, shouldUseScrollView = false, width = variables.featureTrainingModalWidth, confirmSentryLabel, @@ -26,12 +25,10 @@ function FeatureTraining({ setWillShowAgain((prev) => !prev); }, [onWillShowAgainChange, willShowAgain]); + // Closing after confirm is the consumer's responsibility — call it inside onConfirm when desired. const handleConfirm = useCallback(() => { onConfirm?.(willShowAgain); - if (shouldCloseOnConfirm) { - onClose?.(); - } - }, [onConfirm, onClose, shouldCloseOnConfirm, willShowAgain]); + }, [onConfirm, willShowAgain]); const handleClose = useCallback(() => onClose?.(), [onClose]); diff --git a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx index 76b344efa38e..efb4849265de 100644 --- a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx +++ b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx @@ -78,7 +78,6 @@ function FeatureTrainingCarousel({ onConfirm, onClose, onPageChange, - shouldCloseOnConfirm = true, shouldUseScrollView = false, width = variables.featureTrainingModalWidth, confirmSentryLabel, @@ -161,10 +160,7 @@ function FeatureTrainingCarousel({ const handleConfirm = useCallback(() => { onConfirm?.(false); - if (shouldCloseOnConfirm) { - onClose?.(); - } - }, [onConfirm, onClose, shouldCloseOnConfirm]); + }, [onConfirm]); const handleClose = useCallback(() => onClose?.(), [onClose]); diff --git a/src/components/FeatureTraining/types.ts b/src/components/FeatureTraining/types.ts index 6dfaf9493b8d..369cdb945d29 100644 --- a/src/components/FeatureTraining/types.ts +++ b/src/components/FeatureTraining/types.ts @@ -10,9 +10,6 @@ type FeatureTrainingProps = { /** Called when the DismissOption checkbox is toggled */ onWillShowAgainChange?: (willShowAgain: boolean) => void; - /** Whether the modal should close after confirm (defaults to true) */ - shouldCloseOnConfirm?: boolean; - /** Whether the content should render inside a ScrollView (auto-enabled in landscape) */ shouldUseScrollView?: boolean; diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx index ed8ff19ae5fb..792aff26098b 100644 --- a/src/components/FeatureTrainingModal.tsx +++ b/src/components/FeatureTrainingModal.tsx @@ -118,8 +118,13 @@ function FeatureTrainingModal({ > { + onConfirm?.(willShowAgain); + if (!shouldCloseOnConfirm) { + return; + } + closeModal(); + }} onClose={closeModal} width={width} > diff --git a/src/components/MigratedUserWelcomeModal.tsx b/src/components/MigratedUserWelcomeModal.tsx index 42df12320243..f8be8df92a94 100644 --- a/src/components/MigratedUserWelcomeModal.tsx +++ b/src/components/MigratedUserWelcomeModal.tsx @@ -86,6 +86,7 @@ function MigratedUserWelcomeModal() { contentStyle={[styles.pt0, styles.pb0]} > diff --git a/src/components/SubmitPlanWelcomeModal.tsx b/src/components/SubmitPlanWelcomeModal.tsx index 5ad15b48c2b3..e15ad71c7262 100644 --- a/src/components/SubmitPlanWelcomeModal.tsx +++ b/src/components/SubmitPlanWelcomeModal.tsx @@ -58,7 +58,6 @@ function SubmitPlanWelcomeModal() { { willShowAgainRef.current = willShowAgain; From 14c6dcfb6eed5d3d41f8ddec02ae009f24cc6b6f Mon Sep 17 00:00:00 2001 From: gijoe0295 <153004152+gijoe0295@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:29:22 +0700 Subject: [PATCH 197/466] Fix CONSISTENCY-13 --- src/components/FeatureTraining/primitives/ButtonRow.tsx | 1 + src/components/FeatureTraining/primitives/ConfirmButton.tsx | 3 +++ src/components/FeatureTraining/primitives/Description.tsx | 3 +++ src/components/FeatureTraining/primitives/HelpButton.tsx | 5 +++++ src/components/FeatureTraining/primitives/Page.tsx | 1 + src/components/FeatureTraining/primitives/Subtitle.tsx | 1 + src/components/FeatureTraining/primitives/Title.tsx | 3 +++ 7 files changed, 17 insertions(+) diff --git a/src/components/FeatureTraining/primitives/ButtonRow.tsx b/src/components/FeatureTraining/primitives/ButtonRow.tsx index 5998fb2b2139..427ad2ef4d3b 100644 --- a/src/components/FeatureTraining/primitives/ButtonRow.tsx +++ b/src/components/FeatureTraining/primitives/ButtonRow.tsx @@ -6,6 +6,7 @@ import React from 'react'; import {View} from 'react-native'; type ButtonRowProps = { + /** Buttons sharing the horizontal row, e.g. BackButton and ConfirmButton */ children?: ReactNode; }; diff --git a/src/components/FeatureTraining/primitives/ConfirmButton.tsx b/src/components/FeatureTraining/primitives/ConfirmButton.tsx index e88843d3cf04..7ea5dd844a90 100644 --- a/src/components/FeatureTraining/primitives/ConfirmButton.tsx +++ b/src/components/FeatureTraining/primitives/ConfirmButton.tsx @@ -6,7 +6,10 @@ import type {StyleProp, ViewStyle} from 'react-native'; import React from 'react'; type ConfirmButtonProps = { + /** Text label shown on the confirm button */ children: string; + + /** Sentry label for the confirm button; falls back to the root confirmSentryLabel */ sentryLabel?: string; /** Style for the button container, e.g. flex1 when sharing a ButtonRow */ diff --git a/src/components/FeatureTraining/primitives/Description.tsx b/src/components/FeatureTraining/primitives/Description.tsx index 2a835ee94545..c65a3a151360 100644 --- a/src/components/FeatureTraining/primitives/Description.tsx +++ b/src/components/FeatureTraining/primitives/Description.tsx @@ -7,7 +7,10 @@ import React from 'react'; import {View} from 'react-native'; type DescriptionProps = { + /** Description text to render */ children?: string; + + /** Whether the description is HTML markup and should be rendered via RenderHTML */ shouldRenderHTML?: boolean; }; diff --git a/src/components/FeatureTraining/primitives/HelpButton.tsx b/src/components/FeatureTraining/primitives/HelpButton.tsx index 66a5404b03f1..f0fe989cf0bb 100644 --- a/src/components/FeatureTraining/primitives/HelpButton.tsx +++ b/src/components/FeatureTraining/primitives/HelpButton.tsx @@ -7,8 +7,13 @@ import CONST from '@src/CONST'; import React from 'react'; type HelpButtonProps = { + /** Text label shown on the help button */ children: string; + + /** Called when the help button is pressed */ onPress?: () => void; + + /** Sentry label for the help button */ sentryLabel?: string; }; diff --git a/src/components/FeatureTraining/primitives/Page.tsx b/src/components/FeatureTraining/primitives/Page.tsx index 408cdad5d5a1..4b641bfdabe1 100644 --- a/src/components/FeatureTraining/primitives/Page.tsx +++ b/src/components/FeatureTraining/primitives/Page.tsx @@ -3,6 +3,7 @@ import type {ReactNode} from 'react'; import {isValidElement} from 'react'; type PageProps = { + /** Page content — an Illustration and a Body */ children?: ReactNode; }; diff --git a/src/components/FeatureTraining/primitives/Subtitle.tsx b/src/components/FeatureTraining/primitives/Subtitle.tsx index 0cf71f04d5ad..323e36b8dce9 100644 --- a/src/components/FeatureTraining/primitives/Subtitle.tsx +++ b/src/components/FeatureTraining/primitives/Subtitle.tsx @@ -7,6 +7,7 @@ import type {ReactNode} from 'react'; import React from 'react'; type SubtitleProps = { + /** Subtitle text shown above the title */ children?: ReactNode; }; diff --git a/src/components/FeatureTraining/primitives/Title.tsx b/src/components/FeatureTraining/primitives/Title.tsx index 763b0f0b4266..1b8d41f9dbce 100644 --- a/src/components/FeatureTraining/primitives/Title.tsx +++ b/src/components/FeatureTraining/primitives/Title.tsx @@ -8,7 +8,10 @@ import type {StyleProp, TextStyle} from 'react-native'; import React, {isValidElement} from 'react'; type TitleProps = { + /** Title content — rendered as a headline when a string, as-is when a ReactNode */ children?: ReactNode; + + /** Additional style applied to the title text (string titles only) */ style?: StyleProp; }; From 4f8f9c3aa682da5277d2ca20cfb58985c9c54600 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Thu, 13 Aug 2026 08:48:35 -0700 Subject: [PATCH 198/466] update comments --- .../expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx | 4 ++-- .../expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx index 56d16158e310..19cd39793572 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx @@ -202,8 +202,8 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const primaryListData = primaryFeeds.map((entry) => toListItem(entry, false)); - // Suppress the new-program (bank-account setup) branch on unsupported currencies: setting up a brand-new card program requires a USD/UK/EU workspace. - // Issuing a card on an existing feed (issueCardFundID !== undefined) is safe on any currency, so keep the button in that case. + // Suppress the new-program branch on workspaces with unsupported currencies + // These workspaces may only issue cards on existing feeds const shouldShowIssueCardButton = issueCardFundID !== undefined || policy?.outputCurrency === CONST.CURRENCY.USD || isUkEuCurrencySupported; const issueNewCardAndOtherFeedsFooter = canWriteExpensifyCard ? ( diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index aab73017b5f0..90019cedab10 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -176,8 +176,8 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif showLockedAccountModal(); return; } - // The USD requirement only applies to enrolling a brand-new card program (which needs a USD bank account). - // Linking an existing feed doesn't require it, so skip the currency prompt when the workspace has accessible feeds. + // The supported currency restriction only applies to enrolling a brand-new card program. + // If hasAccessibleFeeds is true, allow the flow to start in order to link an existing feed if (!hasAccessibleFeeds && !(policy?.outputCurrency === CONST.CURRENCY.USD || isUkEuCurrencySupported)) { promptCurrencyChangeAndStartFlow(); return; From 5cac92596acc69997be99a20be37753f426ca5b6 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:53:33 +0700 Subject: [PATCH 199/466] Standardize Expense Report and Expense header with predictable button placement and carousel counter --- src/ONYXKEYS.ts | 5 + src/components/MoneyReportHeader.tsx | 74 ++- .../MoneyReportHeaderMoreContent.tsx | 70 ++- src/components/MoneyRequestHeader.tsx | 22 +- .../MoneyRequestReportNavigation.tsx | 14 +- .../MoneyRequestReportTransactionList.tsx | 31 +- ...neyRequestReportTransactionsNavigation.tsx | 329 +++++++------ src/components/PopoverMenu/index.tsx | 27 +- .../ListItem/TransactionGroupListExpanded.tsx | 4 +- src/components/Search/index.tsx | 93 ++-- ...eMoneyReportHeaderMoreContentVisibility.ts | 46 ++ src/hooks/useNavigateToTransactionThread.ts | 15 +- src/languages/de.ts | 1 + src/languages/el.ts | 6 +- src/languages/en.ts | 2 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/ExportOnyxState/common.ts | 1 + src/libs/Navigation/types.ts | 1 + .../actions/TransactionThreadNavigation.ts | 53 +- src/pages/home/RecentlyAddedSection/index.tsx | 2 +- src/pages/inbox/ReportNavigateAwayHandler.tsx | 12 +- src/styles/utils/sizing.ts | 4 + .../MoneyReportHeaderActionsPlacementTest.tsx | 315 ++++++++++++ .../MoneyReportHeaderMoreContentTest.tsx | 120 ----- ...yReportHeaderMoreContentVisibilityTest.tsx | 120 +++++ ...questReportTransactionsNavigation.test.tsx | 458 ++++++++++++++++++ .../useNavigateToTransactionThread.test.ts | 110 +++++ .../TransactionThreadNavigationTest.ts | 135 ++++++ 35 files changed, 1692 insertions(+), 386 deletions(-) create mode 100644 src/hooks/useMoneyReportHeaderMoreContentVisibility.ts create mode 100644 tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx delete mode 100644 tests/ui/components/MoneyReportHeaderMoreContentTest.tsx create mode 100644 tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx create mode 100644 tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx create mode 100644 tests/unit/hooks/useNavigateToTransactionThread.test.ts create mode 100644 tests/unit/libs/actions/TransactionThreadNavigationTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a108395f26e9..727fafedca9c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -811,6 +811,10 @@ const ONYXKEYS = { /** List of transaction IDs used when navigating to prev/next transaction when viewing it in RHP */ TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS: 'transactionThreadNavigationTransactionIDs', + /** Hash of the search snapshot that holds the transactions referenced by TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS. + * Used to fall back to snapshot data when the live transaction collection hasn't loaded those transactions yet (e.g. opening an expense from the Spend page as an approver). */ + TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH: 'transactionThreadNavigationSnapshotHash', + /** Optional map of transactionID -> sibling descriptor for prev/next navigation in snapshot-backed flows (e.g. Home "Recently added"), where siblings may be absent from the main Onyx collections. When set, navigation resolves (and lazily creates) each sibling's thread on demand from its descriptor. */ TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS: 'transactionThreadNavigationThreadReportIDs', @@ -1800,6 +1804,7 @@ type OnyxValuesMapping = { [ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY]: OnyxTypes.LastSearchParams; [ONYXKEYS.NVP_LAST_ANDROID_LOGIN]: string; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS]: string[]; + [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH]: number; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS]: Record; [ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES]: OnyxTypes.ExportTemplate[]; [ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION]: OnboardingAccounting; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 92f168746b29..205ca942b54d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,5 +1,6 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; @@ -9,16 +10,21 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import React, {useEffect} from 'react'; +import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import HeaderLoadingBar from './HeaderLoadingBar'; @@ -27,6 +33,8 @@ import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import {ExportDownloadStatusProvider} from './MoneyReportHeaderActions/ExportDownloadStatusProvider'; import MoneyReportHeaderModals from './MoneyReportHeaderModals'; import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent'; +import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; +import MoneyRequestReportTransactionsNavigation from './MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; import {PaymentAnimationsProvider} from './PaymentAnimationsContext'; import {useSearchSelectionActions} from './Search/SearchContext'; @@ -79,15 +87,38 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const transactions = Object.values(reportTransactions); + const [activeTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + + const singleTransactionID = transactions.length === 1 ? transactions.at(0)?.transactionID : undefined; + + const threadParentReportActionID = moneyRequestReport?.parentReportActionID; + const threadTransactionIDSelector = useCallback( + (parentReportActions: OnyxEntry) => { + const parentReportAction = threadParentReportActionID ? parentReportActions?.[threadParentReportActionID] : undefined; + return isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; + }, + [threadParentReportActionID], + ); + const [threadTransactionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.parentReportID)}`, {selector: threadTransactionIDSelector}); + + const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; + const multiTxAnchorTransactionID = anchorTransactionIDFromRoute && activeTransactionIDs?.includes(anchorTransactionIDFromRoute) ? anchorTransactionIDFromRoute : undefined; + const carouselAnchorTransactionID = singleTransactionID ?? threadTransactionID ?? multiTxAnchorTransactionID; + const shouldShowTransactionNavigation = !!carouselAnchorTransactionID && !!activeTransactionIDs?.includes(carouselAnchorTransactionID); + const styles = useThemeStyles(); const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); const shouldShowHeaderButtonsInHeaderRow = isInLandscapeMode || !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; + const isReportInRHP = route.name !== SCREENS.REPORT; - const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; + const {statusBarType, shouldShowNextStep, hasStatusOrNextStep} = useMoneyReportHeaderMoreContentVisibility(reportIDProp); + const shouldRenderActionsInHeaderRow = shouldShowHeaderButtonsInHeaderRow && !hasStatusOrNextStep; + const shouldDisplaySearchRouter = !isReportInRHP || (isSmallScreenWidth && !isReportInSearch); + const backTo = (route.params as {backTo?: Route} | undefined)?.backTo; const primaryAction = useReportPrimaryAction(reportIDProp); @@ -135,14 +166,28 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt shouldEnableDetailPageNavigation openParentReportInCurrentTab > - {shouldShowHeaderButtonsInHeaderRow && ( - - )} + + {shouldRenderActionsInHeaderRow && ( + + )} + {isReportInSearch && + (shouldShowTransactionNavigation && carouselAnchorTransactionID ? ( + + ) : ( + + ))} + {!shouldShowHeaderButtonsInHeaderRow && ( )} - + ); diff --git a/src/components/MoneyReportHeaderMoreContent.tsx b/src/components/MoneyReportHeaderMoreContent.tsx index b8a5494fa99e..168961dc0903 100644 --- a/src/components/MoneyReportHeaderMoreContent.tsx +++ b/src/components/MoneyReportHeaderMoreContent.tsx @@ -1,17 +1,12 @@ -import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; -import {isGroupPolicy} from '@libs/PolicyUtils'; -import {isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -22,20 +17,37 @@ import {useRoute} from '@react-navigation/native'; import React from 'react'; import {View} from 'react-native'; +import type {MoneyReportHeaderActionsProps} from './MoneyReportHeaderActions/types'; + +import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import MoneyReportHeaderNextStep from './MoneyReportHeaderNextStep'; import MoneyReportHeaderStatusBarSection from './MoneyReportHeaderStatusBarSection'; import {useMoneyReportTransactionThread} from './MoneyReportTransactionThreadContext'; -import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; type MoneyReportHeaderMoreContentProps = { reportID: string | undefined; + + /** The report's primary action, forwarded to the actions row */ + primaryAction: MoneyReportHeaderActionsProps['primaryAction']; + + /** Route to navigate back to */ + backTo: Route | undefined; + + /** Which status bar to render, resolved by the header via useMoneyReportHeaderMoreContentVisibility */ + statusBarType: ValueOf | undefined; + + /** Whether the next step bar should be rendered, resolved alongside `statusBarType` */ + shouldShowNextStep: boolean; + + /** Whether the report actions belong at the end of this row. The header renders them itself when this row is empty. */ + shouldRenderActionsInRow: boolean; }; /** - * Cheap visibility gate — fetches minimal data to decide whether the more-content section - * should render at all, avoiding expensive hooks in the body when nothing is shown. + * Cheap visibility gate — decides whether the more-content section should render at all, + * avoiding expensive hooks in the body when nothing is shown. */ -function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentProps) { +function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, statusBarType, shouldShowNextStep, shouldRenderActionsInRow}: MoneyReportHeaderMoreContentProps) { const route = useRoute< | PlatformStackRouteProp | PlatformStackRouteProp @@ -45,13 +57,9 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); - const {shouldShowStatusBar, statusBarType} = useMoneyReportHeaderStatusBar(reportID, moneyRequestReport?.chatReportID); - - const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - const shouldShowNextStep = isGroupPolicy(policy) && !isInvoiceReport && !shouldShowStatusBar; - const shouldShowMoreContent = shouldShowNextStep || !!statusBarType || isReportInSearch; + const hasStatusOrNextStep = shouldShowNextStep || !!statusBarType; + const shouldShowMoreContent = hasStatusOrNextStep || shouldRenderActionsInRow; if (!shouldShowMoreContent) { return null; @@ -63,6 +71,9 @@ function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentPr statusBarType={statusBarType} isReportInSearch={isReportInSearch} shouldShowNextStep={shouldShowNextStep} + primaryAction={primaryAction} + backTo={backTo} + shouldRenderActionsInRow={shouldRenderActionsInRow} /> ); } @@ -72,20 +83,27 @@ type MoneyReportHeaderMoreContentBodyProps = { statusBarType: ValueOf | undefined; isReportInSearch: boolean; shouldShowNextStep: boolean; + primaryAction: MoneyReportHeaderActionsProps['primaryAction']; + backTo: Route | undefined; + shouldRenderActionsInRow: boolean; }; -function MoneyReportHeaderMoreContentBody({moneyRequestReport, statusBarType, isReportInSearch, shouldShowNextStep}: MoneyReportHeaderMoreContentBodyProps) { +function MoneyReportHeaderMoreContentBody({ + moneyRequestReport, + statusBarType, + isReportInSearch, + shouldShowNextStep, + primaryAction, + backTo, + shouldRenderActionsInRow, +}: MoneyReportHeaderMoreContentBodyProps) { const styles = useThemeStyles(); - const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const shouldDisplayNarrowVersion = shouldUseNarrowLayout || isMediumScreenWidth; - const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); - const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const reportID = moneyRequestReport?.reportID; const {iouTransactionID} = useMoneyReportTransactionThread(); return ( - + {shouldShowNextStep && } - {isReportInSearch && ( - )} diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 0dadca3f4edf..884fb36c56fd 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -102,6 +102,7 @@ function MoneyRequestHeader({reportID: reportIDProp, onBackButtonPress}: MoneyRe const shouldDisplayTransactionNavigation = !!(reportID && isReportInRHP); const shouldOpenParentReportInCurrentTab = !isSelfDM(parentReport); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine() && (wideRHPRouteKeys.length === 0 || isSmallScreenWidth); + const shouldDisplayNarrowVersion = shouldDisplayButtonsInSeparateLine; const getStatusIcon: (src: IconAsset) => ReactNode = (src) => ( - {!shouldDisplayButtonsInSeparateLine && ( + {!shouldDisplayButtonsInSeparateLine && !statusBarProps && ( )} @@ -199,11 +201,19 @@ function MoneyRequestHeader({reportID: reportIDProp, onBackButtonPress}: MoneyRe /> )} {!!statusBarProps && ( - - + + + + + {!shouldDisplayButtonsInSeparateLine && ( + + )} )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index d3506a09dd0f..4299d1b9890d 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -3,6 +3,7 @@ import {useSearchResultsContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import useFilterPendingDeleteReports from '@hooks/useFilterPendingDeleteReports'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchSections from '@hooks/useSearchSections'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -21,6 +22,7 @@ import type LastSearchParams from '@src/types/onyx/ReportNavigation'; import type {OnyxEntry} from 'react-native-onyx'; +import {useIsFocused} from '@react-navigation/native'; import React, {startTransition, useEffect, useState} from 'react'; import {View} from 'react-native'; @@ -113,6 +115,8 @@ function MoneyRequestReportNavigationStandalone({onReportsChange}: MoneyRequestR function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersion, contextReports}: MoneyRequestReportNavigationContentProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); + const isFocused = useIsFocused(); // Lightweight subscriptions only: the current search query and its loading flag. These never mount // the heavy useSearchSections subscription set, so the fast context path stays cheap. @@ -151,7 +155,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi const shouldDisplayNavigationArrows = effectiveAllReports.length > 1 && currentIndex !== -1 && !!lastSearchQuery?.queryJSON; useEffect(() => { - if (!lastSearchQuery?.queryJSON) { + if (!isFocused || !lastSearchQuery?.queryJSON) { return; } @@ -181,7 +185,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi ...lastSearchQuery, previousLengthOfResults: effectiveAllReports.length, }); - }, [currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); + }, [isFocused, currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); const goToReportId = (reportId?: string) => { if (!reportId) { @@ -242,7 +246,11 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi {!shouldUseContextReports && } {shouldDisplayNavigationArrows && ( - {!shouldDisplayNarrowVersion && {`${currentIndex + 1} of ${allReportsCount}`}} + {!shouldDisplayNarrowVersion && ( + + {translate('common.currentOfTotal', {current: currentIndex + 1, total: allReportsCount})} + + )} group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID)); }, [groupedTransactions, sortedTransactions, shouldGroupTransactions]); - // Primitive proxy for visualOrderTransactionIDs used as the effect dependency below. - // Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) can write to the same - // Onyx key with a different ordering. Using the raw array reference would cause the effect - // to re-fire on every referential change and overwrite those IDs. The joined string ensures - // the effect only re-fires when the actual content changes. + // Order-sensitive proxy for visualOrderTransactionIDs used as the effect dependency below. It must stay + // order-sensitive: changing the report's sorting/grouping (without changing which transactions are present) + // reorders the list, and the carousel needs to be re-seeded so its counter and prev/next buttons match the + // new visual order. The active-list checks in the effect still prevent unrelated carousels from being overwritten. const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.join(','), [visualOrderTransactionIDs]); + const [latestActiveTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + useEffect(() => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { return; } + + const anchorTransactionID = (focusedRoute?.params as {anchorTransactionID?: string} | undefined)?.anchorTransactionID; + if (anchorTransactionID && latestActiveTransactionIDs?.includes(anchorTransactionID)) { + return; + } // Don't take over a snapshot-backed carousel (identified by its sibling descriptors, e.g. the Home // "Recently added" flow) that belongs to the transaction thread sitting underneath this report. // Overwriting and then clearing it would drop that carousel when the user navigates back. Row presses @@ -596,11 +602,23 @@ function MoneyRequestReportTransactionList({ if (getActiveTransactionIDs().descriptors) { return; } + + if (visualOrderTransactionIDs.length < 2) { + return; + } + + if ( + latestActiveTransactionIDs && + latestActiveTransactionIDs.length >= visualOrderTransactionIDs.length && + visualOrderTransactionIDs.every((id) => latestActiveTransactionIDs.includes(id)) + ) { + return; + } setActiveTransactionIDs(visualOrderTransactionIDs); return () => { clearActiveTransactionIDs(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is a primitive proxy for the array to avoid re-firing on referential-only changes + // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is an order-sensitive proxy for the array, and we intentionally don't depend on latestActiveTransactionIDs to avoid re-firing when the carousel changes elsewhere }, [visualOrderTransactionIDsKey]); const groupSelectionState = useMemo(() => { @@ -658,6 +676,7 @@ function MoneyRequestReportTransactionList({ report, transaction: sortedTransactions.find((t) => t.transactionID === activeTransactionID), siblingTransactionIDs: visualOrderTransactionIDs, + shouldPreserveBroaderCarousel: true, }); }, [navigateToTransactionThread, reportActions, sortedTransactions, report, visualOrderTransactionIDs], diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index 42731e90c0d7..5451fbda5875 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -1,22 +1,29 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PrevNextButtons from '@components/PrevNextButtons'; +import Text from '@components/Text'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {clearActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {isOneTransactionReport} from '@libs/ReportUtils'; import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; import Navigation from '@navigation/Navigation'; import navigationRef from '@navigation/navigationRef'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; +import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@src/selectors/Onboarding'; import type * as OnyxTypes from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {GestureResponderEvent} from 'react-native'; @@ -24,41 +31,61 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {findFocusedRoute} from '@react-navigation/native'; import React, {startTransition, useCallback, useEffect, useMemo} from 'react'; +import {View} from 'react-native'; + +const CAROUSEL_PRESERVING_SCREENS = [ + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW, +] as const; type MoneyRequestReportRHPNavigationButtonsProps = { currentTransactionID: string; isFromReviewDuplicates?: boolean; + shouldDisplayNarrowVersion?: boolean; }; -const parentReportActionIDsSelector = (reportActions: OnyxEntry) => { - const parentActions = new Map(); +const collectParentReportActions = (reportActions: OnyxEntry, parentActions: Record) => { for (const action of Object.values(reportActions ?? {})) { const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; if (!transactionID) { continue; } - parentActions.set(transactionID, action); + // eslint-disable-next-line no-param-reassign -- intentionally mutates the shared accumulator so callers can build the map in a single pass across multiple report-action sources + parentActions[transactionID] = action; } - return parentActions; }; -function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromReviewDuplicates}: MoneyRequestReportRHPNavigationButtonsProps) { +function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromReviewDuplicates, shouldDisplayNarrowVersion}: MoneyRequestReportRHPNavigationButtonsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); const [transactionIDsList = getEmptyArray()] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + // When the carousel is opened from a search (e.g. the Spend page), the sibling transactions may only exist + // in the search snapshot and not in the live collection yet. We keep the snapshot around to fall back to it + // so prev/next navigation resolves the correct report instead of breaking. + const [snapshotHash] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH); + const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}`); + // Snapshot-backed flows (e.g. Home "Recently added") seed a descriptor per sibling so the carousel can + // resolve (and lazily create) each sibling's thread on demand even when the sibling isn't in the live collection. const [siblingDescriptorsByTransactionID] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS); + const {markReportRHPWidth} = useWideRHPActions(); + // Values required to create a transaction thread on the fly when paging onto a multi-transaction + // (batched) parent report that has no existing thread yet (see onNext/onPrevious fallbacks). + const {accountID, email} = useCurrentUserPersonalDetails(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const personalDetails = usePersonalDetails(); - const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - const {markReportRHPWidth} = useWideRHPActions(); + const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); const {prevTransactionID, nextTransactionID} = useMemo(() => { if (!transactionIDsList || transactionIDsList.length < 2) { return {prevTransactionID: undefined, nextTransactionID: undefined}; } - const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); - const prevID = currentTransactionIndex > 0 ? transactionIDsList.at(currentTransactionIndex - 1) : undefined; const nextID = transactionIDsList.at(currentTransactionIndex + 1); @@ -66,12 +93,15 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR prevTransactionID: prevID, nextTransactionID: nextID, }; - }, [currentTransactionID, transactionIDsList]); + }, [currentTransactionIndex, transactionIDsList]); const prevNextTransactionsSelector = useCallback( (allTransactions: OnyxCollection) => - [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), - [currentTransactionID, nextTransactionID, prevTransactionID], + [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => { + const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; + return allTransactions?.[key] ?? snapshot?.data?.[key]; + }), + [currentTransactionID, nextTransactionID, prevTransactionID, snapshot], ); const [[currentTransaction, prevTransaction, nextTransaction] = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { @@ -80,40 +110,64 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR const parentReportActionsSelector = useCallback( (allReportActions: OnyxCollection) => { - let reportActions = {}; + const parentActions: Record = {}; for (const transaction of [currentTransaction, prevTransaction, nextTransaction]) { - reportActions = { - ...reportActions, - ...allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`], - }; + const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}` as const; + collectParentReportActions(allReportActions?.[key] ?? (snapshot?.data?.[key] as OnyxTypes.ReportActions | undefined), parentActions); } - return parentReportActionIDsSelector(reportActions); + return parentActions; }, - [currentTransaction, nextTransaction, prevTransaction], + [currentTransaction, nextTransaction, prevTransaction, snapshot], ); - const [parentReportActions = new Map()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + const [reportedParentReportActions = getEmptyObject>()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { selector: parentReportActionsSelector, }); + const snapshotData = snapshot?.data; + const snapshotParentReportActions = useMemo(() => { + const parentActions: Record = {}; + if (snapshotData) { + for (const [key, reportActionsForReport] of Object.entries(snapshotData)) { + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)) { + collectParentReportActions(reportActionsForReport as OnyxTypes.ReportActions, parentActions); + } + } + } + return parentActions; + }, [snapshotData]); + + // Live report actions win over the snapshot: the snapshot only fills in transactionIDs the live pass couldn't + // resolve (i.e. unreported ones). A search snapshot is a point-in-time copy, so for a reported transaction it can + // hold an older copy of the same IOU action — e.g. one still missing the childReportID of a thread that has since + // been created. Letting that stale copy win would make prev/next believe the sibling has no thread and create a + // duplicate one instead of navigating to the existing thread. + const parentReportActions = useMemo(() => ({...snapshotParentReportActions, ...reportedParentReportActions}), [reportedParentReportActions, snapshotParentReportActions]); + const {prevParentReportAction, nextParentReportAction} = useMemo(() => { if (!transactionIDsList || transactionIDsList.length < 2) { - return { - prevParentReportAction: undefined, - nextParentReportAction: undefined, - }; + return {prevParentReportAction: undefined, nextParentReportAction: undefined}; } return { - prevParentReportAction: prevTransactionID ? parentReportActions.get(prevTransactionID) : undefined, - nextParentReportAction: nextTransactionID ? parentReportActions.get(nextTransactionID) : undefined, + prevParentReportAction: prevTransactionID ? parentReportActions[prevTransactionID] : undefined, + nextParentReportAction: nextTransactionID ? parentReportActions[nextTransactionID] : undefined, }; }, [nextTransactionID, parentReportActions, prevTransactionID, transactionIDsList]); - const [prevParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevTransaction?.reportID}`); - const [nextParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextTransaction?.reportID}`); - const [prevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); - const [nextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); + const prevParentReportID = prevParentReportAction?.reportID ?? prevTransaction?.reportID; + const nextParentReportID = nextParentReportAction?.reportID ?? nextTransaction?.reportID; + + const [livePrevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); + const [liveNextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); + const [livePrevTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportID}`); + const [liveNextTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportID}`); + + // Fall back to the search snapshot for reports that aren't in the live collection yet. + const prevThreadReport = livePrevThreadReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`]; + const nextThreadReport = liveNextThreadReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`]; + const prevTransactionParentReport = livePrevTransactionParentReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportID}`]; + const nextTransactionParentReport = liveNextTransactionParentReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportID}`]; /** * We clear the sibling transactionThreadIDs when unmounting this component @@ -122,7 +176,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR useEffect(() => { return () => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); - if (focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || focusedRoute?.name === SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW) { + if (focusedRoute?.name && (CAROUSEL_PRESERVING_SCREENS as readonly string[]).includes(focusedRoute.name)) { return; } clearActiveTransactionIDs(); @@ -133,15 +187,26 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR return; } - const onNext = (e: GestureResponderEvent | KeyboardEvent | undefined) => { - e?.preventDefault(); - + const getBackTo = () => { let backTo = Navigation.getActiveRoute(); if (isFromReviewDuplicates) { const currentRoute = navigationRef.getCurrentRoute(); const params = currentRoute?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined; backTo = params?.backTo ?? backTo; } + return backTo; + }; + + const onNext = (e: GestureResponderEvent | KeyboardEvent | undefined) => { + e?.preventDefault(); + const backTo = getBackTo(); + + if (isOneTransactionReport(nextTransactionParentReport) && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + const targetReportID = nextTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}))); + return; + } // Snapshot-backed flows (e.g. Home "Recently added") seed a descriptor per sibling because the sibling // transactions may be absent from the main Onyx collections. Resolve the target sibling lazily here so @@ -149,138 +214,116 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // hydrate it on arrival. const nextDescriptor = nextTransactionID ? siblingDescriptorsByTransactionID?.[nextTransactionID] : undefined; if (nextDescriptor) { - requestAnimationFrame(() => { - const nextReportID = getReportIDToOpenForExpense(nextDescriptor, { - introSelected, - betas, - currentUserEmail, - currentUserAccountID, - personalDetails, - }); - markReportRHPWidth(nextReportID, 'wide'); - requestAnimationFrame(() => - startTransition(() => - Navigation.setParams({ - reportID: nextReportID, - reportActionID: undefined, - backTo, - }), - ), - ); - }); + const nextReportID = getReportIDToOpenForExpense(nextDescriptor, {introSelected, betas, currentUserEmail: email, currentUserAccountID: accountID, personalDetails}); + markReportRHPWidth(nextReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: nextReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}))); return; } const nextThreadReportID = nextParentReportAction?.childReportID; - const navigationParams = { - reportID: nextThreadReportID, - reportActionID: undefined, - backTo, - }; + const navigationParams = {reportID: nextThreadReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}; + + if (!nextThreadReportID && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: email ?? '', + currentUserAccountID: accountID, + betas, + iouReport: nextTransactionParentReport, + iouReportAction: nextParentReportAction, + transaction: nextTransaction, + personalDetails, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + }); + const targetReportID = optimisticThread?.reportID ?? nextTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}))); + return; + } - requestAnimationFrame(() => { - if (nextThreadReportID) { - markReportRHPWidth(nextThreadReportID, 'wide'); - } - // We know that the next thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. - if (!nextThreadReport && nextThreadReportID) { - setOptimisticTransactionThread(nextThreadReportID, nextParentReport?.reportID, nextParentReportAction?.reportActionID, nextParentReport?.policyID); - } - // The transaction thread doesn't exist yet, so we should create it - if (!nextThreadReportID) { - const transactionThreadReport = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserEmail ?? '', - currentUserAccountID, - betas, - iouReport: nextParentReport, - iouReportAction: nextParentReportAction, - transaction: nextTransaction, - personalDetails, - }); - navigationParams.reportID = transactionThreadReport?.reportID; - } - // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread or createTransactionThreadReport before navigating - requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); - }); + if (nextThreadReportID) { + markReportRHPWidth(nextThreadReportID, 'wide'); + } + + if (!nextThreadReport && nextThreadReportID) { + setOptimisticTransactionThread(nextThreadReportID, nextTransactionParentReport?.reportID, nextParentReportAction?.reportActionID, nextTransactionParentReport?.policyID); + } + // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread before navigating + requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); }; const onPrevious = (e: GestureResponderEvent | KeyboardEvent | undefined) => { e?.preventDefault(); + const backTo = getBackTo(); - let backTo = Navigation.getActiveRoute(); - if (isFromReviewDuplicates) { - const currentRoute = navigationRef.getCurrentRoute(); - const params = currentRoute?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined; - backTo = params?.backTo ?? backTo; + // See onNext for the rationale behind the one-transaction-parent branch (and the unreported skip). + if (isOneTransactionReport(prevTransactionParentReport) && prevTransaction?.reportID && prevTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + const targetReportID = prevTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}))); + return; } // See onNext: resolve the target sibling lazily from its descriptor when present. const prevDescriptor = prevTransactionID ? siblingDescriptorsByTransactionID?.[prevTransactionID] : undefined; if (prevDescriptor) { - requestAnimationFrame(() => { - const prevReportID = getReportIDToOpenForExpense(prevDescriptor, { - introSelected, - betas, - currentUserEmail, - currentUserAccountID, - personalDetails, - }); - markReportRHPWidth(prevReportID, 'wide'); - requestAnimationFrame(() => - startTransition(() => - Navigation.setParams({ - reportID: prevReportID, - reportActionID: undefined, - backTo, - }), - ), - ); - }); + const prevReportID = getReportIDToOpenForExpense(prevDescriptor, {introSelected, betas, currentUserEmail: email, currentUserAccountID: accountID, personalDetails}); + markReportRHPWidth(prevReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: prevReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}))); return; } const prevThreadReportID = prevParentReportAction?.childReportID; - const navigationParams = { - reportID: prevThreadReportID, - reportActionID: undefined, - backTo, - }; + const navigationParams = {reportID: prevThreadReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}; + + // See onNext for the rationale: the parent here is a MULTI-transaction (batched) report, so create the + // transaction thread to land on a single-expense view instead of navigating to the whole parent report. + if (!prevThreadReportID && prevTransaction?.reportID && prevTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + const optimisticThread = createTransactionThreadReport({ + introSelected, + currentUserLogin: email ?? '', + currentUserAccountID: accountID, + betas, + iouReport: prevTransactionParentReport, + iouReportAction: prevParentReportAction, + transaction: prevTransaction, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + personalDetails, + }); + const targetReportID = optimisticThread?.reportID ?? prevTransaction.reportID; + markReportRHPWidth(targetReportID, 'wide'); + requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}))); + return; + } - requestAnimationFrame(() => { - if (prevThreadReportID) { - markReportRHPWidth(prevThreadReportID, 'wide'); - } - // We know that the previous thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. - if (!prevThreadReport && prevThreadReportID) { - setOptimisticTransactionThread(prevThreadReportID, prevParentReport?.reportID, prevParentReportAction?.reportActionID, prevParentReport?.policyID); - } - // The transaction thread doesn't exist yet, so we should create it - if (!prevThreadReportID) { - const transactionThreadReport = createTransactionThreadReport({ - introSelected, - currentUserLogin: currentUserEmail ?? '', - currentUserAccountID, - betas, - iouReport: prevParentReport, - iouReportAction: prevParentReportAction, - transaction: prevTransaction, - personalDetails, - }); - navigationParams.reportID = transactionThreadReport?.reportID; - } - // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread or createTransactionThreadReport before navigating - requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); - }); + if (prevThreadReportID) { + markReportRHPWidth(prevThreadReportID, 'wide'); + } + // See onNext for the rationale: use prevTransactionParentReport (the PREV transaction's own parent) + // instead of parentReport (the CURRENT transaction's parent) so the optimistic linkage matches the server. + if (!prevThreadReport && prevThreadReportID) { + setOptimisticTransactionThread(prevThreadReportID, prevTransactionParentReport?.reportID, prevParentReportAction?.reportActionID, prevTransactionParentReport?.policyID); + } + // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread before navigating + requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); }; return ( - + + {!shouldDisplayNarrowVersion && currentTransactionIndex !== -1 && ( + + {translate('common.currentOfTotal', {current: currentTransactionIndex + 1, total: transactionIDsList.length})} + + )} + + ); } diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index 659076764ea7..c212eb57c94a 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -235,6 +235,17 @@ function getSelectedItemIndex(menuItems: PopoverMenuItem[]) { return menuItems.findIndex((option) => option.isSelected); } +function getAvailableHeightForAnchor(anchorVertical: number, verticalAlignment: AnchorAlignment['vertical'], windowHeight: number): number { + if (verticalAlignment === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { + return windowHeight - anchorVertical; + } + if (verticalAlignment === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM) { + return anchorVertical; + } + // CENTER alignment grows in both directions from the anchor, so the closer window edge bounds it. + return Math.min(anchorVertical, windowHeight - anchorVertical) * 2; +} + /** * Return a stable string key for a menu item. * Prefers explicit `key` property on the item. If missing, falls back to `text`. @@ -635,11 +646,23 @@ function BasePopoverMenu({ const stylesArray: ViewStyle[] = [StyleSheet.flatten(styles.createMenuContainer), {width: variables.compactPopoverMenuWidth}, styles.pv2]; if (shouldUseScrollView && shouldEnableMaxHeight && !isInLandscapeMode) { - stylesArray.push({maxHeight: Math.max(windowHeight - variables.compactPopoverMenuVerticalMargin, CONST.POPOVER_MENU_MAX_HEIGHT)}); + const availableHeight = getAvailableHeightForAnchor(anchorPosition.vertical, anchorAlignment.vertical, windowHeight) - variables.compactPopoverMenuVerticalMargin; + const minHeight = Math.min(CONST.POPOVER_MENU_MAX_HEIGHT, windowHeight - variables.compactPopoverMenuVerticalMargin); + stylesArray.push({maxHeight: Math.max(availableHeight, minHeight)}); } return stylesArray; - }, [isSmallScreenWidth, shouldEnableMaxHeight, styles.createMenuContainer, styles.pv2, shouldUseScrollView, windowHeight, isInLandscapeMode]); + }, [ + isSmallScreenWidth, + shouldEnableMaxHeight, + styles.createMenuContainer, + styles.pv2, + shouldUseScrollView, + windowHeight, + isInLandscapeMode, + anchorPosition.vertical, + anchorAlignment.vertical, + ]); const {paddingTop, paddingBottom, paddingVertical, ...restScrollContainerStyle} = (StyleSheet.flatten([isSmallScreenWidth ? styles.pv4 : styles.pv2, scrollContainerStyle]) as ViewStyle) ?? {}; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index ec5c30274425..2edb95bfe187 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -256,12 +256,12 @@ function TransactionGroupListExpandedImpl({ // When opening the transaction thread in RHP we need to find every other ID for the rest of transactions // to display prev/next arrows in RHP for navigation if (isModifiedMousePress(event)) { - setActiveTransactionIDs(siblingTransactionIDs); + setActiveTransactionIDs(siblingTransactionIDs, transactionsQueryJSON?.hash); navigateToTransactionThread(); return; } - setActiveTransactionIDs(siblingTransactionIDs).then(navigateToTransactionThread); + setActiveTransactionIDs(siblingTransactionIDs, transactionsQueryJSON?.hash).then(navigateToTransactionThread); }; const onShowMoreButtonPress = () => { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 637add171c79..b15939b30992 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -25,6 +25,7 @@ import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {saveLastSearchParams} from '@libs/actions/ReportNavigation'; import type {TransactionPreviewData} from '@libs/actions/Search'; import {setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; +import {clearActiveTransactionIDs, setActiveTransactionIDs, shouldWriteActiveTransactionIDsForSearch} from '@libs/actions/TransactionThreadNavigation'; import {flushDeferredWrite, hasDeferredWrite} from '@libs/deferredLayoutWrite'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; @@ -62,7 +63,7 @@ import { getNavigateToReportsSpans, } from '@libs/telemetry/navigateToReportsSpans'; import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; -import {isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; +import {isDeletedTransaction, isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; @@ -160,12 +161,8 @@ function Search({ const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasSeenTourSelector, - }); - const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasCompletedGuidedSetupFlowSelector, - }); + const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const previousTransactions = usePrevious(transactions); const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const {accountID, email} = useCurrentUserPersonalDetails(); @@ -260,13 +257,7 @@ function Search({ hasPendingWriteOnMountRef, skipDeferralOnFocusRef, rearmTracking, - } = useSearchSnapshot({ - queryJSON, - searchResults, - newSearchResultKeys, - transactions, - reportActions, - }); + } = useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}); // Mirror `hasQueuedHighlights` into a ref so the post-create-flow `useFocusEffect` // (which has empty deps) can read the latest value without re-creating its callback. @@ -382,14 +373,7 @@ function Search({ return; } - Log.info('[Search] Showing skeleton', false, { - isOffline, - isDataLoaded, - isCardFeedsLoading, - isSearchLoading: !!searchResults?.search?.isLoading, - hasErrors, - shouldUseLiveData, - }); + Log.info('[Search] Showing skeleton', false, {isOffline, isDataLoaded, isCardFeedsLoading, isSearchLoading: !!searchResults?.search?.isLoading, hasErrors, shouldUseLiveData}); }, [hasErrors, isCardFeedsLoading, isDataLoaded, isOffline, searchResults?.search?.isLoading, shouldShowLoadingState, shouldUseLiveData]); useEffect(() => { @@ -563,6 +547,16 @@ function Search({ }, 0); }, [areItemsGrouped, filteredData]); + const carouselSiblingTransactionIDs = useMemo( + () => + (filteredData as SearchListItem[]) + .filter( + (t): t is TransactionListItemType => !!t && isTransactionListItemType(t) && t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isDeletedTransaction(t), + ) + .map((t) => t.transactionID), + [filteredData], + ); + const onSelectRow = useCallback( (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { @@ -571,6 +565,17 @@ function Search({ const isTransactionItem = isTransactionListItemType(item); const backTo = Navigation.getActiveRoute(); + + // When opening an expense from the Spend page (flat transaction list), populate the carousel + // with all sibling transactions so prev/next navigation works in the RHP transaction view. + if (isTransactionItem) { + if (carouselSiblingTransactionIDs.length > 1) { + setActiveTransactionIDs(carouselSiblingTransactionIDs, hash); + } else { + clearActiveTransactionIDs(); + } + } + // If we're trying to open a transaction without a transaction thread, let's create the thread and navigate the user if (isTransactionItem && !item?.reportAction?.childReportID) { // If the report is unreported (self DM), we want to open the track expense thread instead of a report with an ID of 0 @@ -675,10 +680,7 @@ function Search({ allowPostSearchRecount: true, }); - const route = ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({ - reportID, - backTo, - }); + const route = ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo}); if (openInternalRouteInNewTab(route, event)) { return; } @@ -693,11 +695,7 @@ function Search({ isCreatedTaskReportAction(reportActionItem) && (isOptimisticCreatedTaskAction || reportActionItem.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); const reportActionID = shouldSkipReportActionID ? undefined : reportActionItem.reportActionID; - const route = ROUTES.SEARCH_REPORT.getRoute({ - reportID, - reportActionID, - backTo, - }); + const route = ROUTES.SEARCH_REPORT.getRoute({reportID, reportActionID, backTo}); if (openInternalRouteInNewTab(route, event)) { return; } @@ -738,13 +736,30 @@ function Search({ email, accountID, queryJSON, + hash, offset, searchResults?.search?.hasMoreResults, currentSearchKey, + carouselSiblingTransactionIDs, getCurrencyDecimals, ], ); + const carouselSiblingsKey = carouselSiblingTransactionIDs.join(','); + const [activeCarouselSnapshotHash] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH); + const [activeCarouselTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); + + useEffect(() => { + if (shouldShowLoadingState) { + return; + } + if (!shouldWriteActiveTransactionIDsForSearch(activeCarouselTransactionIDs, activeCarouselSnapshotHash, hash, carouselSiblingTransactionIDs)) { + return; + } + setActiveTransactionIDs(carouselSiblingTransactionIDs, hash); + // eslint-disable-next-line react-hooks/exhaustive-deps -- carouselSiblingsKey is an order-sensitive proxy for the array, which is rebuilt on every search data change + }, [carouselSiblingsKey, activeCarouselSnapshotHash, activeCarouselTransactionIDs, hash, shouldShowLoadingState]); + // getColumnsToShow allocates a fresh array on every call; preserve the previous reference // when contents are equal so downstream consumers don't re-render on Onyx snapshot churn // (e.g. opening a report bumps searchResults.data) that doesn't actually change the columns. @@ -808,9 +823,7 @@ function Search({ const onLayoutBase = useCallback(() => { hasHadFirstLayout.current = true; onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout'); - endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { - [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, - }); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.WARM_FIRST); endNavigateToReportsContentLoad(); TransitionTracker.runAfterTransitions({ @@ -865,9 +878,7 @@ function Search({ const onLayoutChart = useCallback(() => { hasHadFirstLayout.current = true; - endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { - [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, - }); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.WARM_FIRST); endNavigateToReportsContentLoad(); }, []); @@ -956,13 +967,7 @@ function Search({ ); const amountIndicators = useMemo( - () => - searchResults?.data - ? getWideAmountIndicators(searchResults.data) - : { - shouldShowAmountInWideColumn: false, - shouldShowTaxAmountInWideColumn: false, - }, + () => (searchResults?.data ? getWideAmountIndicators(searchResults.data) : {shouldShowAmountInWideColumn: false, shouldShowTaxAmountInWideColumn: false}), [searchResults?.data], ); diff --git a/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts b/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts new file mode 100644 index 000000000000..8813fccddf5d --- /dev/null +++ b/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts @@ -0,0 +1,46 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {isGroupPolicy} from '@libs/PolicyUtils'; +import {isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; + +import type CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {ValueOf} from 'type-fest'; + +import useMoneyReportHeaderStatusBar from './useMoneyReportHeaderStatusBar'; +import useOnyx from './useOnyx'; + +type MoneyReportHeaderMoreContentVisibility = { + /** Which status bar to render below the header, if any */ + statusBarType: ValueOf | undefined; + + /** Whether the next step bar should be rendered below the header */ + shouldShowNextStep: boolean; + + /** Whether the more-content row has anything to show on its own */ + hasStatusOrNextStep: boolean; +}; + +/** + * Resolves what the money report header's more-content row (status bar / next step) will display. + * + * The header needs this before it renders, because it decides where the report actions go: they normally sit at + * the end of the more-content row so they line up with its text, but when that row has nothing to show they + * would be stranded alone under the title, and belong in the header row instead. + */ +function useMoneyReportHeaderMoreContentVisibility(reportID: string | undefined): MoneyReportHeaderMoreContentVisibility { + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const {shouldShowStatusBar, statusBarType} = useMoneyReportHeaderStatusBar(reportID, moneyRequestReport?.chatReportID); + + const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); + const shouldShowNextStep = isGroupPolicy(policy) && !isInvoiceReport && !shouldShowStatusBar; + + return { + statusBarType, + shouldShowNextStep, + hasStatusOrNextStep: shouldShowNextStep || !!statusBarType, + }; +} + +export default useMoneyReportHeaderMoreContentVisibility; diff --git a/src/hooks/useNavigateToTransactionThread.ts b/src/hooks/useNavigateToTransactionThread.ts index cc62eec3fb3b..922d1e68f325 100644 --- a/src/hooks/useNavigateToTransactionThread.ts +++ b/src/hooks/useNavigateToTransactionThread.ts @@ -2,7 +2,7 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; -import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {setActiveTransactionIDs, shouldPreserveActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import Navigation from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; @@ -31,6 +31,9 @@ type NavigateToTransactionThreadParams = { /** Ordered list of sibling transaction IDs used to drive the prev/next carousel in the thread RHP */ siblingTransactionIDs: string[]; + /** When true, keep an already-active broader carousel (e.g. the Spend page's list) instead of re-seeding it with just this report's siblings */ + shouldPreserveBroaderCarousel?: boolean; + /** Route to return to when navigating back; defaults to the current active route */ backTo?: string; }; @@ -51,7 +54,7 @@ function useNavigateToTransactionThread() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, backTo}: NavigateToTransactionThreadParams) => { + return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, shouldPreserveBroaderCarousel = false, backTo}: NavigateToTransactionThreadParams) => { const iouAction = getIOUActionForTransactionID(reportActions, transactionID); const resolvedBackTo = backTo ?? Navigation.getActiveRoute(); let reportIDToNavigate = iouAction?.childReportID; @@ -81,8 +84,12 @@ function useNavigateToTransactionThread() { } // Single transaction report opens in RHP. We seed every sibling transaction ID so the RHP can - // display prev/next arrows for navigation between expenses. - setActiveTransactionIDs(siblingTransactionIDs).then(() => { + // display prev/next arrows for navigation between expenses. A broader carousel the user drilled in from is + // left untouched, so its list (and the snapshot hash backing prev/next) survive navigating back out to it. + const seedCarousel = + shouldPreserveBroaderCarousel && shouldPreserveActiveTransactionIDs(siblingTransactionIDs, transactionID) ? Promise.resolve() : setActiveTransactionIDs(siblingTransactionIDs); + + seedCarousel.then(() => { if (reportIDToNavigate) { markReportRHPWidth(reportIDToNavigate, 'wide'); } diff --git a/src/languages/de.ts b/src/languages/de.ts index 9dc76a1e4123..4d8c972839c2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -487,6 +487,7 @@ const translations: TranslationDeepObject = { previousYear: 'Vorheriges Jahr', nextYear: 'Nächstes Jahr', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} von ${total}`, editor: 'Editor', restrictions: 'Beschränkungen', tryAgain: 'Erneut versuchen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 6a92a3741f69..0773035dd2f5 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -318,6 +318,7 @@ const translations: TranslationDeepObject = { automatic: 'Αυτόματο', showing: 'Εμφανίζονται', of: 'του', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} από ${total}`, default: 'Προεπιλογή', update: 'Ενημέρωση', member: 'Μέλος', @@ -1133,10 +1134,7 @@ const translations: TranslationDeepObject = { }), today: 'Σήμερα', }, - insightsSection: { - chartUnavailable: 'Το γράφημα δεν είναι διαθέσιμο', - notEnoughData: 'Δεν έχουμε ακόμη αρκετά δεδομένα για να συμπληρώσουμε αυτό το γράφημα', - }, + insightsSection: {chartUnavailable: 'Το γράφημα δεν είναι διαθέσιμο', notEnoughData: 'Δεν έχουμε ακόμη αρκετά δεδομένα για να συμπληρώσουμε αυτό το γράφημα'}, }, allSettingsScreen: { subscription: 'Συνδρομή', diff --git a/src/languages/en.ts b/src/languages/en.ts index 2f236444964a..bcdc47a4e7c2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -345,6 +345,8 @@ const translations = { automatic: 'Automatic', showing: 'Showing', of: 'of', + // @context Carousel pagination counter showing the current item's position out of the total (e.g. "3 of 50"). + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} of ${total}`, default: 'Default', update: 'Update', member: 'Member', diff --git a/src/languages/es.ts b/src/languages/es.ts index dccbd68d1f27..8e1014f9cb92 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -472,6 +472,7 @@ const translations: TranslationDeepObject = { goToConcierge: 'Ir a Concierge', allSet: '¡Todo listo!', enterDigitLabel: ({digitIndex, totalDigits}: {digitIndex: number; totalDigits: number}) => `introducir dígito ${digitIndex} de ${totalDigits}`, + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, apiKey: 'Clave API', editor: 'Editor', restrictions: 'Restricciones', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 09a9ff5a7be5..50b32eaa573b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -487,6 +487,7 @@ const translations: TranslationDeepObject = { previousYear: 'Année précédente', nextYear: 'L’an prochain', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} sur ${total}`, editor: 'Éditeur', restrictions: 'Restrictions', tryAgain: 'Réessayer', diff --git a/src/languages/it.ts b/src/languages/it.ts index c4b872f2159e..35978ecb5882 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -487,6 +487,7 @@ const translations: TranslationDeepObject = { previousYear: 'Anno precedente', nextYear: "L'anno prossimo", avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} di ${total}`, editor: 'Editor', restrictions: 'Restrizioni', tryAgain: 'Riprova', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index adaccf059f0b..b9f108613603 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -486,6 +486,7 @@ const translations: TranslationDeepObject = { previousYear: '前年', nextYear: '来年', avatar: 'アバター', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${total} 件中 ${current} 件目`, editor: '編集者', restrictions: '制限', tryAgain: '再試行', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 40398c2fe6c8..6f80af47d8c6 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -486,6 +486,7 @@ const translations: TranslationDeepObject = { previousYear: 'Vorig jaar', nextYear: 'Volgend jaar', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} van ${total}`, editor: 'Editor', restrictions: 'Beperkingen', tryAgain: 'Probeer het opnieuw', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 38a4dae9bad2..b3caedf45fee 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -486,6 +486,7 @@ const translations: TranslationDeepObject = { previousYear: 'Poprzedni rok', nextYear: 'W przyszłym roku', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} z ${total}`, editor: 'Edytor', restrictions: 'Ograniczenia', tryAgain: 'Spróbuj ponownie', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index dcad147ca7d9..65e775748405 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -485,6 +485,7 @@ const translations: TranslationDeepObject = { previousYear: 'Ano anterior', nextYear: 'Ano que vem', avatar: 'Avatar', + currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, editor: 'Editor', restrictions: 'Restrições', tryAgain: 'Tentar novamente', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d53d3e5603d2..cbcb0beaa20c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -482,6 +482,7 @@ const translations: TranslationDeepObject = { previousYear: '上一年', nextYear: '明年', avatar: '头像', + currentOfTotal: ({current, total}: {current: number; total: number}) => `第 ${current} 项(共 ${total} 项)`, editor: '编辑', restrictions: '限制', tryAgain: '重试', diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 6464d6eff450..141063734e14 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -314,6 +314,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, + ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_INVOICE_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3cef807e1afb..aef2dc1f90ad 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2759,6 +2759,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.SEARCH_REPORT]: { reportID: string; reportActionID?: string; + anchorTransactionID?: string; shouldReplaceWithExpenseReportRHP?: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 4e87112d3579..964956e1c1ed 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -18,6 +18,7 @@ import Onyx from 'react-native-onyx'; */ let lastSetIDs: string[] | null = null; +let lastSetSnapshotHash: number | null = null; let lastSetDescriptors: Record | null = null; function areDescriptorMapsEqual(a: Record | null, b: Record | null) { @@ -45,19 +46,25 @@ function areDescriptorMapsEqual(a: Record) { +function setActiveTransactionIDs(ids: string[], snapshotHash?: number, siblingDescriptorsByTransactionID?: Record) { + const nextSnapshotHash = snapshotHash ?? null; const nextDescriptors = siblingDescriptorsByTransactionID ?? null; - const sameIDs = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); - if (sameIDs && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors)) { + const areIDsUnchanged = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); + if (areIDsUnchanged && lastSetSnapshotHash === nextSnapshotHash && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors)) { return Promise.resolve(); } lastSetIDs = ids; + lastSetSnapshotHash = nextSnapshotHash; lastSetDescriptors = nextDescriptors; - return Promise.all([Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ids), Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, nextDescriptors)]); + return Promise.all([ + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ids), + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, nextSnapshotHash), + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, nextDescriptors), + ]); } /** @@ -69,10 +76,42 @@ function getActiveTransactionIDs(): {ids: string[] | null; descriptors: Record 1; + } + if (activeSnapshotHash !== searchSnapshotHash) { + return false; + } + const isUpToDate = activeIDs.length === searchTransactionIDs.length && activeIDs.every((id, index) => id === searchTransactionIDs.at(index)); + return !isUpToDate; +} + +function shouldPreserveActiveTransactionIDs(candidateIDs: string[], anchorTransactionID: string): boolean { + const activeIDs = lastSetIDs; + if (!activeIDs?.includes(anchorTransactionID)) { + return false; + } + return activeIDs.length > candidateIDs.length && candidateIDs.every((id) => activeIDs.includes(id)); +} + function clearActiveTransactionIDs() { lastSetIDs = null; + lastSetSnapshotHash = null; lastSetDescriptors = null; - return Promise.all([Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, null), Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, null)]); + return Promise.all([ + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, null), + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, null), + Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, null), + ]); } -export {setActiveTransactionIDs, clearActiveTransactionIDs, getActiveTransactionIDs}; +export {setActiveTransactionIDs, clearActiveTransactionIDs, getActiveTransactionIDs, shouldWriteActiveTransactionIDsForSearch, shouldPreserveActiveTransactionIDs}; diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx index 422b94cb9af9..0036cd4a86a1 100644 --- a/src/pages/home/RecentlyAddedSection/index.tsx +++ b/src/pages/home/RecentlyAddedSection/index.tsx @@ -89,7 +89,7 @@ function RecentlyAddedSection() { // Each row opens a single-expense view that always lands in (Wide) RHP on both layouts so the carousel // arrows are available. Marking the report as an expense lets the RHP open wide immediately, before its // data loads, instead of flickering from narrow to wide. - setActiveTransactionIDs(siblingTransactionIDs, siblingDescriptorsByTransactionID).then(() => { + setActiveTransactionIDs(siblingTransactionIDs, undefined, siblingDescriptorsByTransactionID).then(() => { markReportRHPWidth(reportID, 'wide'); Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo: ROUTES.HOME})); }); diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index 139219be3ed1..02ba70f04a15 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -171,15 +171,11 @@ function ReportNavigateAwayHandler() { const didReportClose = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; const isTopLevelPolicyRoomWithNoStatus = !report?.statusNum && !prevReport?.parentReportID && prevReport?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ROOM; const isClosedTopLevelPolicyRoom = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && isTopLevelPolicyRoomWithNoStatus; + const userLeavingTriggered = !prevUserLeavingStatus && !!userLeavingStatus; + const deletedParentTriggered = prevDeletedParentAction && !deletedParentAction; + const shouldTrigger = userLeavingTriggered || didReportClose || isRemovalExpectedForReportType || isClosedTopLevelPolicyRoom || deletedParentTriggered; // Navigate to the Concierge chat if the room was removed from another device (e.g. user leaving a room or removed from a room) - if ( - // non-optimistic case - (!prevUserLeavingStatus && !!userLeavingStatus) || - didReportClose || - isRemovalExpectedForReportType || - isClosedTopLevelPolicyRoom || - (prevDeletedParentAction && !deletedParentAction) - ) { + if (shouldTrigger) { navigateAwayFromReport(prevOnyxReportID, prevReport?.parentReportID); } }, [ diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index 216a5e90c82d..4e8e41c484cc 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -92,6 +92,10 @@ export default { minWidth: 8, }, + mnw8: { + minWidth: 32, + }, + mnw25: { minWidth: '25%', }, diff --git a/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx b/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx new file mode 100644 index 000000000000..d5cd4e8ea801 --- /dev/null +++ b/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx @@ -0,0 +1,315 @@ +import {render} from '@testing-library/react-native'; + +import MoneyReportHeader from '@components/MoneyReportHeader'; +import MoneyReportHeaderActions from '@components/MoneyReportHeaderActions'; +import MoneyReportHeaderMoreContent from '@components/MoneyReportHeaderMoreContent'; + +import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; +import useOnyx from '@hooks/useOnyx'; +import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Report} from '@src/types/onyx'; + +import type * as ReactNavigationNative from '@react-navigation/native'; + +import {useRoute} from '@react-navigation/native'; +import React from 'react'; + +const REPORT_ID = '1001'; +const CHAT_REPORT_ID = '2001'; + +const HEADER_ROW_TEST_ID = 'header-row'; +const ACTIONS_TEST_ID = 'header-actions'; +const REPORT_CAROUSEL_TEST_ID = 'report-carousel'; +const TRANSACTIONS_CAROUSEL_TEST_ID = 'transactions-carousel'; + +const PARENT_REPORT_ID = '3001'; +const PARENT_ACTION_ID = 'parentAction1'; +const THREAD_TRANSACTION_ID = 'thread-tx-1'; + +/** A 1:1 DM IOU report: `isDM` is true for its chat report, and there is no next step or status bar to show. */ +const iouReport = {reportID: REPORT_ID, chatReportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.IOU} as Report; +const dmChatReport = {reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT} as Report; + +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +jest.mock('@hooks/useReportPrimaryAction', () => jest.fn()); +jest.mock('@hooks/useMoneyReportHeaderMoreContentVisibility', () => jest.fn()); +jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => jest.fn(() => ({isWideRHPDisplayedOnWideLayout: false, isSuperWideRHPDisplayedOnWideLayout: false}))); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useMobileSelectionMode', () => jest.fn(() => false)); +jest.mock('@hooks/useTransactionsAndViolationsForReport', () => jest.fn(() => ({transactions: {}, violations: {}}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); + +// useThemeStyles throws without a ; return a proxy that yields an empty style object +// for any key so the (mostly-mocked) tree renders without wiring up the full provider stack. +jest.mock('@hooks/useThemeStyles', () => { + const styleProxy = new Proxy({}, {get: () => ({})}); + return jest.fn(() => styleProxy); +}); + +// Only `useRoute` is stubbed: the rest of the module is used by the navigation imports ReportUtils pulls in. +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + __esModule: true, + useRoute: jest.fn(), +})); + +// The providers only supply context to the (mocked) leaves, so pass children straight through. +jest.mock('@components/MoneyReportHeaderModals', () => { + const reactModule = jest.requireActual('react'); + return jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)); +}); +jest.mock('@components/MoneyReportHeaderActions/ExportDownloadStatusProvider', () => { + const reactModule = jest.requireActual('react'); + return { + __esModule: true, + ExportDownloadStatusProvider: jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)), + }; +}); +jest.mock('@components/PaymentAnimationsContext', () => { + const reactModule = jest.requireActual('react'); + return { + __esModule: true, + PaymentAnimationsProvider: jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)), + }; +}); +jest.mock('@components/Search/SearchContext', () => ({ + __esModule: true, + useSearchSelectionActions: jest.fn(() => ({clearSelectedTransactions: jest.fn()})), +})); + +// HeaderWithBackButton stands in for the title row: it renders only its children, tagged so the test can +// assert what landed on the title row and in which order. +jest.mock('@components/HeaderWithBackButton', () => { + const reactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); + return jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(View, {testID: 'header-row'}, children)); +}); +jest.mock('@components/HeaderLoadingBar', () => jest.fn(() => null)); +jest.mock('@components/MoneyReportHeaderActions', () => { + const reactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); + return jest.fn(() => reactModule.createElement(View, {testID: 'header-actions'})); +}); +jest.mock('@components/MoneyReportHeaderMoreContent', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestReportView/MoneyRequestReportNavigation', () => { + const reactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); + return jest.fn(() => reactModule.createElement(View, {testID: 'report-carousel'})); +}); +jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation', () => { + const reactModule = jest.requireActual('react'); + const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); + return jest.fn(() => reactModule.createElement(View, {testID: 'transactions-carousel'})); +}); + +const mockedUseOnyx = jest.mocked(useOnyx); +const mockedUseRoute = jest.mocked(useRoute); +const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayout); +const mockedUseReportPrimaryAction = jest.mocked(useReportPrimaryAction); +const mockedMoreContentVisibility = jest.mocked(useMoneyReportHeaderMoreContentVisibility); +const mockedActions = jest.mocked(MoneyReportHeaderActions); +const mockedMoreContent = jest.mocked(MoneyReportHeaderMoreContent); + +/** Collects the tagged testIDs in render order so the test can assert left-to-right placement on the title row. */ +function getHeaderRowTestIDs(node: ReturnType['toJSON']>): string[] { + const found: string[] = []; + const walk = (current: unknown, isInsideHeaderRow: boolean) => { + if (!current || typeof current !== 'object') { + return; + } + if (Array.isArray(current)) { + for (const child of current) { + walk(child, isInsideHeaderRow); + } + return; + } + const element = current as {props?: Record; children?: unknown}; + const testID = element.props?.testID; + const isHeaderRow = testID === HEADER_ROW_TEST_ID; + if (isInsideHeaderRow && typeof testID === 'string' && testID !== HEADER_ROW_TEST_ID) { + found.push(testID); + } + walk(element.children, isInsideHeaderRow || isHeaderRow); + }; + walk(node, false); + return found; +} + +function renderHeader() { + return render( + , + ); +} + +/** + * Regression guard for https://github.com/Expensify/App/issues/98200: the report actions must sit on the title row + * whenever the more-content row would otherwise be blank, no matter whether the report was opened directly or from + * Search. Placement used to flip between the two entry points, moving the buttons onto a row of their own in Search. + */ +describe('MoneyReportHeader actions placement', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // A wide layout, so the header-row placement is in play at all. + mockedUseResponsiveLayout.mockReturnValue({ + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: false, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isInLandscapeMode: false, + }); + mockedUseReportPrimaryAction.mockReturnValue(CONST.REPORT.PRIMARY_ACTIONS.PAY); + mockedMoreContentVisibility.mockReturnValue({statusBarType: undefined, shouldShowNextStep: false, hasStatusOrNextStep: false}); + mockedUseOnyx.mockImplementation((key) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { + return [iouReport, {status: 'loaded'}]; + } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`) { + return [dmChatReport, {status: 'loaded'}]; + } + return [undefined, {status: 'loaded'}]; + }); + }); + + it.each([ + ['opened directly', SCREENS.REPORT], + ['opened from Search', SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], + ['opened from Search as an expense', SCREENS.RIGHT_MODAL.SEARCH_REPORT], + ])('keeps the actions on the title row when the report is %s', (_label, routeName) => { + mockedUseRoute.mockReturnValue({key: 'route-1', name: routeName, params: {}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).toContain(ACTIONS_TEST_ID); + // The actions row under the title must stay empty, otherwise the buttons would render twice. + expect(mockedMoreContent).toHaveBeenCalled(); + expect(mockedMoreContent.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldRenderActionsInRow: false})); + }); + + it.each([ + ['an invoice report, whose chat report is an invoice room', {reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM} as Report], + [ + 'a workspace report, whose chat report is a policy expense chat', + {reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT} as Report, + ], + ])('keeps the actions on the title row for %s', (_label, chatReport) => { + mockedUseOnyx.mockImplementation((key) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { + return [iouReport, {status: 'loaded'}]; + } + if (key === `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`) { + return [chatReport, {status: 'loaded'}]; + } + return [undefined, {status: 'loaded'}]; + }); + mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.REPORT, params: {}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).toContain(ACTIONS_TEST_ID); + expect(mockedMoreContent.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldRenderActionsInRow: false})); + }); + + it('renders the actions before the carousel so the carousel stays pinned to the top right', () => { + mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, params: {}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).toEqual([ACTIONS_TEST_ID, REPORT_CAROUSEL_TEST_ID]); + }); + + it('moves the actions down to the more-content row when that row has a status or next step', () => { + mockedMoreContentVisibility.mockReturnValue({statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD, shouldShowNextStep: false, hasStatusOrNextStep: true}); + mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, params: {}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).not.toContain(ACTIONS_TEST_ID); + expect(mockedActions).not.toHaveBeenCalled(); + expect(mockedMoreContent.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldRenderActionsInRow: true})); + }); +}); + +describe('MoneyReportHeader transaction carousel anchor', () => { + const threadReport = { + reportID: REPORT_ID, + chatReportID: CHAT_REPORT_ID, + parentReportID: PARENT_REPORT_ID, + parentReportActionID: PARENT_ACTION_ID, + type: CONST.REPORT.TYPE.CHAT, + } as Report; + + const parentIOUAction = { + reportActionID: PARENT_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {IOUTransactionID: THREAD_TRANSACTION_ID, type: CONST.IOU.REPORT_ACTION_TYPE.CREATE}, + }; + + /** Mirrors the failing state: the derived transactions index has nothing for this thread. */ + function mockThread({activeIDs, parentActions}: {activeIDs: string[]; parentActions: Record | undefined}) { + // The anchor is read through a `selector`, so the mock has to apply it the way useOnyx does. + mockedUseOnyx.mockImplementation((key, options) => { + const rawValue = (() => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { + return threadReport; + } + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS) { + return activeIDs; + } + if (key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`) { + return parentActions; + } + return undefined; + })(); + const {selector} = options ?? {}; + const value = selector ? selector(rawValue) : rawValue; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- useOnyx's result type can't express "value depends on the key", and each branch above returns the shape its own key really holds + return [value as NonNullable | undefined, {status: 'loaded'}]; + }); + mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, params: {}}); + } + + beforeEach(() => { + mockedMoreContentVisibility.mockReturnValue({statusBarType: undefined, shouldShowNextStep: false, hasStatusOrNextStep: false}); + mockedUseReportPrimaryAction.mockReturnValue(CONST.REPORT.PRIMARY_ACTIONS.PAY); + }); + + it('renders the transaction carousel using the parent IOU action when the derived index is cold', () => { + mockThread({activeIDs: ['other-tx', THREAD_TRANSACTION_ID], parentActions: {[PARENT_ACTION_ID]: parentIOUAction}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).toContain(TRANSACTIONS_CAROUSEL_TEST_ID); + }); + + it('does not render it when the resolved expense is not one of the carousel siblings', () => { + mockThread({activeIDs: ['other-tx', 'unrelated-tx'], parentActions: {[PARENT_ACTION_ID]: parentIOUAction}}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).not.toContain(TRANSACTIONS_CAROUSEL_TEST_ID); + }); + + it('does not render it when the parent action is unavailable, so there is no expense to anchor to', () => { + mockThread({activeIDs: ['other-tx', THREAD_TRANSACTION_ID], parentActions: undefined}); + + const {toJSON} = renderHeader(); + + expect(getHeaderRowTestIDs(toJSON())).not.toContain(TRANSACTIONS_CAROUSEL_TEST_ID); + }); +}); diff --git a/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx b/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx deleted file mode 100644 index 0cb04c080d37..000000000000 --- a/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import {render} from '@testing-library/react-native'; - -import MoneyReportHeaderMoreContent from '@components/MoneyReportHeaderMoreContent'; -import MoneyReportHeaderNextStep from '@components/MoneyReportHeaderNextStep'; - -import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; -import useOnyx from '@hooks/useOnyx'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report} from '@src/types/onyx'; - -import type {UseOnyxResult} from 'react-native-onyx'; - -import React from 'react'; - -import createRandomPolicy from '../../utils/collections/policies'; - -const TEST_REPORT_ID = '1001'; -const TEST_POLICY_ID = 'policy1'; - -const report = { - reportID: TEST_REPORT_ID, - policyID: TEST_POLICY_ID, - type: CONST.REPORT.TYPE.EXPENSE, -} as Report; - -function createOnyxResult(value: NonNullable | undefined): UseOnyxResult { - return [value, {status: 'loaded'}]; -} - -// `useRoute` is only used to detect Search routes; default to a regular report route so `isReportInSearch` is false. -// Spread the real module so navigation internals (e.g. createNavigationContainerRef) pulled in by the real -// PolicyUtils import chain keep working. -jest.mock('@react-navigation/native', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actualNavigation = jest.requireActual('@react-navigation/native'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actualNavigation, - __esModule: true, - useRoute: jest.fn(() => ({name: 'report'})), - }; -}); - -// The next-step bar is the element under test; render nothing but track whether it was mounted via the visibility gate. -jest.mock('@components/MoneyReportHeaderNextStep', () => ({__esModule: true, default: jest.fn(() => null)})); -jest.mock('@components/MoneyReportHeaderStatusBarSection', () => ({__esModule: true, default: () => null})); -jest.mock('@components/MoneyRequestReportView/MoneyRequestReportNavigation', () => ({__esModule: true, default: () => null})); -jest.mock('@components/MoneyReportTransactionThreadContext', () => ({__esModule: true, useMoneyReportTransactionThread: jest.fn(() => ({iouTransactionID: undefined}))})); - -// Spread the real module: the live PolicyUtils import chain relies on many other ReportUtils exports. -jest.mock('@libs/ReportUtils', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actualReportUtils = jest.requireActual('@libs/ReportUtils'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actualReportUtils, - __esModule: true, - isInvoiceReport: jest.fn(() => false), - }; -}); - -jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: jest.fn(() => ({}))})); -jest.mock('@hooks/useResponsiveLayout', () => ({__esModule: true, default: jest.fn(() => ({shouldUseNarrowLayout: false, isMediumScreenWidth: false}))})); -jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => ({__esModule: true, default: jest.fn(() => ({isWideRHPDisplayedOnWideLayout: false, isSuperWideRHPDisplayedOnWideLayout: false}))})); -jest.mock('@hooks/useMoneyReportHeaderStatusBar', () => ({__esModule: true, default: jest.fn(() => ({shouldShowStatusBar: false, statusBarType: undefined}))})); -jest.mock('@hooks/useOnyx', () => jest.fn()); - -const mockedUseOnyx = jest.mocked(useOnyx); -const mockedStatusBar = jest.mocked(useMoneyReportHeaderStatusBar); -const mockedNextStepBar = jest.mocked(MoneyReportHeaderNextStep); - -let policyValue: Policy | undefined; - -function mockPolicyType(type: Policy['type']) { - policyValue = {...createRandomPolicy(1, type), id: TEST_POLICY_ID}; -} - -describe('MoneyReportHeaderMoreContent', () => { - beforeEach(() => { - jest.clearAllMocks(); - policyValue = undefined; - mockedStatusBar.mockReturnValue({shouldShowStatusBar: false, statusBarType: undefined}); - mockedUseOnyx.mockImplementation((key) => { - if (key === `${ONYXKEYS.COLLECTION.REPORT}${TEST_REPORT_ID}`) { - return createOnyxResult(report); - } - if (key === `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`) { - return createOnyxResult(policyValue); - } - return createOnyxResult(undefined); - }); - }); - - it('renders the next step bar for a Submit workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.SUBMIT); - render(); - expect(mockedNextStepBar).toHaveBeenCalled(); - }); - - it('renders the next step bar for a paid (team) workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.TEAM); - render(); - expect(mockedNextStepBar).toHaveBeenCalled(); - }); - - it('does not render the next step bar for a personal workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.PERSONAL); - render(); - expect(mockedNextStepBar).not.toHaveBeenCalled(); - }); - - it('does not render the next step bar when a status bar is shown', () => { - mockPolicyType(CONST.POLICY.TYPE.SUBMIT); - mockedStatusBar.mockReturnValue({shouldShowStatusBar: true, statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD}); - render(); - expect(mockedNextStepBar).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx b/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx new file mode 100644 index 000000000000..d52e54138a91 --- /dev/null +++ b/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx @@ -0,0 +1,120 @@ +import {renderHook} from '@testing-library/react-native'; + +import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; +import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; +import useOnyx from '@hooks/useOnyx'; + +import {isInvoiceReport} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; + +import type {UseOnyxResult} from 'react-native-onyx'; + +import createRandomPolicy from '../../utils/collections/policies'; + +const TEST_REPORT_ID = '1001'; +const TEST_POLICY_ID = 'policy1'; + +const report = { + reportID: TEST_REPORT_ID, + policyID: TEST_POLICY_ID, + type: CONST.REPORT.TYPE.EXPENSE, +} as Report; + +function createOnyxResult(value: NonNullable | undefined): UseOnyxResult { + return [value, {status: 'loaded'}]; +} + +// Spread the real module: the live PolicyUtils import chain relies on many other ReportUtils exports. +jest.mock('@libs/ReportUtils', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actualReportUtils = jest.requireActual('@libs/ReportUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actualReportUtils, + __esModule: true, + isInvoiceReport: jest.fn(() => false), + }; +}); + +jest.mock('@hooks/useMoneyReportHeaderStatusBar', () => ({__esModule: true, default: jest.fn(() => ({shouldShowStatusBar: false, statusBarType: undefined}))})); +jest.mock('@hooks/useOnyx', () => jest.fn()); + +const mockedUseOnyx = jest.mocked(useOnyx); +const mockedStatusBar = jest.mocked(useMoneyReportHeaderStatusBar); +const mockedIsInvoiceReport = jest.mocked(isInvoiceReport); + +let policyValue: Policy | undefined; + +function mockPolicyType(type: Policy['type']) { + policyValue = {...createRandomPolicy(1, type), id: TEST_POLICY_ID}; +} + +/** + * `hasStatusOrNextStep` is what the header uses to decide where the report actions go: at the end of the + * more-content row when that row has its own content, or in the header row when it would otherwise be blank. + */ +describe('useMoneyReportHeaderMoreContentVisibility', () => { + beforeEach(() => { + jest.clearAllMocks(); + policyValue = undefined; + mockedIsInvoiceReport.mockReturnValue(false); + mockedStatusBar.mockReturnValue({shouldShowStatusBar: false, statusBarType: undefined}); + mockedUseOnyx.mockImplementation((key) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${TEST_REPORT_ID}`) { + return createOnyxResult(report); + } + if (key === `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`) { + return createOnyxResult(policyValue); + } + return createOnyxResult(undefined); + }); + }); + + it('shows the next step for a Submit workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.SUBMIT); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(true); + expect(result.current.hasStatusOrNextStep).toBe(true); + }); + + it('shows the next step for a paid (team) workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.TEAM); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(true); + expect(result.current.hasStatusOrNextStep).toBe(true); + }); + + it('does not show the next step for a personal workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.PERSONAL); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(false); + }); + + it('does not show the next step when a status bar is shown', () => { + mockPolicyType(CONST.POLICY.TYPE.SUBMIT); + mockedStatusBar.mockReturnValue({shouldShowStatusBar: true, statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD}); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(false); + // A status bar still fills the more-content row, so the actions stay in it. + expect(result.current.hasStatusOrNextStep).toBe(true); + }); + + it('reports an empty more-content row for a personal workspace with no status bar', () => { + mockPolicyType(CONST.POLICY.TYPE.PERSONAL); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + // This is the 1:1 DM IOU case: nothing to show, so the actions move up into the header row + // instead of sitting alone on a blank line under the title. + expect(result.current.hasStatusOrNextStep).toBe(false); + }); + + it('reports an empty more-content row for an invoice report, even on a paid workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.TEAM); + mockedIsInvoiceReport.mockReturnValue(true); + const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); + expect(result.current.shouldShowNextStep).toBe(false); + expect(result.current.hasStatusOrNextStep).toBe(false); + }); +}); diff --git a/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx new file mode 100644 index 000000000000..13b5622dc034 --- /dev/null +++ b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx @@ -0,0 +1,458 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import MoneyRequestReportTransactionsNavigation from '@components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; + +import {createTransactionThreadReport} from '@libs/actions/Report'; +import {clearActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; + +import Navigation from '@navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import React from 'react'; + +/** + * These tests verify the navigation resolution of MoneyRequestReportTransactionsNavigation: + * given a transaction list (and optionally a search snapshot), pressing prev/next should resolve + * and navigate to the correct target reportID for each direction. The heavy hooks are mocked so + * the real selectors and the onNext/onPrevious branching logic are exercised in isolation. + */ + +type MockOnyxState = { + transactionIDsList: string[] | undefined; + snapshotHash: string | undefined; + snapshot: {data: Record} | undefined; + siblingDescriptors: Record | undefined; + transactionsCollection: Record; + reportActionsCollection: Record; + reportsCollection: Record; +}; + +const mockState: MockOnyxState = { + transactionIDsList: undefined, + snapshotHash: undefined, + snapshot: undefined, + siblingDescriptors: undefined, + transactionsCollection: {}, + reportActionsCollection: {}, + reportsCollection: {}, +}; + +const mockUseOnyx = jest.fn(); +const mockMarkReportRHPWidth = jest.fn(); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + default: (...args: unknown[]) => mockUseOnyx(...args), +})); + +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => ({}), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: 1, email: 'me@example.com'}), +})); + +jest.mock('@components/WideRHPContextProvider', () => ({ + useWideRHPActions: () => ({markReportRHPWidth: mockMarkReportRHPWidth}), +})); + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: () => ({}), +})); + +type ReactActual = {createElement: typeof React.createElement; Fragment: typeof React.Fragment}; +type ReactNativeActual = { + Pressable: React.ComponentType<{testID?: string; disabled?: boolean; onPress?: () => void}>; + Text: React.ComponentType<{children?: React.ReactNode}>; +}; + +jest.mock('@components/Text', () => { + const {Text} = jest.requireActual('react-native'); + return {__esModule: true, default: Text}; +}); + +jest.mock('@components/PrevNextButtons', () => { + const ReactLib = jest.requireActual('react'); + const {Pressable} = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: {onNext: () => void; onPrevious: () => void; isNextButtonDisabled?: boolean; isPrevButtonDisabled?: boolean}) => + ReactLib.createElement( + ReactLib.Fragment, + null, + ReactLib.createElement(Pressable, {testID: 'prev-button', disabled: props.isPrevButtonDisabled, onPress: () => props.onPrevious()}), + ReactLib.createElement(Pressable, {testID: 'next-button', disabled: props.isNextButtonDisabled, onPress: () => props.onNext()}), + ), + }; +}); + +jest.mock('@navigation/Navigation', () => ({ + __esModule: true, + default: { + setParams: jest.fn(), + getActiveRoute: jest.fn(() => 'active-route'), + }, +})); + +const makeRootState = (focusedRouteName: string) => ({index: 0, routes: [{key: 'k', name: focusedRouteName}]}); +const mockGetRootState = jest.fn(() => makeRootState('testRoute')); + +jest.mock('@navigation/navigationRef', () => ({ + __esModule: true, + default: { + getRootState: () => mockGetRootState(), + getCurrentRoute: jest.fn(() => undefined), + }, +})); + +jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(() => undefined), + setOptimisticTransactionThread: jest.fn(), +})); + +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + clearActiveTransactionIDs: jest.fn(), +})); + +jest.mock('@libs/TransactionThreadNavigationUtils', () => ({ + getReportIDToOpenForExpense: jest.fn(() => 'resolved-descriptor-report'), +})); + +const makeIOUAction = (transactionID: string, {childReportID, reportID}: {childReportID?: string; reportID: string}) => ({ + reportActionID: `action_${transactionID}`, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {IOUTransactionID: transactionID, type: 'create'}, + childReportID, + reportID, +}); + +const CURRENT_ID = 'tCur'; +const PREV_ID = 'tPrev'; +const NEXT_ID = 'tNext'; + +const resetMockState = () => { + mockState.transactionIDsList = [PREV_ID, CURRENT_ID, NEXT_ID]; + mockState.snapshotHash = undefined; + mockState.snapshot = undefined; + mockState.siblingDescriptors = undefined; + mockState.transactionsCollection = {}; + mockState.reportActionsCollection = {}; + mockState.reportsCollection = {}; + mockGetRootState.mockReturnValue(makeRootState('testRoute')); +}; + +const setupUseOnyx = () => { + mockUseOnyx.mockImplementation((key: string, options?: {selector?: (data: unknown) => unknown}) => { + const selector = options?.selector; + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS) { + return [mockState.transactionIDsList]; + } + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH) { + return [mockState.snapshotHash]; + } + if (key === `${ONYXKEYS.COLLECTION.SNAPSHOT}${mockState.snapshotHash}`) { + return [mockState.snapshot]; + } + if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS) { + return [mockState.siblingDescriptors]; + } + if (key === ONYXKEYS.COLLECTION.TRANSACTION) { + return [selector ? selector(mockState.transactionsCollection) : undefined]; + } + if (key === ONYXKEYS.COLLECTION.REPORT_ACTIONS) { + return [selector ? selector(mockState.reportActionsCollection) : undefined]; + } + if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { + return [mockState.reportsCollection[key]]; + } + // NVP_ONBOARDING (selector-based), NVP_INTRO_SELECTED, BETAS and anything else are not relevant to resolution. + return [undefined]; + }); +}; + +const renderNavigation = () => render(); + +// Navigation.setParams is deferred inside requestAnimationFrame. Run it synchronously so the resolved +// navigation happens during the press and can be asserted immediately afterwards. +const press = (testID: string) => { + global.requestAnimationFrame = (callback: FrameRequestCallback) => { + callback(0); + return 0; + }; + fireEvent.press(screen.getByTestId(testID)); +}; + +describe('MoneyRequestReportTransactionsNavigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetMockState(); + setupUseOnyx(); + }); + + describe('one-transaction parent report', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 1}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 1}, + }; + }); + + it('navigates next to the parent reportID', () => { + renderNavigation(); + + press('next-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', reportActionID: undefined, anchorTransactionID: NEXT_ID})); + expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('rNext', 'wide'); + }); + + it('navigates previous to the parent reportID', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', reportActionID: undefined, anchorTransactionID: PREV_ID})); + expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('rPrev', 'wide'); + }); + }); + + describe('resolves siblings and parents from the search snapshot', () => { + beforeEach(() => { + // Live collections are intentionally empty; everything is only in the snapshot. + mockState.snapshotHash = 'hash1'; + mockState.snapshot = { + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 1}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 1}, + }, + }; + }); + + it('navigates next using snapshot-only data', () => { + renderNavigation(); + + press('next-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', anchorTransactionID: NEXT_ID})); + }); + + it('navigates previous using snapshot-only data', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', anchorTransactionID: PREV_ID})); + }); + }); + + describe('multi-transaction parent with an existing thread', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportActionsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {childReportID: 'threadPrev', reportID: 'rPrev'})}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {childReportID: 'threadNext', reportID: 'rNext'})}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, + }; + }); + + it('navigates next to the existing transaction thread reportID', () => { + renderNavigation(); + + press('next-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadNext', anchorTransactionID: NEXT_ID})); + expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('threadNext', 'wide'); + }); + + it('navigates previous to the existing transaction thread reportID', () => { + renderNavigation(); + + press('prev-button'); + + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadPrev', anchorTransactionID: PREV_ID})); + expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('threadPrev', 'wide'); + }); + }); + + describe('live report actions take precedence over a stale snapshot copy', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportActionsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {childReportID: 'threadPrev', reportID: 'rPrev'})}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {childReportID: 'threadNext', reportID: 'rNext'})}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, + }; + mockState.snapshotHash = 'hash1'; + mockState.snapshot = { + data: { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {reportID: 'rPrev'})}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {reportID: 'rNext'})}, + }, + }; + }); + + it('navigates next to the live thread instead of creating a duplicate', () => { + renderNavigation(); + + press('next-button'); + + expect(createTransactionThreadReport).not.toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadNext', anchorTransactionID: NEXT_ID})); + }); + + it('navigates previous to the live thread instead of creating a duplicate', () => { + renderNavigation(); + + press('prev-button'); + + expect(createTransactionThreadReport).not.toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadPrev', anchorTransactionID: PREV_ID})); + }); + }); + + describe('multi-transaction parent without a thread', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + mockState.reportActionsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {reportID: 'rPrev'})}, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {reportID: 'rNext'})}, + }; + mockState.reportsCollection = { + [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, + [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, + }; + }); + + it('creates a thread and navigates next, anchoring on the target transaction', () => { + renderNavigation(); + + press('next-button'); + + expect(createTransactionThreadReport).toHaveBeenCalled(); + // createTransactionThreadReport is mocked to return undefined, so the target falls back to the transaction's own reportID. + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', anchorTransactionID: NEXT_ID})); + }); + + it('creates a thread and navigates previous, anchoring on the target transaction', () => { + renderNavigation(); + + press('prev-button'); + + expect(createTransactionThreadReport).toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', anchorTransactionID: PREV_ID})); + }); + }); + + describe('snapshot-backed sibling descriptors', () => { + beforeEach(() => { + mockState.transactionsCollection = { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, + }; + // No parent report present -> not a one-transaction report, so resolution uses the descriptor. + mockState.siblingDescriptors = { + [PREV_ID]: {reportID: 'rPrev'}, + [NEXT_ID]: {reportID: 'rNext'}, + }; + }); + + it('navigates next to the descriptor-resolved reportID', () => { + jest.mocked(getReportIDToOpenForExpense).mockReturnValue('descNext'); + renderNavigation(); + + press('next-button'); + + expect(getReportIDToOpenForExpense).toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'descNext', anchorTransactionID: NEXT_ID})); + }); + + it('navigates previous to the descriptor-resolved reportID', () => { + jest.mocked(getReportIDToOpenForExpense).mockReturnValue('descPrev'); + renderNavigation(); + + press('prev-button'); + + expect(getReportIDToOpenForExpense).toHaveBeenCalled(); + expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'descPrev', anchorTransactionID: PREV_ID})); + }); + }); + + it('does not render navigation when there are fewer than two transactions', () => { + mockState.transactionIDsList = [CURRENT_ID]; + + renderNavigation(); + + expect(screen.queryByTestId('next-button')).toBeNull(); + }); + + describe('clearing the carousel on unmount', () => { + const setFocusedRoute = (name: string) => { + mockGetRootState.mockReturnValue(makeRootState(name)); + }; + + // Unmounting onto one of these means we're still inside the expense-navigation flow, and a screen lower in + // the RHP stack may still depend on the carousel. In particular, opening the parent report from the + // subtitle link pushes an EXPENSE_REPORT / SEARCH_MONEY_REQUEST_REPORT RHP on top of the transaction + // thread, so backing out of an expense onto it must not wipe the underlying thread's carousel (#90366). + it.each([ + ['SEARCH_REPORT', SCREENS.RIGHT_MODAL.SEARCH_REPORT], + ['SEARCH_MONEY_REQUEST_REPORT', SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], + ['EXPENSE_REPORT', SCREENS.RIGHT_MODAL.EXPENSE_REPORT], + ['TRANSACTION_DUPLICATE.DYNAMIC_REVIEW', SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW], + ])('keeps the active transaction IDs when unmounting onto %s', (_label, screenName) => { + setFocusedRoute(screenName); + + renderNavigation().unmount(); + + expect(clearActiveTransactionIDs).not.toHaveBeenCalled(); + }); + + it('clears the active transaction IDs when unmounting onto an unrelated screen', () => { + setFocusedRoute(SCREENS.SEARCH.ROOT); + + renderNavigation().unmount(); + + expect(clearActiveTransactionIDs).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/hooks/useNavigateToTransactionThread.test.ts b/tests/unit/hooks/useNavigateToTransactionThread.test.ts new file mode 100644 index 000000000000..7955d5a6f9e8 --- /dev/null +++ b/tests/unit/hooks/useNavigateToTransactionThread.test.ts @@ -0,0 +1,110 @@ +import {renderHook} from '@testing-library/react-native'; + +import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; + +import {setActiveTransactionIDs, shouldPreserveActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import Navigation from '@libs/Navigation/Navigation'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; + +import type {Report} from '@src/types/onyx'; + +import createRandomReportAction from '../../utils/collections/reportActions'; +import {createExpenseReport} from '../../utils/collections/reports'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const TRANSACTION_ID = 'B2'; +const SIBLING_TRANSACTION_IDS = ['B1', 'B2']; +const THREAD_REPORT_ID = 'thread-1'; + +const report: Report = {...createExpenseReport(1), reportID: 'reportB'}; +// An IOU action that already has a thread, so the hook takes the short path to navigation. +const iouAction = {...createRandomReportAction(1), reportActionID: 'action1', childReportID: THREAD_REPORT_ID}; + +jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ + setActiveTransactionIDs: jest.fn(() => Promise.resolve()), + shouldPreserveActiveTransactionIDs: jest.fn(() => false), +})); + +jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(), + setOptimisticTransactionThread: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: {getActiveRoute: jest.fn(() => '/search'), navigate: jest.fn()}, +})); + +jest.mock('@libs/ReportActionsUtils', () => ({getIOUActionForTransactionID: jest.fn()})); + +jest.mock('@components/WideRHPContextProvider', () => ({useWideRHPActions: jest.fn(() => ({markReportRHPWidth: jest.fn()}))})); + +// The personal details context is only forwarded to createTransactionThreadReport (mocked above), so an empty map is enough. +jest.mock('@components/OnyxListItemProvider', () => ({usePersonalDetails: jest.fn(() => ({}))})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({email: 'a@b.com', accountID: 1}))); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}])); + +const mockedGetIOUAction = jest.mocked(getIOUActionForTransactionID); +const mockedSetActiveTransactionIDs = jest.mocked(setActiveTransactionIDs); +const mockedShouldPreserve = jest.mocked(shouldPreserveActiveTransactionIDs); +const mockedNavigate = jest.mocked(Navigation.navigate); + +function callHook(overrides?: {shouldPreserveBroaderCarousel?: boolean}) { + const {result} = renderHook(() => useNavigateToTransactionThread()); + result.current({ + transactionID: TRANSACTION_ID, + reportActions: [iouAction], + report, + transaction: undefined, + siblingTransactionIDs: SIBLING_TRANSACTION_IDS, + ...overrides, + }); + return waitForBatchedUpdates(); +} + +/** + * Regression guard for https://github.com/Expensify/App/issues/98196: an expense row inside a report that was opened + * from a broader carousel (e.g. the Spend page) must not re-seed the carousel with just that report's expenses, which + * shrank the "x of y" counter and left the wrong list behind after navigating back. + */ +describe('useNavigateToTransactionThread', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedGetIOUAction.mockReturnValue(iouAction); + mockedShouldPreserve.mockReturnValue(false); + mockedSetActiveTransactionIDs.mockReturnValue(Promise.resolve()); + }); + + it('seeds the carousel with the given siblings by default', async () => { + await callHook(); + + expect(mockedSetActiveTransactionIDs).toHaveBeenCalledWith(SIBLING_TRANSACTION_IDS); + expect(mockedShouldPreserve).not.toHaveBeenCalled(); + }); + + it('still seeds when preservation is requested but no broader carousel is active', async () => { + await callHook({shouldPreserveBroaderCarousel: true}); + + expect(mockedShouldPreserve).toHaveBeenCalledWith(SIBLING_TRANSACTION_IDS, TRANSACTION_ID); + expect(mockedSetActiveTransactionIDs).toHaveBeenCalledWith(SIBLING_TRANSACTION_IDS); + }); + + it('leaves a broader carousel untouched when preservation is requested', async () => { + mockedShouldPreserve.mockReturnValue(true); + + await callHook({shouldPreserveBroaderCarousel: true}); + + expect(mockedSetActiveTransactionIDs).not.toHaveBeenCalled(); + }); + + it('navigates to the thread whether or not the carousel was re-seeded', async () => { + mockedShouldPreserve.mockReturnValue(true); + + await callHook({shouldPreserveBroaderCarousel: true}); + + // Navigation used to be chained onto the seeding promise, so skipping the write must not skip the hop. + expect(mockedNavigate).toHaveBeenCalledTimes(1); + expect(mockedNavigate.mock.calls.at(0)?.at(0)).toContain(THREAD_REPORT_ID); + }); +}); diff --git a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts new file mode 100644 index 000000000000..89771dc13a07 --- /dev/null +++ b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts @@ -0,0 +1,135 @@ +import {clearActiveTransactionIDs, setActiveTransactionIDs, shouldPreserveActiveTransactionIDs, shouldWriteActiveTransactionIDsForSearch} from '@libs/actions/TransactionThreadNavigation'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; + +const SEARCH_HASH = 959171759; +const OTHER_SEARCH_HASH = 123456; + +// The Spend page's expense list, as the carousel was seeded with it when a row was pressed. +const SEEDED_IDS = ['A1', 'A2', 'A3']; + +// The Spend page holds one expense from report A and two from report B; report B owns only its own two. +const SPEND_PAGE_IDS = ['A1', 'B1', 'B2']; +const REPORT_B_IDS = ['B1', 'B2']; + +describe('shouldWriteActiveTransactionIDsForSearch', () => { + it('refreshes when the search gained one expense, so y grows by one', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A0', ...SEEDED_IDS])).toBe(true); + }); + + it('refreshes when the search gained two expenses, so y grows by two', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A0', 'A00', ...SEEDED_IDS])).toBe(true); + }); + + it('refreshes when the search lost an expense but still has two to navigate between', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A1', 'A3'])).toBe(true); + }); + + it('refreshes when the same expenses are re-sorted, because the counter index follows the list order', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A3', 'A2', 'A1'])).toBe(true); + }); + + it('does nothing when the carousel already matches the search', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, [...SEEDED_IDS])).toBe(false); + }); + + it('leaves another search\u2019s carousel alone', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, OTHER_SEARCH_HASH, SEARCH_HASH, ['A0', ...SEEDED_IDS])).toBe(false); + }); + + it('leaves a report-scoped carousel alone, since a drill-in clears the snapshot hash', () => { + expect(shouldWriteActiveTransactionIDsForSearch(['B1', 'B2'], undefined, SEARCH_HASH, ['A0', ...SEEDED_IDS])).toBe(false); + }); + + it('adopts the carousel when nothing is active, so it comes back after a clear', () => { + expect(shouldWriteActiveTransactionIDsForSearch(undefined, undefined, SEARCH_HASH, SEEDED_IDS)).toBe(true); + expect(shouldWriteActiveTransactionIDsForSearch([], undefined, SEARCH_HASH, SEEDED_IDS)).toBe(true); + }); + + it('does not adopt a carousel for a single expense, which is nothing to navigate between', () => { + expect(shouldWriteActiveTransactionIDsForSearch(undefined, undefined, SEARCH_HASH, ['A1'])).toBe(false); + }); + + it('re-seeds down to a single remaining expense rather than retiring the carousel', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A1'])).toBe(true); + }); + + it('restores the carousel once a shrunken search grows back to two expenses', () => { + expect(shouldWriteActiveTransactionIDsForSearch(['A1'], SEARCH_HASH, SEARCH_HASH, ['A1', 'A2'])).toBe(true); + }); + + // A merge deletes transactions through an unbatched Onyx.set, so the derived list momentarily empties. Writing that + // would leave nothing for the settled render to recover from. + it('ignores an empty search list, which is the shape a search takes mid-update', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, [])).toBe(false); + }); + + it('leaves another search\u2019s carousel alone even when this search is empty', () => { + expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, OTHER_SEARCH_HASH, SEARCH_HASH, [])).toBe(false); + }); +}); + +/** + * Regression guard for https://github.com/Expensify/App/issues/98196: opening an expense from Spend seeds the carousel + * with every expense in the search, but drilling into the owning report and tapping a row there re-seeded it with just + * that report's expenses. Navigating back then left the counter showing the report's total instead of the search's. + */ +describe('shouldPreserveActiveTransactionIDs', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await clearActiveTransactionIDs(); + await waitForBatchedUpdates(); + }); + + it('preserves a strictly broader carousel that contains the tapped expense', async () => { + await setActiveTransactionIDs(SPEND_PAGE_IDS, SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(true); + }); + + it('does not preserve when no carousel is active', () => { + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(false); + }); + + it('does not preserve when the active carousel is missing the tapped expense', async () => { + await setActiveTransactionIDs(['C1', 'C2', 'C3'], SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(false); + }); + + it('does not preserve when the active carousel does not cover every sibling', async () => { + // Broader by length, but B2 is absent, so prev/next would skip an expense the report shows. + await setActiveTransactionIDs(['A1', 'B1', 'C1'], SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B1')).toBe(false); + }); + + it('does not preserve an equally sized carousel, so a re-sorted report re-seeds in its new visual order', async () => { + await setActiveTransactionIDs(['B2', 'B1'], SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B1')).toBe(false); + }); + + it('does not preserve a narrower carousel', async () => { + await setActiveTransactionIDs(['B1'], SEARCH_HASH); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B1')).toBe(false); + }); + + it('does not preserve a carousel that only exists in persisted Onyx state', async () => { + // The Onyx key is persisted, so on a fresh load it can hold a carousel from an earlier session or a search that + // no longer matches the screen. Preserving that made a drill-in show a stale total ("4 of 6" in a 2-expense + // report), so only a list seeded during this session counts. + await Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, SPEND_PAGE_IDS); + await waitForBatchedUpdates(); + + expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(false); + }); +}); From a4c7a68ff8b66e2fa572b3d7b9c545baf4f01266 Mon Sep 17 00:00:00 2001 From: Nyoman Jyotisa Date: Fri, 14 Aug 2026 11:56:38 +0800 Subject: [PATCH 200/466] fix: allow adding negative expenses to expense reports --- src/pages/AddExistingExpense.tsx | 9 ++------- src/pages/canAddTransactionAmountToReport.ts | 5 +++++ tests/unit/AddExistingExpenseTest.ts | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 src/pages/canAddTransactionAmountToReport.ts diff --git a/src/pages/AddExistingExpense.tsx b/src/pages/AddExistingExpense.tsx index a87b277ad135..32e421dfba20 100644 --- a/src/pages/AddExistingExpense.tsx +++ b/src/pages/AddExistingExpense.tsx @@ -52,6 +52,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +import canAddTransactionAmountToReport from './canAddTransactionAmountToReport'; import UnreportedExpenseListItem from './UnreportedExpenseListItem'; type AddExistingExpensePageType = PlatformStackScreenProps; @@ -121,13 +122,7 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { const transactionAmount = getTransactionDetails(item)?.amount ?? 0; - // Only block negative amounts for unreported expenses. - if (transactionAmount < 0 && isUnreported) { - return false; - } - - // Zero amount expenses are not allowed in IOU reports - if (isIOU && transactionAmount === 0) { + if (!canAddTransactionAmountToReport(transactionAmount, isIOU)) { return false; } diff --git a/src/pages/canAddTransactionAmountToReport.ts b/src/pages/canAddTransactionAmountToReport.ts new file mode 100644 index 000000000000..dea52f77d421 --- /dev/null +++ b/src/pages/canAddTransactionAmountToReport.ts @@ -0,0 +1,5 @@ +function canAddTransactionAmountToReport(transactionAmount: number, isIOU: boolean): boolean { + return !isIOU || transactionAmount > 0; +} + +export default canAddTransactionAmountToReport; diff --git a/tests/unit/AddExistingExpenseTest.ts b/tests/unit/AddExistingExpenseTest.ts index 5bbbdb4b0ccc..5e683c736d0e 100644 --- a/tests/unit/AddExistingExpenseTest.ts +++ b/tests/unit/AddExistingExpenseTest.ts @@ -1,5 +1,8 @@ +import {getTransactionDetails} from '@libs/ReportUtils'; import {createUnreportedExpenses} from '@libs/TransactionUtils'; +import canAddTransactionAmountToReport from '@pages/canAddTransactionAmountToReport'; + import CONST from '@src/CONST'; import type Transaction from '@src/types/onyx/Transaction'; @@ -25,6 +28,23 @@ function generateTransaction(values: Partial = {}): Transaction { } describe('AddExistingExpense', () => { + describe('canAddTransactionAmountToReport', () => { + const getUnreportedTransactionAmount = (storedAmount: number) => getTransactionDetails(generateTransaction({amount: storedAmount}))?.amount ?? 0; + + test.each([ + {amountType: 'negative', storedAmount: 1000, isIOU: false, expected: true}, + {amountType: 'zero', storedAmount: 0, isIOU: false, expected: true}, + {amountType: 'positive', storedAmount: -1000, isIOU: false, expected: true}, + {amountType: 'negative', storedAmount: 1000, isIOU: true, expected: false}, + {amountType: 'zero', storedAmount: 0, isIOU: true, expected: false}, + {amountType: 'positive', storedAmount: -1000, isIOU: true, expected: true}, + ])('should handle a $amountType unreported expense with isIOU=$isIOU', ({storedAmount, isIOU, expected}) => { + const transactionAmount = getUnreportedTransactionAmount(storedAmount); + + expect(canAddTransactionAmountToReport(transactionAmount, isIOU)).toBe(expected); + }); + }); + describe('createUnreportedExpenses', () => { it('should mark transactions with DELETE pendingAction as disabled', () => { const normalTransaction = generateTransaction({ From db37ed02cdec542a7530ec558a46c4e55063c833 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 14 Aug 2026 12:02:54 +0700 Subject: [PATCH 201/466] refactor code --- src/CONST/index.ts | 1 - src/ONYXKEYS.ts | 10 +- .../InternationalBankAccountDetailsStep.tsx | 94 +++++++++++++++++++ src/hooks/useLoadDepositAccountSetup.ts | 42 +++++++-- src/libs/BankAccountUtils.ts | 58 ++++++++++++ src/libs/ExportOnyxState/common.ts | 2 +- src/libs/actions/BankAccounts.ts | 12 ++- .../AddPersonalBankAccountPage/index.tsx | 8 +- .../InternationalBankAccountDetailsStep.tsx | 31 ++---- .../InternationalBankAccountFields.tsx | 69 -------------- .../InternationalDepositAccountContent.tsx | 3 +- .../subPages/Confirmation.tsx | 10 +- .../InternationalBankAccountDetails.tsx | 30 ++---- .../InternationalDepositAccount/utils.ts | 65 +------------ src/setup/index.ts | 1 + 15 files changed, 230 insertions(+), 206 deletions(-) create mode 100644 src/components/SubStepForms/InternationalBankAccountDetailsStep.tsx delete mode 100644 src/pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ca30f8bb608f..c14d5e78d659 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -750,7 +750,6 @@ const CONST = { MASKED_US_ACCOUNT_NUMBER: /^[X]{0,13}[0-9]{4}$/, SWIFT_BIC: /^[A-Za-z0-9]{8,11}$/, IBAN: /^[a-zA-Z]{2}[0-9]{2}[a-zA-Z0-9]{4}[0-9]{7}([a-zA-Z0-9]?){0,16}$/, - INTERNATIONAL_SWIFT_CODE: /^.{1,12}$/, }, STATE: { VERIFYING: 'VERIFYING', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 26054978c653..8cc900072757 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -462,8 +462,12 @@ const ONYXKEYS = { /** Set when we are loading the bulk change approver page */ IS_LOADING_BULK_CHANGE_APPROVER_PAGE: 'isLoadingBulkChangeApproverPage', - /** Set when we are loading the reimbursement countries used to decide whether to collect international deposit details */ - IS_LOADING_DEPOSIT_ACCOUNT_SETUP: 'isLoadingDepositAccountSetup', + /** + * Set when we are loading the reimbursement countries used to decide whether to collect international deposit + * details. RAM-only so an interrupted request never persists a stuck `true` and leaves the deposit account flow + * showing a loading state on the next launch. + */ + RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP: 'isLoadingDepositAccountSetup', /** Is report data loading? */ IS_LOADING_APP: 'isLoadingApp', @@ -1669,7 +1673,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_PAYMENT_METHODS]: boolean; [ONYXKEYS.IS_LOADING_SHARE_BANK_ACCOUNTS]: boolean; [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; - [ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP]: boolean; + [ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; [ONYXKEYS.IS_LOADING_AGENT_TEMPLATES]: boolean; diff --git a/src/components/SubStepForms/InternationalBankAccountDetailsStep.tsx b/src/components/SubStepForms/InternationalBankAccountDetailsStep.tsx new file mode 100644 index 000000000000..47a19f000b02 --- /dev/null +++ b/src/components/SubStepForms/InternationalBankAccountDetailsStep.tsx @@ -0,0 +1,94 @@ +/** + * Shared step for collecting international bank account details (IBAN + SWIFT/BIC), used by both the USD and the + * Corpay international personal bank account flows so they present the same copy and layout. + */ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import type {SubPageProps} from '@hooks/useSubPage/types'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; + +import React from 'react'; +import {View} from 'react-native'; + +const IBAN_INPUT_ID = 'iban'; +const SWIFT_CODE_INPUT_ID = 'swiftCode'; + +type InternationalBankAccountDetailsStepProps = Pick & { + /** The ID of the form */ + formID: TFormID; + + /** The validation function to call when the form is submitted */ + validate: (values: FormOnyxValues) => FormInputErrors; + + /** A function to call when the form is submitted */ + onSubmit: (values: FormOnyxValues) => void; + + /** Pre-filled IBAN value, if one is already known */ + ibanDefaultValue?: string; + + /** Pre-filled SWIFT/BIC code value, if one is already known */ + swiftCodeDefaultValue?: string; +}; + +function InternationalBankAccountDetailsStep({ + formID, + validate, + onSubmit, + ibanDefaultValue = '', + swiftCodeDefaultValue = '', + isEditing, +}: InternationalBankAccountDetailsStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + + return ( + + + {translate('bankAccount.internationalBankAccountDetailsTitle')} + {translate('bankAccount.internationalBankAccountDetailsSubtitle')} + + + + + ); +} + +InternationalBankAccountDetailsStep.displayName = 'InternationalBankAccountDetailsStep'; + +export default InternationalBankAccountDetailsStep; +export {IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID}; diff --git a/src/hooks/useLoadDepositAccountSetup.ts b/src/hooks/useLoadDepositAccountSetup.ts index 62c58f8f872c..180b43fde42f 100644 --- a/src/hooks/useLoadDepositAccountSetup.ts +++ b/src/hooks/useLoadDepositAccountSetup.ts @@ -1,27 +1,51 @@ import {openDepositAccountSetup} from '@userActions/BankAccounts'; import ONYXKEYS from '@src/ONYXKEYS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import {useEffect} from 'react'; +import {useRoute} from '@react-navigation/native'; +import {useEffect, useRef, useState} from 'react'; import useOnyx from './useOnyx'; /** - * Fetches the reimbursement countries for the user's policies (OpenDepositAccountSetup) when the personal bank - * account setup flow is opened and reports whether that fetch is in flight. + * Fetches the reimbursement countries for the user's policies (OpenDepositAccountSetup) and reports whether that + * fetch is in flight. * * useShouldCollectInternationalDepositDetails decides whether to collect international deposit details from this data, - * and that decision determines which steps the flow shows and where it starts. Because the substep hooks capture their - * starting position on the first render, callers must gate the flow on this loading state so the decision is made with - * the reimbursement data present rather than a stale/empty value. + * and that decision determines which steps the flow shows, so callers must gate rendering on the returned flag. + * + * Each page of the flow is a separate navigation (a fresh mount of the screen, not an in-place update), so the request + * can't simply be tied to the mount or it would fire on every next/back. Instead it fires when either: + * - this is the flow-entry mount (no subPage in the URL yet), so every new visit to the flow gets fresh countries, or + * - the loading flag is still undefined, which covers landing mid-flow via a deeplink or a reload. + * + * The flag is RAM-only, so a reload starts from undefined and refetches. It is set to `true` optimistically while the + * request is in flight and `false` once it settles. */ function useLoadDepositAccountSetup(): boolean { + const route = useRoute(); + // Frozen at the first render: useSubPage redirects the entry mount by setting the subPage param, which would + // otherwise flip this to false part-way through the same mount. + const [isFlowEntryMount] = useState(() => !(route.params as {subPage?: string} | undefined)?.subPage); + const [isLoadingDepositAccountSetup, metadata] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP); + const isReadingFlagFromOnyx = isLoadingOnyxValue(metadata); + const hasRequestedRef = useRef(false); + useEffect(() => { + // Until Onyx has read the key, an undefined flag only means "not read yet" - deciding now would fire a + // duplicate request on every mid-flow mount. + if (hasRequestedRef.current || isReadingFlagFromOnyx) { + return; + } + if (!isFlowEntryMount && isLoadingDepositAccountSetup !== undefined) { + return; + } + hasRequestedRef.current = true; openDepositAccountSetup(); - }, []); + }, [isFlowEntryMount, isLoadingDepositAccountSetup, isReadingFlagFromOnyx]); - const [isLoadingDepositAccountSetup = true] = useOnyx(ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP); - return isLoadingDepositAccountSetup; + return isLoadingDepositAccountSetup ?? true; } export default useLoadDepositAccountSetup; diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index 3e2f168571b7..55eba03b721a 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -1,3 +1,5 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; @@ -267,7 +269,63 @@ function getRequiredKYBDocuments(externalApiResponses: KYBVerificationResponses) return requiredDocuments; } +/** + * IBAN/SWIFT/BIC can come from either the dedicated `iban`/`swiftCode` fields filled in on the international bank + * account details step, or from `accountNumber`/`swiftBicCode`, which the Corpay bank-details step already collects + * for some countries. Either pairing satisfies the requirement, so both are checked. The Corpay fields are matched + * against the same formats because their own validation rules vary by country and don't guarantee an IBAN or a BIC. + */ +function hasValidInternationalBankAccountDetails(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string) { + const isIBANValid = CONST.BANK_ACCOUNT.REGEX.IBAN.test((iban ?? '').trim()) || CONST.BANK_ACCOUNT.REGEX.IBAN.test((accountNumber ?? '').trim()); + const isSwiftCodeValid = CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test((swiftCode ?? '').trim()) || CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test((swiftBicCode ?? '').trim()); + return isIBANValid && isSwiftCodeValid; +} + +/** + * Resolves the IBAN/SWIFT values to display or submit, falling back to `accountNumber`/`swiftBicCode` when the + * dedicated `iban`/`swiftCode` fields weren't collected (e.g. the international bank account details step was + * skipped because the Corpay bank-details step already gathered equivalent values). + */ +function getInternationalBankAccountDetailsValues(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string): {iban: string; swiftCode: string} { + let resolvedIBAN = iban ?? ''; + if (!resolvedIBAN && accountNumber && CONST.BANK_ACCOUNT.REGEX.IBAN.test(accountNumber.trim())) { + resolvedIBAN = accountNumber; + } + + let resolvedSwiftCode = swiftCode ?? ''; + if (!resolvedSwiftCode && swiftBicCode && CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(swiftBicCode.trim())) { + resolvedSwiftCode = swiftBicCode; + } + + return { + iban: resolvedIBAN, + swiftCode: resolvedSwiftCode, + }; +} + +/** + * Shared IBAN/SWIFT format validation for the international bank account details step, used by both the USD and + * international personal bank account flows. + */ +function getInternationalBankAccountDetailsErrors( + iban: string | undefined, + swiftCode: string | undefined, + translate: LocaleContextProps['translate'], +): Partial> { + const errors: Partial> = {}; + if (iban && !CONST.BANK_ACCOUNT.REGEX.IBAN.test(iban.trim())) { + errors.iban = translate('bankAccount.error.iban'); + } + if (swiftCode && !CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(swiftCode.trim())) { + errors.swiftCode = translate('bankAccount.error.swiftCode'); + } + return errors; +} + export { + hasValidInternationalBankAccountDetails, + getInternationalBankAccountDetailsValues, + getInternationalBankAccountDetailsErrors, getBankAccountSearchLabel, isFilterableBankAccount, getDefaultCompanyWebsite, diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index e943ac41f053..58c7af86ae35 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -205,7 +205,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.IS_LOADING_APP, ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE, ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE, - ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP, ONYXKEYS.IS_LOADING_PAYMENT_METHODS, ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW, ONYXKEYS.IS_LOADING_REPORT_DATA, @@ -292,6 +291,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.RAM_ONLY_ARE_TRANSLATIONS_LOADING, ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, + ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE, diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index cbbfc8f75158..2aabddb4b404 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -25,6 +25,7 @@ import type AskForCorpaySignerInformationParams from '@libs/API/parameters/AskFo import type {SaveCorpayOnboardingCompanyDetails} from '@libs/API/parameters/SaveCorpayOnboardingCompanyDetailsParams'; import type SaveCorpayOnboardingDirectorInformationParams from '@libs/API/parameters/SaveCorpayOnboardingDirectorInformationParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {getInternationalBankAccountDetailsValues} from '@libs/BankAccountUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -175,18 +176,18 @@ function openPersonalBankAccountSetupView({ * collect international deposit details (IBAN/SWIFT), and this data is intentionally not part of the policy summary. */ function openDepositAccountSetup() { - const onyxData: OnyxData = { + const onyxData: OnyxData = { optimisticData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP, + key: ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP, value: true, }, ], finallyData: [ { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.IS_LOADING_DEPOSIT_ACCOUNT_SETUP, + key: ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP, value: false, }, ], @@ -1591,8 +1592,13 @@ function unshareBankAccount(bankAccountID: number, ownerEmail: string) { } function createCorpayBankAccountForWalletFlow(data: InternationalBankAccountForm, classification: string, destinationCountry: string, preferredMethod: string) { + // The international details step is skipped when the Corpay bank-details step already collected equivalent values, + // so resolve them here to guarantee the IBAN/SWIFT are always sent when we have them, whichever step captured them. + const {iban, swiftCode} = getInternationalBankAccountDetailsValues(data.iban, data.swiftCode, data.accountNumber, data.swiftBicCode); const inputData = { ...data, + iban, + swiftCode, classification, destinationCountry, preferredMethod, diff --git a/src/pages/AddPersonalBankAccountPage/index.tsx b/src/pages/AddPersonalBankAccountPage/index.tsx index aa5b436c3285..f417a9768b81 100644 --- a/src/pages/AddPersonalBankAccountPage/index.tsx +++ b/src/pages/AddPersonalBankAccountPage/index.tsx @@ -8,6 +8,7 @@ import useShouldCollectInternationalDepositDetails from '@hooks/useShouldCollect import useSubPage from '@hooks/useSubPage'; import type {SubPageProps} from '@hooks/useSubPage/types'; +import {hasValidInternationalBankAccountDetails} from '@libs/BankAccountUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import {formatE164PhoneNumber} from '@libs/LoginUtils'; import getActiveTabName from '@libs/Navigation/helpers/getActiveTabName'; @@ -148,9 +149,10 @@ function AddPersonalBankAccountPage() { }; // Skip the international bank account details page when no collecting policy needs international details for a US - // bank account, or when we already have them: the account number is an IBAN and a SWIFT/BIC code is set. - const isAccountNumberIBAN = CONST.BANK_ACCOUNT.REGEX.IBAN.test((personalBankAccount?.accountNumber ?? '').trim()); - const shouldSkipInternationalBankAccountDetails = !shouldCollectInternationalDepositDetails || (isAccountNumberIBAN && !!personalBankAccount?.swiftCode); + // bank account, or when this page already collected a valid IBAN and SWIFT/BIC code. Unlike the international flow, + // a US account number is never an IBAN, so this page is the only source of those values here. + const shouldSkipInternationalBankAccountDetails = + !shouldCollectInternationalDepositDetails || hasValidInternationalBankAccountDetails(personalBankAccount?.iban, personalBankAccount?.swiftCode); const pages = isManual ? pagesWithManualSetup : pagesWithPlaid; const skipPages = getSkippedStepsPersonalInfo(privatePersonalDetails, shouldSkipInternationalBankAccountDetails) diff --git a/src/pages/AddPersonalBankAccountPage/substeps/InternationalBankAccountDetailsStep.tsx b/src/pages/AddPersonalBankAccountPage/substeps/InternationalBankAccountDetailsStep.tsx index 281c8ec7ba40..54c3cbf42694 100644 --- a/src/pages/AddPersonalBankAccountPage/substeps/InternationalBankAccountDetailsStep.tsx +++ b/src/pages/AddPersonalBankAccountPage/substeps/InternationalBankAccountDetailsStep.tsx @@ -1,22 +1,17 @@ /** * Personal-info substep for collecting international bank account details (IBAN + SWIFT/BIC) when adding a personal bank account. */ -import FormProvider from '@components/Form/FormProvider'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; +import InternationalBankAccountDetailsForm from '@components/SubStepForms/InternationalBankAccountDetailsStep'; -import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePersonalBankAccountDetailsFormSubmit from '@hooks/usePersonalBankAccountDetailsFormSubmit'; import type {SubPageProps} from '@hooks/useSubPage/types'; -import useThemeStyles from '@hooks/useThemeStyles'; +import {getInternationalBankAccountDetailsErrors} from '@libs/BankAccountUtils'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; -import InternationalBankAccountFields from '@pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields'; -import {getInternationalBankAccountDetailsErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; - import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalBankAccountForm'; @@ -31,8 +26,6 @@ function InternationalBankAccountDetailsStep({onNext, isEditing}: InternationalB const [bankAccountPersonalDetails] = useOnyx(ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_FORM_DRAFT); const {translate} = useLocalize(); - const styles = useThemeStyles(); - const {inputCallbackRef} = useAutoFocusInput(); const validate = (values: FormOnyxValues): FormInputErrors => ({ ...getFieldRequiredErrors(values, STEP_FIELDS, translate), @@ -46,22 +39,14 @@ function InternationalBankAccountDetailsStep({onNext, isEditing}: InternationalB }); return ( - - {translate('bankAccount.internationalBankAccountDetailsTitle')} - {translate('bankAccount.internationalBankAccountDetailsSubtitle')} - - + onSubmit={handleSubmit} + isEditing={isEditing} + ibanDefaultValue={bankAccountPersonalDetails?.iban} + swiftCodeDefaultValue={bankAccountPersonalDetails?.swiftCode} + /> ); } diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields.tsx deleted file mode 100644 index bebc71bb630e..000000000000 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Shared IBAN + SWIFT/BIC input fields, used by both the personal and Corpay international bank account flows. - */ -import InputWrapper from '@components/Form/InputWrapper'; -import TextInput from '@components/TextInput'; - -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import CONST from '@src/CONST'; - -import type {TextInput as RNTextInput} from 'react-native'; - -import React from 'react'; - -const IBAN_INPUT_ID = 'iban'; -const SWIFT_CODE_INPUT_ID = 'swiftCode'; - -type InternationalBankAccountFieldsProps = { - /** Pre-filled IBAN value, if one is already known */ - ibanDefaultValue?: string; - - /** Pre-filled SWIFT/BIC code value, if one is already known */ - swiftCodeDefaultValue?: string; - - /** - * Whether keystrokes are persisted to the form draft immediately. This must be false while editing so the - * change is only committed to the draft when the user presses confirm, not on every keystroke. - */ - shouldSaveDraft: boolean; - - /** Ref callback used to auto-focus the IBAN input, if the consumer needs it */ - inputCallbackRef?: (ref: RNTextInput | null) => void; -}; - -function InternationalBankAccountFields({ibanDefaultValue = '', swiftCodeDefaultValue = '', shouldSaveDraft, inputCallbackRef}: InternationalBankAccountFieldsProps) { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - - return ( - <> - - - - ); -} - -InternationalBankAccountFields.displayName = 'InternationalBankAccountFields'; - -export default InternationalBankAccountFields; -export {IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID}; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx index a6e9299dd877..e9aa01219a94 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -10,6 +10,7 @@ import useSubPage from '@hooks/useSubPage'; import {clearCorpayBankAccountFields} from '@libs/actions/BankAccounts'; import {clearDraftValues} from '@libs/actions/FormActions'; +import {hasValidInternationalBankAccountDetails} from '@libs/BankAccountUtils'; import getActiveTabName from '@libs/Navigation/helpers/getActiveTabName'; import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import Navigation from '@libs/Navigation/Navigation'; @@ -41,7 +42,7 @@ import Confirmation from './subPages/Confirmation'; import CountrySelection from './subPages/CountrySelection'; import InternationalBankAccountDetails from './subPages/InternationalBankAccountDetails'; import Success from './subPages/Success'; -import {getFieldsMap, getInitialPersonalDetailsValues, getInitialSubstep, getSubstepValues, hasValidInternationalBankAccountDetails, testValidation} from './utils'; +import {getFieldsMap, getInitialPersonalDetailsValues, getInitialSubstep, getSubstepValues, testValidation} from './utils'; type InternationalDepositAccountContentProps = { privatePersonalDetails: OnyxEntry; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index 56cdf92996aa..dcc1560189ac 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -14,10 +14,10 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getInternationalBankAccountDetailsValues, hasValidInternationalBankAccountDetails} from '@libs/BankAccountUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; -import {getInternationalBankAccountDetailsValues, hasValidInternationalBankAccountDetails} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; import {clearReimbursementAccountBankCreation, createCorpayBankAccountForWalletFlow, hideBankAccountErrors} from '@userActions/BankAccounts'; @@ -66,13 +66,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp }; const getDataAndGoToNextStep = (values: FormOnyxValues) => { - const dataToSubmit = {...formValues, ...values}; - // The international bank account details step may have been skipped, in which case iban/swiftCode are only - // available via accountNumber/swiftBicCode. Resolve them explicitly so the request always includes them. - const {iban, swiftCode} = getInternationalBankAccountDetailsValues(dataToSubmit.iban, dataToSubmit.swiftCode, dataToSubmit.accountNumber, dataToSubmit.swiftBicCode); - dataToSubmit.iban = iban; - dataToSubmit.swiftCode = swiftCode; - createCorpayBankAccountForWalletFlow(dataToSubmit, corpayFields?.classification ?? '', corpayFields?.destinationCountry ?? '', corpayFields?.preferredMethod ?? ''); + createCorpayBankAccountForWalletFlow({...formValues, ...values}, corpayFields?.classification ?? '', corpayFields?.destinationCountry ?? '', corpayFields?.preferredMethod ?? ''); }; useEffect(() => { diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx index 2a1423b1285a..0d92e10f6717 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx @@ -1,30 +1,25 @@ /** * Sub-page for collecting international bank account details (IBAN + SWIFT/BIC) in the Corpay deposit-account flow. */ -import FormProvider from '@components/Form/FormProvider'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Text from '@components/Text'; +import InternationalBankAccountDetailsForm, {IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID} from '@components/SubStepForms/InternationalBankAccountDetailsStep'; import useInternationalBankAccountFormSubmit from '@hooks/useInternationalBankAccountFormSubmit'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; +import {getInternationalBankAccountDetailsErrors, getInternationalBankAccountDetailsValues} from '@libs/BankAccountUtils'; import {getFieldRequiredErrors} from '@libs/ValidationUtils'; -import InternationalBankAccountFields, {IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID} from '@pages/settings/Wallet/InternationalDepositAccount/InternationalBankAccountFields'; import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; -import {getInternationalBankAccountDetailsErrors, getInternationalBankAccountDetailsValues} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; -import {View} from 'react-native'; const STEP_FIELDS = [IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID]; function InternationalBankAccountDetails({isEditing, onNext, formValues}: CustomSubPageProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const handleSubmit = useInternationalBankAccountFormSubmit({ fieldIds: STEP_FIELDS, @@ -47,23 +42,14 @@ function InternationalBankAccountDetails({isEditing, onNext, formValues}: Custom }); return ( - - - {translate('bankAccount.internationalBankAccountDetails')} - - - + onSubmit={handleSubmit} + isEditing={isEditing} + ibanDefaultValue={ibanDefaultValue} + swiftCodeDefaultValue={swiftCodeDefaultValue} + /> ); } diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts index 0f808ffa2b76..8385a8e9debe 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts +++ b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts @@ -1,6 +1,7 @@ import type {FormOnyxValues} from '@components/Form/types'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {hasValidInternationalBankAccountDetails} from '@libs/BankAccountUtils'; import {addErrorMessage} from '@libs/ErrorUtils'; import {getCurrentAddress} from '@libs/PersonalDetailsUtils'; @@ -114,39 +115,6 @@ function testValidation(values: InternationalBankAccountForm, fieldsMap: CorpayF return true; } -/** - * IBAN/SWIFT/BIC can come from either the dedicated `iban`/`swiftCode` fields filled in on the international bank - * account details step, or from `accountNumber`/`swiftBicCode`, which the Corpay bank-details step already collects - * for some countries. Either pairing satisfies the requirement, so both are checked. - */ -function hasValidInternationalBankAccountDetails(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string) { - const isIBANValid = CONST.BANK_ACCOUNT.REGEX.IBAN.test((iban ?? '').trim()) || CONST.BANK_ACCOUNT.REGEX.IBAN.test((accountNumber ?? '').trim()); - const isSwiftCodeValid = (!!swiftCode && CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(swiftCode.trim())) || !!swiftBicCode; - return isIBANValid && isSwiftCodeValid; -} - -/** - * Resolves the IBAN/SWIFT values to display or submit, falling back to `accountNumber`/`swiftBicCode` when the - * dedicated `iban`/`swiftCode` fields weren't collected (e.g. the international bank account details step was - * skipped because the Corpay bank-details step already gathered equivalent values). - */ -function getInternationalBankAccountDetailsValues(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string): {iban: string; swiftCode: string} { - let resolvedIBAN = iban ?? ''; - if (!resolvedIBAN && accountNumber && CONST.BANK_ACCOUNT.REGEX.IBAN.test(accountNumber.trim())) { - resolvedIBAN = accountNumber; - } - - let resolvedSwiftCode = swiftCode ?? ''; - if (!resolvedSwiftCode) { - resolvedSwiftCode = swiftBicCode ?? ''; - } - - return { - iban: resolvedIBAN, - swiftCode: resolvedSwiftCode, - }; -} - function getInitialSubstep( values: InternationalBankAccountForm, fieldsMap: Record, CorpayFieldsMap>, @@ -192,33 +160,4 @@ function getValidationErrors(values: FormOnyxValues> { - const errors: Partial> = {}; - if (iban && !CONST.BANK_ACCOUNT.REGEX.IBAN.test(iban.trim())) { - errors.iban = translate('bankAccount.error.iban'); - } - if (swiftCode && !CONST.BANK_ACCOUNT.REGEX.INTERNATIONAL_SWIFT_CODE.test(swiftCode.trim())) { - errors.swiftCode = translate('bankAccount.error.swiftCode'); - } - return errors; -} - -export { - getFieldsMap, - getSubstepValues, - getInitialPersonalDetailsValues, - getInitialSubstep, - testValidation, - getValidationErrors, - getInternationalBankAccountDetailsErrors, - hasValidInternationalBankAccountDetails, - getInternationalBankAccountDetailsValues, -}; +export {getFieldsMap, getSubstepValues, getInitialPersonalDetailsValues, getInitialSubstep, testValidation, getValidationErrors}; diff --git a/src/setup/index.ts b/src/setup/index.ts index 2d0f25d0f3aa..520879594908 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -82,6 +82,7 @@ export default function () { ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD, ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE, ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER, + ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP, ], }); From 8e6f1ea833a3510191bd1e9d883f66fb25e1a78c Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 14 Aug 2026 12:08:03 +0700 Subject: [PATCH 202/466] spell check fix --- src/hooks/useLoadDepositAccountSetup.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useLoadDepositAccountSetup.ts b/src/hooks/useLoadDepositAccountSetup.ts index 180b43fde42f..1a816bf580a6 100644 --- a/src/hooks/useLoadDepositAccountSetup.ts +++ b/src/hooks/useLoadDepositAccountSetup.ts @@ -20,8 +20,8 @@ import useOnyx from './useOnyx'; * - this is the flow-entry mount (no subPage in the URL yet), so every new visit to the flow gets fresh countries, or * - the loading flag is still undefined, which covers landing mid-flow via a deeplink or a reload. * - * The flag is RAM-only, so a reload starts from undefined and refetches. It is set to `true` optimistically while the - * request is in flight and `false` once it settles. + * The flag is RAM-only, so a reload starts from undefined and fetches again. It is set to `true` optimistically while + * the request is in flight and `false` once it settles. */ function useLoadDepositAccountSetup(): boolean { const route = useRoute(); From 9d2f6c28a98d31f12097e57ae05600c6e9a1fc07 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:45:24 +0700 Subject: [PATCH 203/466] migrate TRAVEL_WORKSPACE_CONFIRMATION --- config/eslint/eslint.seatbelt.tsv | 2 +- src/ROUTES.ts | 15 +++++++-------- src/SCREENS.ts | 2 +- .../ModalStackNavigators/index.tsx | 2 +- .../Navigation/linkingConfig/OldRoutes.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 2 +- src/libs/Navigation/types.ts | 5 +---- src/libs/Permissions.ts | 2 +- src/pages/Travel/DynamicTravelUpgrade.tsx | 5 +++-- ...micWorkspaceConfirmationForTravelPage.tsx} | 19 +++++++------------ tests/navigation/getMatchingNewRouteTest.ts | 5 +++++ .../workspaceConfirmationEntryScreensTest.ts | 2 +- 12 files changed, 30 insertions(+), 32 deletions(-) rename src/pages/Travel/{WorkspaceConfirmationForTravelPage.tsx => DynamicWorkspaceConfirmationForTravelPage.tsx} (77%) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index da8748ebe2b7..09d352d6e132 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1053,8 +1053,8 @@ "../../src/pages/Travel/DynamicDomainSelectorPage.tsx" "no-restricted-imports" 1 "../../src/pages/Travel/DynamicTravelTerms.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/Travel/DynamicTravelUpgrade.tsx" "no-restricted-imports" 1 +"../../src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/Travel/TravelUpgrade.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/Travel/WorkspaceConfirmationForTravelPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/TripChatNameEditPage.tsx" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 "../../src/pages/UnreportedExpenseListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ValidateLoginPage/index.web.tsx" "react-hooks/set-state-in-effect" 1 diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f6fa9525aea6..7be3a28066d4 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -175,11 +175,15 @@ const DYNAMIC_ROUTES = { }, WORKSPACE_CONFIRMATION_CURRENCY: { path: 'currency', - entryScreens: [SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.STEP_UPGRADE], + entryScreens: [SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.STEP_UPGRADE], }, WORKSPACE_CONFIRMATION_PLAN_TYPE: { path: 'plan-type', - entryScreens: [SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.STEP_UPGRADE], + entryScreens: [SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.STEP_UPGRADE], + }, + TRAVEL_WORKSPACE_CONFIRMATION: { + path: 'workspace-confirmation', + entryScreens: [SCREENS.TRAVEL.DYNAMIC_UPGRADE], }, MIGRATED_USER_WELCOME: { path: 'migrated-user-welcome', @@ -355,7 +359,7 @@ const DYNAMIC_ROUTES = { SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.WORKSPACE_CONFIRMATION.OWNER_SELECTOR, SCREENS.WORKSPACE_DUPLICATE.ROOT, - SCREENS.TRAVEL.WORKSPACE_CONFIRMATION, + SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.STEP_UPGRADE, SCREENS.REPORT_DETAILS.DYNAMIC_ROOT, ], @@ -3820,11 +3824,6 @@ const ROUTES = { return getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}/${pnr}/${sequenceIndex}`, backTo); }, }, - TRAVEL_WORKSPACE_CONFIRMATION: { - route: 'travel/upgrade/workspace/confirmation', - - getRoute: (backTo?: string) => getUrlWithBackToParam(`travel/upgrade/workspace/confirmation`, backTo), - }, TRAVEL_VERIFY_ACCOUNT: { route: `travel/${VERIFY_ACCOUNT}`, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index e759c4b922ae..6b39ee33e8a7 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -35,7 +35,7 @@ const SCREENS = { TRIP_SUMMARY: 'Travel_TripSummary', TRIP_DETAILS: 'Travel_TripDetails', DYNAMIC_PUBLIC_DOMAIN_ERROR: 'Dynamic_Travel_PublicDomainError', - WORKSPACE_CONFIRMATION: 'Travel_WorkspaceConfirmation', + DYNAMIC_WORKSPACE_CONFIRMATION: 'Dynamic_Travel_WorkspaceConfirmation', TRAVEL_DOT_LINK_WEB_VIEW: 'Travel_DotLinkWebView', VERIFY_ACCOUNT: 'Travel_VerifyAccount', ENABLE: 'Travel_Enable', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index a0a72f581889..b236b4c315a4 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -239,7 +239,7 @@ const TravelModalStackNavigator = createModalStackNavigator require('../../../../pages/Travel/TripDetailsPage').default, [SCREENS.TRAVEL.DYNAMIC_DOMAIN_PERMISSION_INFO]: () => require('../../../../pages/Travel/DynamicDomainPermissionInfoPage').default, [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: () => require('../../../../pages/Travel/DynamicPublicDomainErrorPage').default, - [SCREENS.TRAVEL.WORKSPACE_CONFIRMATION]: () => require('../../../../pages/Travel/WorkspaceConfirmationForTravelPage').default, + [SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION]: () => require('../../../../pages/Travel/DynamicWorkspaceConfirmationForTravelPage').default, [SCREENS.TRAVEL.VERIFY_ACCOUNT]: () => require('../../../../pages/Travel/VerifyAccountPage').default, [SCREENS.TRAVEL.ENABLE]: () => require('../../../../pages/Travel/EnableTravel').default, }); diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index 968752c401e2..4e5f3ecf3908 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -73,6 +73,7 @@ const oldRoutes: Record = { '/workspaces/$1/accounting/quickbooks-online/advanced/quickbooks-online-autosync/quickbooks-online-accounting-method', '/workspaces/*/connections/quickbooks-online/advanced/autosync': '/workspaces/$1/accounting/quickbooks-online/advanced/quickbooks-online-autosync', '/travel/upgrade': '/travel/travel-upgrade', + '/travel/upgrade/workspace/confirmation': '/travel/travel-upgrade/workspace-confirmation', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account': '/workspaces/$1/accounting/quickbooks-desktop/export/qbd-company-card-expense-account', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account-select': '/workspaces/$1/accounting/quickbooks-desktop/export/qbd-company-card-expense-account-select', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account/account-select': diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index de84f77a078d..b0d30d2fcd27 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1930,7 +1930,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.TRAVEL.DYNAMIC_DOMAIN_PERMISSION_INFO]: DYNAMIC_ROUTES.TRAVEL_DOMAIN_PERMISSION_INFO.path, [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: DYNAMIC_ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR.path, - [SCREENS.TRAVEL.WORKSPACE_CONFIRMATION]: ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.route, + [SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION]: DYNAMIC_ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.path, [SCREENS.TRAVEL.VERIFY_ACCOUNT]: ROUTES.TRAVEL_VERIFY_ACCOUNT.route, [SCREENS.TRAVEL.ENABLE]: {path: ROUTES.TRAVEL_ENABLE.route, exact: true}, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3cef807e1afb..8f4db4facbe3 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2795,10 +2795,7 @@ type TravelNavigatorParamList = { domain: string; policyID?: string; }; - [SCREENS.TRAVEL.WORKSPACE_CONFIRMATION]: { - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; - }; + [SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION]: undefined; [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: { policyID?: string; }; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 66cc2e2cd991..f664626c1bcf 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -6,7 +6,7 @@ import type {OnyxEntry} from 'react-native-onyx'; // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { - return !!betas?.includes(CONST.BETAS.ALL); + return true; } /** diff --git a/src/pages/Travel/DynamicTravelUpgrade.tsx b/src/pages/Travel/DynamicTravelUpgrade.tsx index 5837b88eaf90..6887cf8601cf 100644 --- a/src/pages/Travel/DynamicTravelUpgrade.tsx +++ b/src/pages/Travel/DynamicTravelUpgrade.tsx @@ -9,6 +9,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getActivePolicies, isPaidGroupPolicy} from '@libs/PolicyUtils'; @@ -17,7 +18,7 @@ import UpgradeIntro from '@pages/workspace/upgrade/UpgradeIntro'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -34,7 +35,7 @@ function DynamicTravelUpgrade() { const isUpgraded = groupPaidPolicies.length > 0; const openWorkspaceConfirmation = () => { - Navigation.navigate(ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.getRoute(Navigation.getActiveRoute())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.path)); }; return ( diff --git a/src/pages/Travel/WorkspaceConfirmationForTravelPage.tsx b/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx similarity index 77% rename from src/pages/Travel/WorkspaceConfirmationForTravelPage.tsx rename to src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx index ddf5f8b96581..42a618d9599b 100644 --- a/src/pages/Travel/WorkspaceConfirmationForTravelPage.tsx +++ b/src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx @@ -4,27 +4,21 @@ import type {WorkspaceConfirmationSubmitFunctionParams} from '@components/Worksp import useActivePolicy from '@hooks/useActivePolicy'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; import useOnyx from '@hooks/useOnyx'; import {createDraftWorkspace, createWorkspace} from '@libs/actions/Policy/Policy'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; -import type {TravelNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; - -import type {StackScreenProps} from '@react-navigation/stack'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import React from 'react'; -type WorkspaceConfirmationForTravelPageProps = StackScreenProps; - -function WorkspaceConfirmationForTravelPage({route}: WorkspaceConfirmationForTravelPageProps) { +function DynamicWorkspaceConfirmationForTravelPage() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); @@ -34,9 +28,10 @@ function WorkspaceConfirmationForTravelPage({route}: WorkspaceConfirmationForTra const activePolicy = useActivePolicy(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const hasActiveAdminPolicies = useHasActiveAdminPolicies(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.path); const goBack = () => { - Navigation.goBack(route.params?.backTo ?? createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_UPGRADE.path, ROUTES.TRAVEL_MY_TRIPS.route)); + Navigation.goBack(backPath); }; const onSubmit = (params: WorkspaceConfirmationSubmitFunctionParams) => { @@ -70,7 +65,7 @@ function WorkspaceConfirmationForTravelPage({route}: WorkspaceConfirmationForTra return ( { expect(getMatchingNewRoute('/travel/upgrade?backTo=/home')).toBe('/travel/travel-upgrade?backTo=/home'); }); + it('redirects old travel workspace confirmation path to dynamic route', () => { + expect(getMatchingNewRoute('/travel/upgrade/workspace/confirmation')).toBe('/travel/travel-upgrade/workspace-confirmation'); + expect(getMatchingNewRoute('/travel/upgrade/workspace/confirmation?backTo=/home')).toBe('/travel/travel-upgrade/workspace-confirmation?backTo=/home'); + }); + it('redirects legacy new task flat routes to the new nested dynamic routes', () => { expect(getMatchingNewRoute('/new/task/details')).toBe('/task-details'); expect(getMatchingNewRoute('/new/task')).toBe('/task-details/task-confirm'); diff --git a/tests/navigation/workspaceConfirmationEntryScreensTest.ts b/tests/navigation/workspaceConfirmationEntryScreensTest.ts index 1ea15d53321c..526d389139fb 100644 --- a/tests/navigation/workspaceConfirmationEntryScreensTest.ts +++ b/tests/navigation/workspaceConfirmationEntryScreensTest.ts @@ -12,7 +12,7 @@ describe('WORKSPACE_CONFIRMATION_CURRENCY', () => { it('allows opening the currency selector from every screen that hosts the confirm workspace form', () => { expect(currencyEntryScreens).toEqual( - expect.arrayContaining([SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.STEP_UPGRADE]), + expect.arrayContaining([SCREENS.WORKSPACE_CONFIRMATION.DYNAMIC_ROOT, SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION, SCREENS.MONEY_REQUEST.STEP_UPGRADE]), ); }); From 314dacf7025c704d5e1985764fc3d340723f55b7 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:03:26 +0700 Subject: [PATCH 204/466] migrate TRAVEL_VERIFY_ACCOUNT --- src/ROUTES.ts | 11 ++++++----- src/SCREENS.ts | 2 +- src/components/BookTravelButton.tsx | 4 ++-- .../AppNavigator/ModalStackNavigators/index.tsx | 4 ++-- src/libs/Navigation/linkingConfig/OldRoutes.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 2 +- src/libs/Navigation/types.ts | 5 +---- ...untPage.tsx => DynamicVerifyAccountPage.tsx} | 17 ++++++++++------- src/pages/Travel/EnableTravel/index.tsx | 5 +++-- tests/navigation/getMatchingNewRouteTest.ts | 5 +++++ tests/unit/components/BookTravelButtonTest.tsx | 5 +++-- 11 files changed, 35 insertions(+), 26 deletions(-) rename src/pages/Travel/{VerifyAccountPage.tsx => DynamicVerifyAccountPage.tsx} (75%) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7be3a28066d4..f200b5d48801 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1172,6 +1172,12 @@ const DYNAMIC_ROUTES = { path: 'travel-upgrade', entryScreens: [SCREENS.TRAVEL.MY_TRIPS, SCREENS.WORKSPACE.TRAVEL, SCREENS.SEARCH.ROOT], }, + TRAVEL_VERIFY_ACCOUNT: { + path: 'travel-verify-account', + entryScreens: [SCREENS.TRAVEL.MY_TRIPS, SCREENS.WORKSPACE.TRAVEL, SCREENS.SEARCH.ROOT, SCREENS.TRAVEL.ENABLE], + getRoute: (policyID?: string) => getUrlWithParams('travel-verify-account', {policyID}), + queryParams: ['policyID'], + }, REPORT_CHANGE_APPROVER: { path: 'change-approver', entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], @@ -3824,11 +3830,6 @@ const ROUTES = { return getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}/${pnr}/${sequenceIndex}`, backTo); }, }, - TRAVEL_VERIFY_ACCOUNT: { - route: `travel/${VERIFY_ACCOUNT}`, - - getRoute: (domain?: string, policyID?: string, backTo?: string) => getUrlWithBackToParam(getUrlWithParams(`travel/${VERIFY_ACCOUNT}`, {domain, policyID}), backTo), - }, TRAVEL_ENABLE: { route: 'travel/enable/:policyID/:subPage?/:action?', getRoute: (policyID: string, subPage?: string, action?: 'edit') => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6b39ee33e8a7..83aede6716e1 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -37,7 +37,7 @@ const SCREENS = { DYNAMIC_PUBLIC_DOMAIN_ERROR: 'Dynamic_Travel_PublicDomainError', DYNAMIC_WORKSPACE_CONFIRMATION: 'Dynamic_Travel_WorkspaceConfirmation', TRAVEL_DOT_LINK_WEB_VIEW: 'Travel_DotLinkWebView', - VERIFY_ACCOUNT: 'Travel_VerifyAccount', + DYNAMIC_VERIFY_ACCOUNT: 'Dynamic_Travel_VerifyAccount', ENABLE: 'Travel_Enable', MISSING_PERSONAL_DETAILS_CONFIRM_VALIDATE_CODE: 'Travel_MissingPersonalDetails_ConfirmValidateCode', }, diff --git a/src/components/BookTravelButton.tsx b/src/components/BookTravelButton.tsx index 75f6e01ded7b..7248ca86253f 100644 --- a/src/components/BookTravelButton.tsx +++ b/src/components/BookTravelButton.tsx @@ -167,7 +167,7 @@ function BookTravelButton({ // Legacy request-access path for not-yet-provisioned workspaces when the self-serve provisioning beta is off. if (!isPolicyProvisioned && !isBetaEnabled(CONST.BETAS.IS_TRAVEL_VERIFIED)) { if (!isUserValidated) { - Navigation.navigate(ROUTES.TRAVEL_VERIFY_ACCOUNT.getRoute(undefined, activePolicyID, Navigation.getActiveRoute())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT.getRoute(activePolicyID))); return; } if (shouldShowVerifyAccountModal) { @@ -198,7 +198,7 @@ function BookTravelButton({ // replaced with the verify URL a render later. if (!isUserValidated) { setTravelProvisioningNextStep(enableTravelRoute); - Navigation.navigate(ROUTES.TRAVEL_VERIFY_ACCOUNT.getRoute(undefined, activePolicyID, Navigation.getActiveRoute())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT.getRoute(activePolicyID))); return; } Navigation.navigate(enableTravelRoute); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index b236b4c315a4..f8ba589f887c 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -95,7 +95,7 @@ const OPTIONS_PER_SCREEN: Partial [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: { animation: Animations.NONE, }, - [SCREENS.TRAVEL.VERIFY_ACCOUNT]: { + [SCREENS.TRAVEL.DYNAMIC_VERIFY_ACCOUNT]: { animationTypeForReplace: 'push', }, [SCREENS.MULTIFACTOR_AUTHENTICATION.REVOKE]: { @@ -240,7 +240,7 @@ const TravelModalStackNavigator = createModalStackNavigator require('../../../../pages/Travel/DynamicDomainPermissionInfoPage').default, [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: () => require('../../../../pages/Travel/DynamicPublicDomainErrorPage').default, [SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION]: () => require('../../../../pages/Travel/DynamicWorkspaceConfirmationForTravelPage').default, - [SCREENS.TRAVEL.VERIFY_ACCOUNT]: () => require('../../../../pages/Travel/VerifyAccountPage').default, + [SCREENS.TRAVEL.DYNAMIC_VERIFY_ACCOUNT]: () => require('../../../../pages/Travel/DynamicVerifyAccountPage').default, [SCREENS.TRAVEL.ENABLE]: () => require('../../../../pages/Travel/EnableTravel').default, }); diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index 4e5f3ecf3908..606d493bb04a 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -74,6 +74,7 @@ const oldRoutes: Record = { '/workspaces/*/connections/quickbooks-online/advanced/autosync': '/workspaces/$1/accounting/quickbooks-online/advanced/quickbooks-online-autosync', '/travel/upgrade': '/travel/travel-upgrade', '/travel/upgrade/workspace/confirmation': '/travel/travel-upgrade/workspace-confirmation', + '/travel/verify-account': '/travel/travel-verify-account', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account': '/workspaces/$1/accounting/quickbooks-desktop/export/qbd-company-card-expense-account', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account-select': '/workspaces/$1/accounting/quickbooks-desktop/export/qbd-company-card-expense-account-select', '/workspaces/*/accounting/quickbooks-desktop/export/company-card-expense-account/account-select': diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index b0d30d2fcd27..b24cba22c03e 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1931,7 +1931,7 @@ const config: LinkingOptions['config'] = { [SCREENS.TRAVEL.DYNAMIC_DOMAIN_PERMISSION_INFO]: DYNAMIC_ROUTES.TRAVEL_DOMAIN_PERMISSION_INFO.path, [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: DYNAMIC_ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR.path, [SCREENS.TRAVEL.DYNAMIC_WORKSPACE_CONFIRMATION]: DYNAMIC_ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.path, - [SCREENS.TRAVEL.VERIFY_ACCOUNT]: ROUTES.TRAVEL_VERIFY_ACCOUNT.route, + [SCREENS.TRAVEL.DYNAMIC_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT.path, [SCREENS.TRAVEL.ENABLE]: {path: ROUTES.TRAVEL_ENABLE.route, exact: true}, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 8f4db4facbe3..465fbcffe6d9 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2800,11 +2800,8 @@ type TravelNavigatorParamList = { policyID?: string; }; [SCREENS.TRAVEL.DYNAMIC_UPGRADE]: undefined; - [SCREENS.TRAVEL.VERIFY_ACCOUNT]: { - domain?: string; + [SCREENS.TRAVEL.DYNAMIC_VERIFY_ACCOUNT]: { policyID?: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: Routes; }; [SCREENS.TRAVEL.ENABLE]: {policyID: string; subPage?: string; action?: 'edit'}; }; diff --git a/src/pages/Travel/VerifyAccountPage.tsx b/src/pages/Travel/DynamicVerifyAccountPage.tsx similarity index 75% rename from src/pages/Travel/VerifyAccountPage.tsx rename to src/pages/Travel/DynamicVerifyAccountPage.tsx index 8a0bdac326f9..a1e5a2615ecf 100644 --- a/src/pages/Travel/VerifyAccountPage.tsx +++ b/src/pages/Travel/DynamicVerifyAccountPage.tsx @@ -1,3 +1,4 @@ +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -10,16 +11,18 @@ import VerifyAccountPageBase from '@pages/settings/VerifyAccountPageBase'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback, useEffect} from 'react'; -type VerifyAccountPageProps = StackScreenProps; +type DynamicVerifyAccountPageProps = StackScreenProps; -function VerifyAccountPage({route}: VerifyAccountPageProps) { - const {backTo, policyID} = route.params; +function DynamicVerifyAccountPage({route}: DynamicVerifyAccountPageProps) { + const {policyID} = route.params ?? {}; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT.path); const [travelProvisioning] = useOnyx(ONYXKEYS.TRAVEL_PROVISIONING); const {isBetaEnabled} = usePermissions(); @@ -40,12 +43,12 @@ function VerifyAccountPage({route}: VerifyAccountPageProps) { }, []); const handleClose = useCallback(() => { - Navigation.goBack(backTo); - }, [backTo]); + Navigation.goBack(backPath); + }, [backPath]); return ( { expect(getMatchingNewRoute('/travel/upgrade/workspace/confirmation?backTo=/home')).toBe('/travel/travel-upgrade/workspace-confirmation?backTo=/home'); }); + it('redirects old travel verify account path to dynamic route', () => { + expect(getMatchingNewRoute('/travel/verify-account')).toBe('/travel/travel-verify-account'); + expect(getMatchingNewRoute('/travel/verify-account?policyID=123')).toBe('/travel/travel-verify-account?policyID=123'); + }); + it('redirects legacy new task flat routes to the new nested dynamic routes', () => { expect(getMatchingNewRoute('/new/task/details')).toBe('/task-details'); expect(getMatchingNewRoute('/new/task')).toBe('/task-details/task-confirm'); diff --git a/tests/unit/components/BookTravelButtonTest.tsx b/tests/unit/components/BookTravelButtonTest.tsx index 25f4ed2efcd5..b653656ee336 100644 --- a/tests/unit/components/BookTravelButtonTest.tsx +++ b/tests/unit/components/BookTravelButtonTest.tsx @@ -6,11 +6,12 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {cleanupTravelProvisioningSession, setTravelProvisioningNextStep} from '@libs/actions/Travel'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import React from 'react'; @@ -135,7 +136,7 @@ describe('BookTravelButton', () => { // forward-navigate back to once validated (this avoids a URL blink from double-navigating through the // stepper, which would otherwise immediately redirect to this same verify-account page anyway) expect(setTravelProvisioningNextStep).toHaveBeenCalledWith(ENABLE_TRAVEL_ROUTE); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.TRAVEL_VERIFY_ACCOUNT.getRoute(undefined, POLICY_ID, '')); + expect(Navigation.navigate).toHaveBeenCalledWith(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_VERIFY_ACCOUNT.getRoute(POLICY_ID))); expect(Navigation.navigate).not.toHaveBeenCalledWith(ENABLE_TRAVEL_ROUTE); }); }); From 3b59ff97bd37144ee4935dae9b95635d79f18282 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:04:59 +0700 Subject: [PATCH 205/466] remove code debug --- src/libs/Permissions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index f664626c1bcf..66cc2e2cd991 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -6,7 +6,7 @@ import type {OnyxEntry} from 'react-native-onyx'; // eslint-disable-next-line rulesdir/no-beta-handler function canUseAllBetas(betas: OnyxEntry): boolean { - return true; + return !!betas?.includes(CONST.BETAS.ALL); } /** From 3fb69fada51e8f9e402c6ec621ca645782c0e05b Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:55:38 +0700 Subject: [PATCH 206/466] migrate TRAVEL_TRIP_SUMMARY and TRAVEL_TRIP_DETAILS --- src/ROUTES.ts | 62 +++++++++++++------ src/SCREENS.ts | 4 +- .../ReportActionItem/MoneyRequestView.tsx | 4 +- .../ReportActionItem/TripDetailsView.tsx | 7 +-- .../ModalStackNavigators/index.tsx | 4 +- .../Navigation/linkingConfig/OldRoutes.ts | 2 + src/libs/Navigation/linkingConfig/config.ts | 9 +-- src/libs/Navigation/types.ts | 10 +-- ...ilsPage.tsx => DynamicTripDetailsPage.tsx} | 13 ++-- ...aryPage.tsx => DynamicTripSummaryPage.tsx} | 13 ++-- .../UpcomingTravelItem.tsx | 5 +- tests/navigation/getMatchingNewRouteTest.ts | 24 +++++++ 12 files changed, 103 insertions(+), 54 deletions(-) rename src/pages/Travel/{TripDetailsPage.tsx => DynamicTripDetailsPage.tsx} (93%) rename src/pages/Travel/{TripSummaryPage.tsx => DynamicTripSummaryPage.tsx} (82%) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f6fa9525aea6..21ba63f1b233 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1168,6 +1168,48 @@ const DYNAMIC_ROUTES = { path: 'travel-upgrade', entryScreens: [SCREENS.TRAVEL.MY_TRIPS, SCREENS.WORKSPACE.TRAVEL, SCREENS.SEARCH.ROOT], }, + // `trip-details` and `trip-summary` (below) are named to not start with `trip/`, so their literal + // segment doesn't collide with the legacy static routes `r/:reportID/trip/:transactionID/:pnr/:sequenceIndex` + // and `r/:reportID/trip/:transactionID` redirected in OldRoutes.ts - sharing the `trip/` prefix would make + // those old patterns' greedy trailing wildcard re-match these routes' own output. + // `reportID` is an explicit path param (not inherited from the entry screen) because this can be opened + // from a readonly transaction preview (TransactionDuplicate/TransactionMerge confirmation) whose own + // `reportID` param belongs to the duplicate/merge set, not to this transaction's trip room report. + TRAVEL_TRIP_DETAILS: { + path: 'trip-details/:reportID/:transactionID/:pnr/:sequenceIndex', + entryScreens: [ + SCREENS.REPORT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.HOME, + SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_CONFIRMATION, + SCREENS.MERGE_TRANSACTION.DYNAMIC_CONFIRMATION_PAGE, + ], + getRoute: (reportID: string | undefined, transactionID: string | undefined, pnr: string | undefined, sequenceIndex: number) => { + if (!reportID || !transactionID || !pnr) { + Log.warn('Invalid reportID, transactionID or pnr is used to build the TRAVEL_TRIP_DETAILS route'); + } + return `trip-details/${reportID}/${transactionID}/${pnr}/${sequenceIndex}` as const; + }, + }, + TRAVEL_TRIP_SUMMARY: { + path: 'trip-summary/:reportID/:transactionID', + entryScreens: [ + SCREENS.REPORT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_CONFIRMATION, + SCREENS.MERGE_TRANSACTION.DYNAMIC_CONFIRMATION_PAGE, + ], + getRoute: (reportID: string | undefined, transactionID: string | undefined) => { + if (!reportID || !transactionID) { + Log.warn('Invalid reportID or transactionID is used to build the TRAVEL_TRIP_SUMMARY route'); + } + return `trip-summary/${reportID}/${transactionID}` as const; + }, + }, REPORT_CHANGE_APPROVER: { path: 'change-approver', entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], @@ -3800,26 +3842,6 @@ const ROUTES = { }, }, TRACK_TRAINING_MODAL: 'track-training', - TRAVEL_TRIP_SUMMARY: { - route: 'r/:reportID/trip/:transactionID', - getRoute: (reportID: string | undefined, transactionID: string | undefined, backTo?: string) => { - if (!reportID || !transactionID) { - Log.warn('Invalid reportID or transactionID is used to build the TRAVEL_TRIP_SUMMARY route'); - } - - return getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}`, backTo); - }, - }, - TRAVEL_TRIP_DETAILS: { - route: 'r/:reportID/trip/:transactionID/:pnr/:sequenceIndex', - getRoute: (reportID: string | undefined, transactionID: string | undefined, pnr: string | undefined, sequenceIndex: number, backTo?: string) => { - if (!reportID || !transactionID || !pnr) { - Log.warn('Invalid reportID, transactionID or pnr is used to build the TRAVEL_TRIP_DETAILS route'); - } - - return getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}/${pnr}/${sequenceIndex}`, backTo); - }, - }, TRAVEL_WORKSPACE_CONFIRMATION: { route: 'travel/upgrade/workspace/confirmation', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index e759c4b922ae..91796284c70e 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -32,8 +32,8 @@ const SCREENS = { MY_TRIPS: 'Travel_MyTrips', DYNAMIC_UPGRADE: 'Dynamic_Travel_Upgrade', DYNAMIC_DOMAIN_PERMISSION_INFO: 'Dynamic_Travel_DomainPermissionInfo', - TRIP_SUMMARY: 'Travel_TripSummary', - TRIP_DETAILS: 'Travel_TripDetails', + DYNAMIC_TRIP_SUMMARY: 'Dynamic_Travel_TripSummary', + DYNAMIC_TRIP_DETAILS: 'Dynamic_Travel_TripDetails', DYNAMIC_PUBLIC_DOMAIN_ERROR: 'Dynamic_Travel_PublicDomainError', WORKSPACE_CONFIRMATION: 'Travel_WorkspaceConfirmation', TRAVEL_DOT_LINK_WEB_VIEW: 'Travel_DotLinkWebView', diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 1d4c26c237d0..704205add8cb 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1649,9 +1649,9 @@ function MoneyRequestView({ onPress={() => { const reservations = transaction?.receipt?.reservationList?.length ?? 0; if (reservations > 1) { - Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(transactionThreadReport?.reportID, transaction.transactionID, getReportRHPActiveRoute())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(transactionThreadReport?.reportID, transaction.transactionID))); } - Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(transactionThreadReport?.reportID, transaction.transactionID, '0', 0, getReportRHPActiveRoute())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.getRoute(transactionThreadReport?.reportID, transaction.transactionID, '0', 0))); }} /> )} diff --git a/src/components/ReportActionItem/TripDetailsView.tsx b/src/components/ReportActionItem/TripDetailsView.tsx index 0c26cae32915..9c8a20788109 100644 --- a/src/components/ReportActionItem/TripDetailsView.tsx +++ b/src/components/ReportActionItem/TripDetailsView.tsx @@ -14,6 +14,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import StringUtils from '@libs/StringUtils'; @@ -22,7 +23,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {ReservationData} from '@src/libs/TripReservationUtils'; import {formatCancelledDescription, formatTransitLocationLabel, getPNRReservationDataFromTripReport, getTripReservationCode, getTripReservationIcon} from '@src/libs/TripReservationUtils'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Report} from '@src/types/onyx'; import type {Reservation} from '@src/types/onyx/Transaction'; import type Transaction from '@src/types/onyx/Transaction'; @@ -171,9 +172,7 @@ function ReservationView({reservation, transactionID, tripRoomReportID, sequence iconStyles={[StyleUtils.getTripReservationIconContainer(false), styles.mr3, shouldCenterIcon && styles.alignSelfCenter]} secondaryIconFill={theme.icon} onPress={() => - Navigation.navigate( - ROUTES.TRAVEL_TRIP_DETAILS.getRoute(tripRoomReportID, transactionID, String(reservation.reservationID), sequenceIndex, Navigation.getReportRHPActiveRoute()), - ) + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.getRoute(tripRoomReportID, transactionID, String(reservation.reservationID), sequenceIndex))) } /> ); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index a0a72f581889..3d2eb369a31e 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -235,8 +235,8 @@ const TravelModalStackNavigator = createModalStackNavigator require('../../../../pages/Travel/MyTripsPage').default, [SCREENS.TRAVEL.TRAVEL_DOT_LINK_WEB_VIEW]: () => require('../../../../pages/Travel/TravelDotLinkWebview').default, [SCREENS.TRAVEL.DYNAMIC_UPGRADE]: () => require('../../../../pages/Travel/DynamicTravelUpgrade').default, - [SCREENS.TRAVEL.TRIP_SUMMARY]: () => require('../../../../pages/Travel/TripSummaryPage').default, - [SCREENS.TRAVEL.TRIP_DETAILS]: () => require('../../../../pages/Travel/TripDetailsPage').default, + [SCREENS.TRAVEL.DYNAMIC_TRIP_SUMMARY]: () => require('../../../../pages/Travel/DynamicTripSummaryPage').default, + [SCREENS.TRAVEL.DYNAMIC_TRIP_DETAILS]: () => require('../../../../pages/Travel/DynamicTripDetailsPage').default, [SCREENS.TRAVEL.DYNAMIC_DOMAIN_PERMISSION_INFO]: () => require('../../../../pages/Travel/DynamicDomainPermissionInfoPage').default, [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: () => require('../../../../pages/Travel/DynamicPublicDomainErrorPage').default, [SCREENS.TRAVEL.WORKSPACE_CONFIRMATION]: () => require('../../../../pages/Travel/WorkspaceConfirmationForTravelPage').default, diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index 968752c401e2..90422a5b8130 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -47,6 +47,8 @@ const oldRoutes: Record = { '/r/*/duplicates/review/billable': '/r/$1/billable/$1', '/r/*/duplicates/review': '/r/$1/duplicates/review/$1', '/r/*/duplicates/confirm': '/r/$1/confirm/$1', + '/r/*/trip/*/*/*': '/r/$1/trip-details/$1/$2/$3/$4', + '/r/*/trip/*': '/r/$1/trip-summary/$1/$2', '/workspaces/*/overview/address': '/workspaces/$1/overview/workspace-address', '/workspaces/*/categories/settings': '/workspaces/$1/categories/categories-settings', '/workspaces/*/categories/new': '/workspaces/$1/categories/category-new', diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index de84f77a078d..96dd87fc2ccf 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1921,13 +1921,8 @@ const config: LinkingOptions['config'] = { [SCREENS.TRAVEL.MY_TRIPS]: ROUTES.TRAVEL_MY_TRIPS.route, [SCREENS.TRAVEL.TRAVEL_DOT_LINK_WEB_VIEW]: ROUTES.TRAVEL_DOT_LINK_WEB_VIEW.route, [SCREENS.TRAVEL.DYNAMIC_UPGRADE]: DYNAMIC_ROUTES.TRAVEL_UPGRADE.path, - [SCREENS.TRAVEL.TRIP_SUMMARY]: ROUTES.TRAVEL_TRIP_SUMMARY.route, - [SCREENS.TRAVEL.TRIP_DETAILS]: { - path: ROUTES.TRAVEL_TRIP_DETAILS.route, - parse: { - reservationIndex: (reservationIndex: string) => parseInt(reservationIndex, 10), - }, - }, + [SCREENS.TRAVEL.DYNAMIC_TRIP_SUMMARY]: DYNAMIC_ROUTES.TRAVEL_TRIP_SUMMARY.path, + [SCREENS.TRAVEL.DYNAMIC_TRIP_DETAILS]: DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.path, [SCREENS.TRAVEL.DYNAMIC_DOMAIN_PERMISSION_INFO]: DYNAMIC_ROUTES.TRAVEL_DOMAIN_PERMISSION_INFO.path, [SCREENS.TRAVEL.DYNAMIC_PUBLIC_DOMAIN_ERROR]: DYNAMIC_ROUTES.TRAVEL_PUBLIC_DOMAIN_ERROR.path, [SCREENS.TRAVEL.WORKSPACE_CONFIRMATION]: ROUTES.TRAVEL_WORKSPACE_CONFIRMATION.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3cef807e1afb..209111bf5663 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2777,19 +2777,15 @@ type TravelNavigatorParamList = { isTestAccount?: string; redirectUrl?: string; }; - [SCREENS.TRAVEL.TRIP_SUMMARY]: { + [SCREENS.TRAVEL.DYNAMIC_TRIP_SUMMARY]: { reportID: string; transactionID: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: string; }; - [SCREENS.TRAVEL.TRIP_DETAILS]: { + [SCREENS.TRAVEL.DYNAMIC_TRIP_DETAILS]: { reportID: string; transactionID: string; - sequenceIndex: number; + sequenceIndex: string; pnr: string; - // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md - backTo?: string; }; [SCREENS.TRAVEL.DYNAMIC_DOMAIN_PERMISSION_INFO]: { domain: string; diff --git a/src/pages/Travel/TripDetailsPage.tsx b/src/pages/Travel/DynamicTripDetailsPage.tsx similarity index 93% rename from src/pages/Travel/TripDetailsPage.tsx rename to src/pages/Travel/DynamicTripDetailsPage.tsx index 2e92d9846dd6..a571ee400493 100644 --- a/src/pages/Travel/TripDetailsPage.tsx +++ b/src/pages/Travel/DynamicTripDetailsPage.tsx @@ -4,6 +4,7 @@ import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -14,6 +15,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; import type {TravelNavigatorParamList} from '@libs/Navigation/types'; import {getTripIDFromTransactionParentReportID} from '@libs/ReportUtils'; import {formatCancelledDescription, getReservationDetailsFromSequence, getReservationsFromTripReport} from '@libs/TripReservationUtils'; @@ -23,6 +25,7 @@ import {openTravelDotLink} from '@userActions/Link'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PersonalDetailsList} from '@src/types/onyx'; @@ -40,9 +43,9 @@ function pickTravelerPersonalDetails(personalDetails: OnyxEntry personalDetail?.login === travelerEmail); } -type TripDetailsPageProps = StackScreenProps; +type DynamicTripDetailsPageProps = StackScreenProps; -function TripDetailsPage({route}: TripDetailsPageProps) { +function DynamicTripDetailsPage({route}: DynamicTripDetailsPageProps) { const icons = useMemoizedLazyExpensifyIcons([ 'NewWindow', 'Plane', @@ -64,6 +67,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) { const {isBetaEnabled} = usePermissions(); const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL); const {isOffline} = useNetwork(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.path); const [isModifyTripLoading, setIsModifyTripLoading] = useState(false); const [isTripSupportLoading, setIsTripSupportLoading] = useState(false); @@ -90,7 +94,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) { includeSafeAreaPaddingBottom shouldEnablePickerAvoiding={false} shouldEnableMaxHeight - testID="TripDetailsPage" + testID="DynamicTripDetailsPage" shouldShowOfflineIndicatorInWideScreen > Navigation.goBack(backPath)} icon={reservationIcon} iconHeight={20} iconWidth={20} @@ -171,4 +176,4 @@ function TripDetailsPage({route}: TripDetailsPageProps) { ); } -export default TripDetailsPage; +export default DynamicTripDetailsPage; diff --git a/src/pages/Travel/TripSummaryPage.tsx b/src/pages/Travel/DynamicTripSummaryPage.tsx similarity index 82% rename from src/pages/Travel/TripSummaryPage.tsx rename to src/pages/Travel/DynamicTripSummaryPage.tsx index eee9d2b0cdb3..aa80d2b01452 100644 --- a/src/pages/Travel/TripSummaryPage.tsx +++ b/src/pages/Travel/DynamicTripSummaryPage.tsx @@ -5,25 +5,29 @@ import {ReservationView} from '@components/ReportActionItem/TripDetailsView'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Navigation from '@libs/Navigation/Navigation'; import type {TravelNavigatorParamList} from '@libs/Navigation/types'; import CONFIG from '@src/CONFIG'; import * as TripReservationUtils from '@src/libs/TripReservationUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; -type TripSummaryPageProps = StackScreenProps; +type DynamicTripSummaryPageProps = StackScreenProps; -function TripSummaryPage({route}: TripSummaryPageProps) { +function DynamicTripSummaryPage({route}: DynamicTripSummaryPageProps) { const {translate} = useLocalize(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.TRAVEL_TRIP_SUMMARY.path); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(route.params.transactionID)}`); @@ -34,7 +38,7 @@ function TripSummaryPage({route}: TripSummaryPageProps) { includeSafeAreaPaddingBottom={false} shouldEnablePickerAvoiding={false} shouldEnableMaxHeight - testID="TripSummaryPage" + testID="DynamicTripSummaryPage" shouldShowOfflineIndicatorInWideScreen > Navigation.goBack(backPath)} /> {reservationsData.map(({reservation, transactionID, sequenceIndex, isCancelled}) => { @@ -65,4 +70,4 @@ function TripSummaryPage({route}: TripSummaryPageProps) { ); } -export default TripSummaryPage; +export default DynamicTripSummaryPage; diff --git a/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx b/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx index 6c65c8724bb7..45c58e048c30 100644 --- a/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx +++ b/src/pages/home/UpcomingTravelSection/UpcomingTravelItem.tsx @@ -8,13 +8,14 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {formatCancelledDescription, getTripReservationIcon} from '@libs/TripReservationUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Reservation} from '@src/types/onyx/Transaction'; import {differenceInCalendarDays} from 'date-fns'; @@ -107,7 +108,7 @@ function UpcomingTravelItem({reservation: upcomingReservation}: UpcomingTravelIt const subtitle = typeId ? `${relativeTime} ${CONST.DOT_SEPARATOR} ${typeId}` : relativeTime; const handlePress = () => { - Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(reportID, transactionID, reservation.reservationID, sequenceIndex)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.getRoute(reportID, transactionID, reservation.reservationID, sequenceIndex))); }; return ( diff --git a/tests/navigation/getMatchingNewRouteTest.ts b/tests/navigation/getMatchingNewRouteTest.ts index 02f6275f58c1..b15adf4b3afa 100644 --- a/tests/navigation/getMatchingNewRouteTest.ts +++ b/tests/navigation/getMatchingNewRouteTest.ts @@ -267,6 +267,30 @@ describe('getBestMatchingPath', () => { expect(getMatchingNewRoute('/travel/upgrade?backTo=/home')).toBe('/travel/travel-upgrade?backTo=/home'); }); + it('redirects old travel trip details path to the new dynamic suffix shape', () => { + expect(getMatchingNewRoute('/r/123/trip/456/PNR1/0')).toBe('/r/123/trip-details/123/456/PNR1/0'); + }); + + it('preserves query params when redirecting old travel trip details routes', () => { + expect(getMatchingNewRoute('/r/123/trip/456/PNR1/0?backTo=/home')).toBe('/r/123/trip-details/123/456/PNR1/0?backTo=/home'); + }); + + it('does not redirect the already-migrated travel trip details dynamic route', () => { + expect(getMatchingNewRoute('/r/123/trip-details/123/456/PNR1/0')).toBe(undefined); + }); + + it('redirects old travel trip summary path to the new dynamic suffix shape', () => { + expect(getMatchingNewRoute('/r/123/trip/456')).toBe('/r/123/trip-summary/123/456'); + }); + + it('preserves query params when redirecting old travel trip summary routes', () => { + expect(getMatchingNewRoute('/r/123/trip/456?backTo=/home')).toBe('/r/123/trip-summary/123/456?backTo=/home'); + }); + + it('does not redirect the already-migrated travel trip summary dynamic route', () => { + expect(getMatchingNewRoute('/r/123/trip-summary/123/456')).toBe(undefined); + }); + it('redirects legacy new task flat routes to the new nested dynamic routes', () => { expect(getMatchingNewRoute('/new/task/details')).toBe('/task-details'); expect(getMatchingNewRoute('/new/task')).toBe('/task-details/task-confirm'); From e683f4d3e7591f931e12ef6ecbd136eba65b5d1c Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:01:55 +0700 Subject: [PATCH 207/466] remove comment unused --- src/ROUTES.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 21ba63f1b233..c4f0d5dfb730 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1168,13 +1168,6 @@ const DYNAMIC_ROUTES = { path: 'travel-upgrade', entryScreens: [SCREENS.TRAVEL.MY_TRIPS, SCREENS.WORKSPACE.TRAVEL, SCREENS.SEARCH.ROOT], }, - // `trip-details` and `trip-summary` (below) are named to not start with `trip/`, so their literal - // segment doesn't collide with the legacy static routes `r/:reportID/trip/:transactionID/:pnr/:sequenceIndex` - // and `r/:reportID/trip/:transactionID` redirected in OldRoutes.ts - sharing the `trip/` prefix would make - // those old patterns' greedy trailing wildcard re-match these routes' own output. - // `reportID` is an explicit path param (not inherited from the entry screen) because this can be opened - // from a readonly transaction preview (TransactionDuplicate/TransactionMerge confirmation) whose own - // `reportID` param belongs to the duplicate/merge set, not to this transaction's trip room report. TRAVEL_TRIP_DETAILS: { path: 'trip-details/:reportID/:transactionID/:pnr/:sequenceIndex', entryScreens: [ From 489f2792581571c09baff323a0910bdaa6eba982 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:34:50 +0700 Subject: [PATCH 208/466] update entry screens --- src/ROUTES.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c4f0d5dfb730..4200c264fd3f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1178,6 +1178,7 @@ const DYNAMIC_ROUTES = { SCREENS.HOME, SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_CONFIRMATION, SCREENS.MERGE_TRANSACTION.DYNAMIC_CONFIRMATION_PAGE, + SCREENS.TRAVEL.DYNAMIC_TRIP_SUMMARY, ], getRoute: (reportID: string | undefined, transactionID: string | undefined, pnr: string | undefined, sequenceIndex: number) => { if (!reportID || !transactionID || !pnr) { From f592ea04c81bd6b597bebb00c7a6d8d87b0ed27e Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Fri, 14 Aug 2026 08:58:11 -0700 Subject: [PATCH 209/466] Update copy in E-card enrollment block modal --- src/languages/en.ts | 2 ++ .../expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index f083414ef702..985b99234dff 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7406,6 +7406,8 @@ const translations = { areYouSure: 'Are you sure?', workspaceCurrency: 'Workspace currency', updateCurrencyPrompt: 'It looks like your workspace is currently set to a different currency than USD. Please click the button below to update your currency to USD now.', + updateCurrencyForExpensifyCard: + 'Expensify Card works with USD, GBP, and EUR bank accounts. Update this workspace to a supported currency, or use a different workspace to enroll.', updateToUSD: 'Update to USD', updateWorkspaceCurrency: 'Update workspace currency', workspaceCurrencyNotSupported: 'Workspace currency not supported', diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index 65a2aae0b9f0..6437d2f1d15a 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -128,8 +128,8 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif isCurrencyModalOpen.current = true; const result = await showConfirmModal({ title: translate('workspace.common.expensifyCard'), - prompt: translate('workspace.bankAccount.updateCurrencyPrompt'), - confirmText: translate('workspace.bankAccount.updateToUSD'), + prompt: translate('workspace.bankAccount.updateCurrencyForExpensifyCard'), + confirmText: translate('workspace.bankAccount.updateWorkspaceCurrency'), cancelText: translate('common.cancel'), danger: true, }); From f5bac1a793caef5166b3da4f2ad5bad619354e67 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Fri, 14 Aug 2026 09:01:40 -0700 Subject: [PATCH 210/466] Show currency picker instead of auto-switching to USD --- .../expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index 6437d2f1d15a..75a16c5e3c6b 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -137,8 +137,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif if (result.action !== ModalActions.CONFIRM || !policy) { return; } - updatePolicyGeneralSettings(policy, policy.name, CONST.CURRENCY.USD); - startFlow(); + Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policy.id, true)); }; return ( From 69f29a20f47e04ff04b96770df68c0c21e5d1318 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Fri, 14 Aug 2026 09:02:51 -0700 Subject: [PATCH 211/466] polyglot parrot --- src/languages/de.ts | 2 ++ src/languages/el.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 2 ++ src/languages/it.ts | 2 ++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 2 ++ src/languages/pl.ts | 2 ++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 1 + 10 files changed, 19 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 671463f45aff..f3f2c1d7378b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7216,6 +7216,8 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, yourWorkspace: `Dein Arbeitsbereich ist auf eine nicht unterstützte Währung eingestellt. Sieh dir die Liste der unterstützten Währungen an.`, chooseAnExisting: 'Wähle ein bestehendes Bankkonto zum Bezahlen von Ausgaben oder füge ein neues hinzu.', changeBankAccount: 'Bankkonto ändern', + updateCurrencyForExpensifyCard: + 'Die Expensify Karte funktioniert mit Bankkonten in USD, GBP und EUR. Aktualisieren Sie diesen Workspace auf eine unterstützte Währung oder verwenden Sie einen anderen Workspace für die Registrierung.', }, changeOwner: { changeOwnerPageTitle: 'Besitz übertragen', diff --git a/src/languages/el.ts b/src/languages/el.ts index b6dcc8eddc55..1efe39c21c29 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7477,6 +7477,8 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ notAllowedToAddBankAccount: 'Ο χώρος εργασίας σας είναι ρυθμισμένος σε μη υποστηριζόμενο νόμισμα. Επικοινωνήστε με έναν διαχειριστή χώρου εργασίας που έχει δικαίωμα να το αλλάξει.', changeBankAccount: 'Αλλαγή τραπεζικού λογαριασμού', + updateCurrencyForExpensifyCard: + 'Η κάρτα Expensify λειτουργεί με τραπεζικούς λογαριασμούς σε USD, GBP και EUR. Ενημερώστε αυτόν τον χώρο εργασίας σε υποστηριζόμενο νόμισμα ή χρησιμοποιήστε έναν διαφορετικό χώρο εργασίας για να εγγραφείτε.', }, changeOwner: { changeOwnerPageTitle: 'Μεταφορά ιδιοκτήτη', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2edd8cb93b03..e8007afd1ca8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7181,6 +7181,8 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, yourWorkspace: `Tu espacio de trabajo está configurado en una moneda no soportada. Consulta la lista de monedas soportadas.`, chooseAnExisting: 'Elige una cuenta bancaria existente para pagar gastos o añade una nueva.', changeBankAccount: 'Cambiar cuenta bancaria', + updateCurrencyForExpensifyCard: + 'La Tarjeta Expensify funciona con cuentas bancarias en USD, GBP y EUR. Actualiza este espacio de trabajo a una divisa compatible o usa otro espacio de trabajo para inscribirte.', }, changeOwner: { changeOwnerPageTitle: 'Transferir la propiedad', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 7468a28abfb3..81572f308448 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7242,6 +7242,8 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, yourWorkspace: `Votre espace de travail est défini sur une devise non prise en charge. Consultez la liste des devises prises en charge.`, chooseAnExisting: 'Choisissez un compte bancaire existant pour payer les dépenses ou ajoutez-en un nouveau.', changeBankAccount: 'Changer de compte bancaire', + updateCurrencyForExpensifyCard: + 'La Carte Expensify fonctionne avec des comptes bancaires en USD, GBP et EUR. Mettez à jour cet espace de travail vers une devise prise en charge ou utilisez un autre espace de travail pour vous inscrire.', }, changeOwner: { changeOwnerPageTitle: 'Transférer le responsable', diff --git a/src/languages/it.ts b/src/languages/it.ts index a7b8b777aaff..75f6108cbadc 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7184,6 +7184,8 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, yourWorkspace: `La tua area di lavoro è impostata su una valuta non supportata. Visualizza l’elenco delle valute supportate.`, chooseAnExisting: 'Scegli un conto bancario esistente per pagare le spese oppure aggiungine uno nuovo.', changeBankAccount: 'Cambia conto bancario', + updateCurrencyForExpensifyCard: + 'La Carta Expensify funziona con conti bancari in USD, GBP ed EUR. Aggiorna questo spazio di lavoro a una valuta supportata oppure usa un altro spazio di lavoro per iscriverti.', }, changeOwner: { changeOwnerPageTitle: 'Trasferisci proprietario', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b8cb7a550c87..63390d30d820 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7101,6 +7101,8 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から yourWorkspace: `ご利用のワークスペースはサポートされていない通貨に設定されています。サポートされている通貨の一覧を表示します。`, chooseAnExisting: '既存の銀行口座を選択して経費を支払うか、新しい口座を追加してください。', changeBankAccount: '銀行口座を変更', + updateCurrencyForExpensifyCard: + 'Expensify カードは、USD・GBP・EUR の銀行口座でご利用いただけます。このワークスペースを対応通貨に更新するか、別のワークスペースで登録してください。', }, changeOwner: { changeOwnerPageTitle: '所有者を変更', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8fd574249a88..6dcc6d870630 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7171,6 +7171,8 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, yourWorkspace: `Je werkruimte is ingesteld op een niet-ondersteunde valuta. Bekijk de lijst met ondersteunde valuta's.`, chooseAnExisting: 'Kies een bestaande bankrekening om onkosten te betalen of voeg een nieuwe toe.', changeBankAccount: 'Bankrekening wijzigen', + updateCurrencyForExpensifyCard: + 'Expensify Kaart werkt met bankrekeningen in USD, GBP en EUR. Werk deze workspace bij naar een ondersteunde valuta, of gebruik een andere workspace om je aan te melden.', }, changeOwner: { changeOwnerPageTitle: 'Eigenaar overdragen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0c428e2f01bd..bf1b3baaedab 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7149,6 +7149,8 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, yourWorkspace: `Twoje miejsce pracy jest ustawione na nieobsługiwaną walutę. Zobacz listę obsługiwanych walut.`, chooseAnExisting: 'Wybierz istniejące konto bankowe do opłacania wydatków lub dodaj nowe.', changeBankAccount: 'Zmień konto bankowe', + updateCurrencyForExpensifyCard: + 'Karta Expensify działa z kontami bankowymi w USD, GBP i EUR. Zmień walutę tego workspace’a na obsługiwaną albo użyj innego workspace’a, żeby się zarejestrować.', }, changeOwner: { changeOwnerPageTitle: 'Przenieś właściciela', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 500f04df05c6..653edc9faeb0 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7170,6 +7170,8 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, yourWorkspace: `Seu workspace está configurado para uma moeda não compatível. Veja a lista de moedas compatíveis.`, chooseAnExisting: 'Escolha uma conta bancária existente para pagar despesas ou adicione uma nova.', changeBankAccount: 'Alterar conta bancária', + updateCurrencyForExpensifyCard: + 'O Cartão Expensify funciona com contas bancárias em USD, GBP e EUR. Atualize este workspace para uma moeda compatível ou use outro workspace para se inscrever.', }, changeOwner: { changeOwnerPageTitle: 'Transferir proprietário', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f98945ab108d..d4d145d2d9cd 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6932,6 +6932,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM yourWorkspace: `您的工作区当前使用不受支持的货币。请查看支持的货币列表。`, chooseAnExisting: '选择现有银行账户来支付报销,或添加新账户。', changeBankAccount: '更改银行账户', + updateCurrencyForExpensifyCard: 'Expensify 卡支持美元、英镑和欧元银行账户。请将此工作区更新为支持的货币,或使用其他工作区进行注册。', }, changeOwner: { changeOwnerPageTitle: '转移所有者', From 1e0aae975bec8e26f93b5d9b0001d439a85be7b1 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Fri, 14 Aug 2026 09:12:11 -0700 Subject: [PATCH 212/466] Add useCanEnrollNewExpensifyCardProgram --- src/hooks/useCanEnrollNewExpensifyCardProgram.ts | 15 +++++++++++++++ .../WorkspaceExpensifyCardFeedSelectorPage.tsx | 6 +++--- .../WorkspaceExpensifyCardPageEmptyState.tsx | 6 +++--- 3 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 src/hooks/useCanEnrollNewExpensifyCardProgram.ts diff --git a/src/hooks/useCanEnrollNewExpensifyCardProgram.ts b/src/hooks/useCanEnrollNewExpensifyCardProgram.ts new file mode 100644 index 000000000000..049fdd703e15 --- /dev/null +++ b/src/hooks/useCanEnrollNewExpensifyCardProgram.ts @@ -0,0 +1,15 @@ +import CONST from '@src/CONST'; + +import useExpensifyCardUkEuSupported from './useExpensifyCardUkEuSupported'; +import usePolicy from './usePolicy'; + +/** + * Whether the workspace currency allows enrollment in a new Expensify Card program. + * Linking an existing card feed is not restricted by currency. + */ +export default function useCanEnrollNewExpensifyCardProgram(policyID?: string) { + const policy = usePolicy(policyID); + const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policyID); + + return policy?.outputCurrency === CONST.CURRENCY.USD || isUkEuCurrencySupported; +} diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx index 700be0cf9df6..38f97baefa86 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx @@ -10,10 +10,10 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; +import useCanEnrollNewExpensifyCardProgram from '@hooks/useCanEnrollNewExpensifyCardProgram'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultFundID from '@hooks/useDefaultFundID'; import useExpensifyCardFeedsForFeedSelector from '@hooks/useExpensifyCardFeedsForFeedSelector'; -import useExpensifyCardUkEuSupported from '@hooks/useExpensifyCardUkEuSupported'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -84,7 +84,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); const policy = usePolicy(policyID); const canWriteExpensifyCard = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD); - const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policyID); + const canEnrollNewCardProgram = useCanEnrollNewExpensifyCardProgram(policyID); const getIssueCardFundID = () => { if (primaryFeeds.length === 0) { @@ -204,7 +204,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF // Suppress the new-program branch on workspaces with unsupported currencies // These workspaces may only issue cards on existing feeds - const shouldShowIssueCardButton = issueCardFundID !== undefined || policy?.outputCurrency === CONST.CURRENCY.USD || isUkEuCurrencySupported; + const shouldShowIssueCardButton = issueCardFundID !== undefined || canEnrollNewCardProgram; const issueNewCardAndOtherFeedsFooter = canWriteExpensifyCard ? ( diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index 75a16c5e3c6b..1c130c505b75 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -5,6 +5,7 @@ import {useLockedAccountActions, useLockedAccountState} from '@components/Locked import {ModalActions} from '@components/Modal/Global/ModalContext'; import Text from '@components/Text'; +import useCanEnrollNewExpensifyCardProgram from '@hooks/useCanEnrollNewExpensifyCardProgram'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useExpensifyCardFeedsForFeedSelector from '@hooks/useExpensifyCardFeedsForFeedSelector'; @@ -34,8 +35,6 @@ import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSection import variables from '@styles/variables'; -import {updateGeneralSettings as updatePolicyGeneralSettings} from '@userActions/Policy/Policy'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -78,6 +77,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif const isSetupUnfinished = hasInProgressUSDVBBA(reimbursementAccount?.achData); const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policy?.id); + const canEnrollNewCardProgram = useCanEnrollNewExpensifyCardProgram(policy?.id); const {allFeeds} = useExpensifyCardFeedsForFeedSelector(policy?.id); const hasAccessibleFeeds = allFeeds.length > 0; @@ -176,7 +176,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif } // The supported currency restriction only applies to enrolling a brand-new card program. // If hasAccessibleFeeds is true, allow the flow to start in order to link an existing feed - if (!hasAccessibleFeeds && !(policy?.outputCurrency === CONST.CURRENCY.USD || isUkEuCurrencySupported)) { + if (!hasAccessibleFeeds && !canEnrollNewCardProgram) { promptCurrencyChangeAndStartFlow(); return; } From a2c4431c75879c580535af1be356ccf099d1c41c Mon Sep 17 00:00:00 2001 From: "Brandon Stites (via MelvinBot)" Date: Fri, 14 Aug 2026 20:39:54 +0000 Subject: [PATCH 213/466] Docs: note monthly settlement date shown on Expensify Card settings page Co-authored-by: Brandon Stites --- .../Set-Up-and-Manage-the-Expensify-Card-US.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md index b2a14bcfd664..2ad4a668fba1 100644 --- a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md @@ -102,6 +102,12 @@ Your workspace also has built-in default protections that automatically block ce ![Click Settings to adjust the settlement account or frequency]({{site.url}}/assets/images/ExpensifyHelp-ExpensifyCard_08.png){:width="100%"} +## When your Expensify Cards settle each month + +When your settlement frequency is set to **Monthly**, the **Settings** page shows the recurring day of the month your cards settle. On the Expensify Card **Settings** page, the **Settlement frequency** row displays supporting text below it, such as *Expensify cards will settle on the 27th of each month.* + +This date is shown for reference only and cannot be changed. If your settlement frequency is set to **Daily**, no monthly settlement date is shown because balances settle automatically every business day. + ## What the Status column shows in the Expensify Card list The Expensify Card list includes a **Status** column so you can see each card's lifecycle state at a glance without opening the card. Click the **Status** column header to sort the list by status. From 04836781595af9ddc11f346e76470b9317260c81 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:29:02 +0700 Subject: [PATCH 214/466] update entry screen --- src/ROUTES.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f775989aa0d7..66e179152d30 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -285,7 +285,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, ], getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string) => { @@ -306,7 +306,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, ], getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, reportActionID?: string) => { @@ -327,7 +327,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, SCREENS.WORKSPACE.CREATE_DISTANCE_RATE, SCREENS.WORKSPACE.CREATE_DISTANCE_RATE_UPGRADE, From 04a2d76f1c145ed1436093d55918b181734c9927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski=20=28via=20MelvinBot=29?= Date: Mon, 17 Aug 2026 08:56:38 +0000 Subject: [PATCH 215/466] Remove backTo explanatory comment per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Jasikowski --- src/pages/settings/Agents/AddAgentPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/AddAgentPage.tsx index 9893a9ae97a6..14b7459c9cc3 100644 --- a/src/pages/settings/Agents/AddAgentPage.tsx +++ b/src/pages/settings/Agents/AddAgentPage.tsx @@ -128,8 +128,6 @@ function AddAgentPageContent({route, template}: AddAgentPageContentProps) { if (getIsNarrowLayout()) { // Reveal the DM under the modal before dismissing so we navigate directly to it in one animation, // instead of dismissing to the agents list first and navigating to the DM afterward. - // backTo points the DM header's back button at the agents list; without it, dismissing the whole - // settings modal stack leaves the list off the back stack, so back would fall through to the LHN. Navigation.revealRouteBeforeDismissingModal(ROUTES.REPORT_WITH_ID.getRoute(optimisticReportID, undefined, undefined, ROUTES.SETTINGS_AGENTS)); return; } From 25c695ab58aef686bd7c215b6f1b8f8a8d9c215e Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Mon, 17 Aug 2026 10:15:05 -0700 Subject: [PATCH 216/466] Update copy in E-card enrollment block modal --- src/languages/en.ts | 3 ++- .../expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 985b99234dff..b5c0caa1c091 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7407,7 +7407,8 @@ const translations = { workspaceCurrency: 'Workspace currency', updateCurrencyPrompt: 'It looks like your workspace is currently set to a different currency than USD. Please click the button below to update your currency to USD now.', updateCurrencyForExpensifyCard: - 'Expensify Card works with USD, GBP, and EUR bank accounts. Update this workspace to a supported currency, or use a different workspace to enroll.', + 'The Expensify Card is available to issue in USD, GBP, and EUR. Please update this workspace to a supported currency or use a different workspace', + updateCurrencyForExpensifyCardTitle: 'Get the Expensify Card', updateToUSD: 'Update to USD', updateWorkspaceCurrency: 'Update workspace currency', workspaceCurrencyNotSupported: 'Workspace currency not supported', diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index 1c130c505b75..1a8ca8b3d678 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -127,7 +127,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif const promptCurrencyChangeAndStartFlow = async () => { isCurrencyModalOpen.current = true; const result = await showConfirmModal({ - title: translate('workspace.common.expensifyCard'), + title: translate('workspace.bankAccount.updateCurrencyForExpensifyCardTitle'), prompt: translate('workspace.bankAccount.updateCurrencyForExpensifyCard'), confirmText: translate('workspace.bankAccount.updateWorkspaceCurrency'), cancelText: translate('common.cancel'), From fe2c572ecadc58ab756ce3a3a56ec9228278b3dd Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Mon, 17 Aug 2026 10:27:50 -0700 Subject: [PATCH 217/466] add period at end of second sentence --- src/languages/en.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index b5c0caa1c091..93c3b2591252 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7407,7 +7407,7 @@ const translations = { workspaceCurrency: 'Workspace currency', updateCurrencyPrompt: 'It looks like your workspace is currently set to a different currency than USD. Please click the button below to update your currency to USD now.', updateCurrencyForExpensifyCard: - 'The Expensify Card is available to issue in USD, GBP, and EUR. Please update this workspace to a supported currency or use a different workspace', + 'The Expensify Card is available to issue in USD, GBP, and EUR. Please update this workspace to a supported currency or use a different workspace.', updateCurrencyForExpensifyCardTitle: 'Get the Expensify Card', updateToUSD: 'Update to USD', updateWorkspaceCurrency: 'Update workspace currency', From e5d6d72e08ec5543232e909c86572e48e5132b32 Mon Sep 17 00:00:00 2001 From: Caio Coan Date: Mon, 17 Aug 2026 16:38:10 -0300 Subject: [PATCH 218/466] Verify approver to show over limit notifice --- src/libs/TransactionUtils/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 403222b47705..e9c040a25f8d 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -38,6 +38,7 @@ import { isOpenExpenseReport, isOpenReport, isProcessingReport, + isReportManager, isSelfDM, isSettled, isThread, @@ -2041,7 +2042,9 @@ function shouldShowViolation( } if (violationName === CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT) { - return isPolicyAdmin(policy) && !isSubmitter && isProcessingReport(iouReport); + // Submitters are not shown this notice because they cannot act on it, but a submitter who is also the report's + // approver is the person who has to approve it manually, so they still need to know why it was not auto-approved. + return isPolicyAdmin(policy) && (!isSubmitter || isReportManager(iouReport)) && isProcessingReport(iouReport); } if (violationName === CONST.VIOLATIONS.RTER) { From 96ed238ddb23e84b2a8d4f92fdc4ca2b5ddf0ccc Mon Sep 17 00:00:00 2001 From: "Chuck Dries (via MelvinBot)" Date: Mon, 17 Aug 2026 17:43:03 +0000 Subject: [PATCH 219/466] Add missing updateCurrencyForExpensifyCardTitle translation key to all locales Co-authored-by: Chuck Dries --- src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 10 files changed, 10 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index f3f2c1d7378b..821bc5c7aa32 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7218,6 +7218,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, changeBankAccount: 'Bankkonto ändern', updateCurrencyForExpensifyCard: 'Die Expensify Karte funktioniert mit Bankkonten in USD, GBP und EUR. Aktualisieren Sie diesen Workspace auf eine unterstützte Währung oder verwenden Sie einen anderen Workspace für die Registrierung.', + updateCurrencyForExpensifyCardTitle: 'Holen Sie sich die Expensify Karte', }, changeOwner: { changeOwnerPageTitle: 'Besitz übertragen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 1efe39c21c29..04152757805d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7479,6 +7479,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ changeBankAccount: 'Αλλαγή τραπεζικού λογαριασμού', updateCurrencyForExpensifyCard: 'Η κάρτα Expensify λειτουργεί με τραπεζικούς λογαριασμούς σε USD, GBP και EUR. Ενημερώστε αυτόν τον χώρο εργασίας σε υποστηριζόμενο νόμισμα ή χρησιμοποιήστε έναν διαφορετικό χώρο εργασίας για να εγγραφείτε.', + updateCurrencyForExpensifyCardTitle: 'Αποκτήστε την κάρτα Expensify', }, changeOwner: { changeOwnerPageTitle: 'Μεταφορά ιδιοκτήτη', diff --git a/src/languages/es.ts b/src/languages/es.ts index e8007afd1ca8..20404ffefde2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7183,6 +7183,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, changeBankAccount: 'Cambiar cuenta bancaria', updateCurrencyForExpensifyCard: 'La Tarjeta Expensify funciona con cuentas bancarias en USD, GBP y EUR. Actualiza este espacio de trabajo a una divisa compatible o usa otro espacio de trabajo para inscribirte.', + updateCurrencyForExpensifyCardTitle: 'Consigue la Tarjeta Expensify', }, changeOwner: { changeOwnerPageTitle: 'Transferir la propiedad', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 81572f308448..43393c002dfa 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7244,6 +7244,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, changeBankAccount: 'Changer de compte bancaire', updateCurrencyForExpensifyCard: 'La Carte Expensify fonctionne avec des comptes bancaires en USD, GBP et EUR. Mettez à jour cet espace de travail vers une devise prise en charge ou utilisez un autre espace de travail pour vous inscrire.', + updateCurrencyForExpensifyCardTitle: 'Obtenez la Carte Expensify', }, changeOwner: { changeOwnerPageTitle: 'Transférer le responsable', diff --git a/src/languages/it.ts b/src/languages/it.ts index 75f6108cbadc..a537ce163eb5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7186,6 +7186,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, changeBankAccount: 'Cambia conto bancario', updateCurrencyForExpensifyCard: 'La Carta Expensify funziona con conti bancari in USD, GBP ed EUR. Aggiorna questo spazio di lavoro a una valuta supportata oppure usa un altro spazio di lavoro per iscriverti.', + updateCurrencyForExpensifyCardTitle: 'Ottieni la Carta Expensify', }, changeOwner: { changeOwnerPageTitle: 'Trasferisci proprietario', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 63390d30d820..d9cb025123b4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7103,6 +7103,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から changeBankAccount: '銀行口座を変更', updateCurrencyForExpensifyCard: 'Expensify カードは、USD・GBP・EUR の銀行口座でご利用いただけます。このワークスペースを対応通貨に更新するか、別のワークスペースで登録してください。', + updateCurrencyForExpensifyCardTitle: 'Expensify カードを取得する', }, changeOwner: { changeOwnerPageTitle: '所有者を変更', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6dcc6d870630..a2685b194b45 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7173,6 +7173,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, changeBankAccount: 'Bankrekening wijzigen', updateCurrencyForExpensifyCard: 'Expensify Kaart werkt met bankrekeningen in USD, GBP en EUR. Werk deze workspace bij naar een ondersteunde valuta, of gebruik een andere workspace om je aan te melden.', + updateCurrencyForExpensifyCardTitle: 'Ontvang de Expensify Kaart', }, changeOwner: { changeOwnerPageTitle: 'Eigenaar overdragen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index bf1b3baaedab..37e1686093e4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7151,6 +7151,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, changeBankAccount: 'Zmień konto bankowe', updateCurrencyForExpensifyCard: 'Karta Expensify działa z kontami bankowymi w USD, GBP i EUR. Zmień walutę tego workspace’a na obsługiwaną albo użyj innego workspace’a, żeby się zarejestrować.', + updateCurrencyForExpensifyCardTitle: 'Zdobądź Kartę Expensify', }, changeOwner: { changeOwnerPageTitle: 'Przenieś właściciela', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 653edc9faeb0..85464624fa36 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7172,6 +7172,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, changeBankAccount: 'Alterar conta bancária', updateCurrencyForExpensifyCard: 'O Cartão Expensify funciona com contas bancárias em USD, GBP e EUR. Atualize este workspace para uma moeda compatível ou use outro workspace para se inscrever.', + updateCurrencyForExpensifyCardTitle: 'Obtenha o Cartão Expensify', }, changeOwner: { changeOwnerPageTitle: 'Transferir proprietário', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d4d145d2d9cd..882f93cfd23d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6933,6 +6933,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM chooseAnExisting: '选择现有银行账户来支付报销,或添加新账户。', changeBankAccount: '更改银行账户', updateCurrencyForExpensifyCard: 'Expensify 卡支持美元、英镑和欧元银行账户。请将此工作区更新为支持的货币,或使用其他工作区进行注册。', + updateCurrencyForExpensifyCardTitle: '获取 Expensify 卡', }, changeOwner: { changeOwnerPageTitle: '转移所有者', From 883318809270cdb6b8b7b5f0befb9afe694f253e Mon Sep 17 00:00:00 2001 From: "Ionatan Wiznia (via MelvinBot)" Date: Tue, 18 Aug 2026 02:06:14 +0000 Subject: [PATCH 220/466] Docs: remove Submit via PDF from upcoming features (now GA) Co-authored-by: Ionatan Wiznia --- .../Unlisted/Upcoming-Expense-And-Report-Management.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/articles/Unlisted/Upcoming-Expense-And-Report-Management.md b/docs/articles/Unlisted/Upcoming-Expense-And-Report-Management.md index e3946c8b9d9e..0e813b2b5050 100644 --- a/docs/articles/Unlisted/Upcoming-Expense-And-Report-Management.md +++ b/docs/articles/Unlisted/Upcoming-Expense-And-Report-Management.md @@ -1,7 +1,7 @@ --- title: Upcoming Features – Expense & Report Management (New Expensify) description: Internal AgentZero reference for planned and in-development expense and report management features in New Expensify. Not for public search indexing. -keywords: upcoming, coming soon, planned, roadmap, duplicate detection, mileage rates, distance rates, merchant naming, bulk PDF export, cross-period export, export filtered views, submit via PDF, approval paths, personal expense repayment, pending BYOC transactions, AgentZero reference +keywords: upcoming, coming soon, planned, roadmap, duplicate detection, mileage rates, distance rates, merchant naming, bulk PDF export, cross-period export, export filtered views, approval paths, personal expense repayment, pending BYOC transactions, AgentZero reference noindex: true sitemap: false internalScope: For AgentZero and internal tooling only. Use this page to give accurate, forward-looking answers about expense and report management features that are planned but not yet generally available. Do not expose this URL in user-facing help. @@ -17,7 +17,6 @@ This page is an **AgentZero reference** for expense and report management featur - **Bulk report PDF export** – Export multiple expense report PDFs at once from search results or a filtered view, rather than downloading one at a time. - **Cross-period expense exporting** – When exporting grouped report transactions to an accounting system, allow expenses from different accounting periods to be split across the correct periods rather than all landing on one date. - **Export filtered views** – Export the results of any in-app filtered or searched view directly to CSV or an accounting integration, without needing to manipulate data externally. -- **Submit via PDF (Submit workspace)** – Allow employees using a Submit workspace to generate a PDF of their expense report directly, without needing to submit to themselves as a workaround. - **Multiple approval paths per approver** – Support for approvers who manage more than one approval workflow or department, so expenses route correctly regardless of which workspace or category they belong to. - **Personal expense repayments** – When an employee accidentally charges a personal expense to a company card, allow them to repay it directly through Expensify rather than going outside the platform. - **Pending BYOC transactions** – Show pending (not-yet-settled) card transactions from Bring Your Own Card feeds in New Expensify, matching the real-time visibility employees expect from their banking apps. From e89b30f5999a72b8b233238467341babe9fcba72 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 18 Aug 2026 10:40:12 +0700 Subject: [PATCH 221/466] use useEffectEvent --- src/hooks/useLoadDepositAccountSetup.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/hooks/useLoadDepositAccountSetup.ts b/src/hooks/useLoadDepositAccountSetup.ts index 1a816bf580a6..419cb8ca57d6 100644 --- a/src/hooks/useLoadDepositAccountSetup.ts +++ b/src/hooks/useLoadDepositAccountSetup.ts @@ -4,7 +4,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useRoute} from '@react-navigation/native'; -import {useEffect, useRef, useState} from 'react'; +import {useEffect, useEffectEvent, useState} from 'react'; import useOnyx from './useOnyx'; @@ -30,20 +30,24 @@ function useLoadDepositAccountSetup(): boolean { const [isFlowEntryMount] = useState(() => !(route.params as {subPage?: string} | undefined)?.subPage); const [isLoadingDepositAccountSetup, metadata] = useOnyx(ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP); const isReadingFlagFromOnyx = isLoadingOnyxValue(metadata); - const hasRequestedRef = useRef(false); + + // Reading the flag through an effect event keeps it out of the effect's dependencies, so the request the flag + // itself triggers can't feed back and re-run the effect. + const openDepositAccountSetupEvent = useEffectEvent(() => { + if (!isFlowEntryMount && isLoadingDepositAccountSetup !== undefined) { + return; + } + openDepositAccountSetup(); + }); useEffect(() => { // Until Onyx has read the key, an undefined flag only means "not read yet" - deciding now would fire a // duplicate request on every mid-flow mount. - if (hasRequestedRef.current || isReadingFlagFromOnyx) { + if (isReadingFlagFromOnyx) { return; } - if (!isFlowEntryMount && isLoadingDepositAccountSetup !== undefined) { - return; - } - hasRequestedRef.current = true; - openDepositAccountSetup(); - }, [isFlowEntryMount, isLoadingDepositAccountSetup, isReadingFlagFromOnyx]); + openDepositAccountSetupEvent(); + }, [isReadingFlagFromOnyx]); return isLoadingDepositAccountSetup ?? true; } From 3a7fa9110730f983865c309295cac7f7c2e140e9 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:26:23 +0700 Subject: [PATCH 222/466] Restrict Teachers Unite to split expense and hide it from workspace pickers --- src/CONST/index.ts | 2 + src/components/EmptyStateComponent/types.ts | 7 +- .../MoneyRequestReportTransactionList.tsx | 2 +- .../SearchMoneyRequestReportEmptyState.tsx | 110 ++++++++------ .../AddExpenseActionButton.tsx | 2 +- src/hooks/useExpenseActions.ts | 2 +- src/libs/PolicyUtils.ts | 8 +- src/libs/ReportUtils.ts | 141 +++++++++--------- src/libs/isTeachersUnitePolicyID.ts | 19 +++ ...DynamicNewReportWorkspaceSelectionPage.tsx | 5 +- .../AttachmentPickerWithMenuItems.tsx | 17 ++- .../menuItems/CreateReportMenuItem.tsx | 10 +- .../iou/request/ParticipantSearchResults.tsx | 6 +- tests/unit/ReportUtilsTest.ts | 21 ++- 14 files changed, 209 insertions(+), 143 deletions(-) create mode 100644 src/libs/isTeachersUnitePolicyID.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index dfb0076f97c0..eceeeb6a1512 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1574,6 +1574,7 @@ const CONST = { CREATE_NEW_EXPENSE: 'createNewExpense', ADD_EXISTING_EXPENSE: 'addExistingExpense', TRACK_DISTANCE_EXPENSE: 'trackDistanceExpense', + SPLIT_EXPENSE: 'splitExpense', }, ACTION_BADGE: { SUBMIT: 'submit', @@ -8796,6 +8797,7 @@ const CONST = { ADD_EXPENSE_CREATE: 'MoreMenu-AddExpenseCreate', ADD_EXPENSE_TRACK_DISTANCE: 'MoreMenu-AddExpenseTrackDistance', ADD_EXPENSE_EXISTING: 'MoreMenu-AddExpenseExisting', + ADD_EXPENSE_SPLIT: 'MoreMenu-AddExpenseSplit', PAY: 'MoreMenu-Pay', DUPLICATE_REPORT: 'MoreMenu-DuplicateReport', MOVE_EXPENSE: 'MoreMenu-MoveExpense', diff --git a/src/components/EmptyStateComponent/types.ts b/src/components/EmptyStateComponent/types.ts index fbfe045c6226..95f6a922f43a 100644 --- a/src/components/EmptyStateComponent/types.ts +++ b/src/components/EmptyStateComponent/types.ts @@ -1,5 +1,6 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ImageStyle} from 'expo-image'; @@ -16,11 +17,7 @@ type EmptyStateButton = { style?: StyleProp; innerStyles?: StyleProp; hoverStyles?: StyleProp; - dropDownOptions?: Array< - DropdownOption< - ValueOf<{readonly CREATE_NEW_EXPENSE: 'createNewExpense'; readonly TRACK_DISTANCE_EXPENSE: 'trackDistanceExpense'; readonly ADD_EXISTING_EXPENSE: 'addExistingExpense'}> - > - >; + dropDownOptions?: Array>>; }; type EmptyStateComponentProps = { diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 2bf06f7fe5a7..d2c41aaa9d56 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -280,7 +280,7 @@ function MoneyRequestReportTransactionList({ const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'ReceiptPlus', 'Columns', 'Plus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'ReceiptPlus', 'Columns', 'Plus', 'Transfer']); const {translate, localeCompare} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth, isInLandscapeMode} = useResponsiveLayout(); diff --git a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx index 2d1ab8551661..064cec54b95b 100644 --- a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx +++ b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx @@ -37,7 +37,7 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const {translate} = useLocalize(); const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['FolderWithPapersAndWatch']); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'Plus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'Plus', 'Transfer']); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const reportId = report.reportID; @@ -45,55 +45,69 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); const canAddTransactionToReport = canAddTransaction(report, isReportArchived); const isReportTeachersUnite = isTeachersUniteReport(report); - const addExpenseDropdownOptions = [ - // Teachers Unite doesn't support reimbursement, so "Create expense" and "Track distance" are hidden for those reports. - ...(isReportTeachersUnite - ? [] - : [ - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, - text: translate('iou.createExpense'), - icon: expensifyIcons.Plus, - onSelected: () => { - if (!reportId) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs); - }, + // Teachers Unite doesn't support reimbursement, so members can only add an expense via Split expense. + const addExpenseDropdownOptions = isReportTeachersUnite + ? [ + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.SPLIT_EXPENSE, + text: translate('iou.splitExpense'), + icon: expensifyIcons.Transfer, + onSelected: () => { + if (!reportId) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SPLIT, reportId, draftTransactionIDs); }, - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, - text: translate('iou.trackDistance'), - icon: expensifyIcons.Location, - onSelected: () => { - if (!reportId) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startDistanceRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs, lastDistanceExpenseType); - }, + }, + ] + : [ + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, + text: translate('iou.createExpense'), + icon: expensifyIcons.Plus, + onSelected: () => { + if (!reportId) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs); }, - ]), - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE, - text: translate('iou.addExistingExpense'), - icon: icons.ReceiptPlus, - onSelected: () => { - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - openUnreportedExpense(reportId); - }, - }, - ]; + }, + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, + text: translate('iou.trackDistance'), + icon: expensifyIcons.Location, + onSelected: () => { + if (!reportId) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startDistanceRequest(CONST.IOU.TYPE.SUBMIT, reportId, draftTransactionIDs, lastDistanceExpenseType); + }, + }, + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE, + text: translate('iou.addExistingExpense'), + icon: icons.ReceiptPlus, + onSelected: () => { + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + openUnreportedExpense(reportId); + }, + }, + ]; useEffect(() => { cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${report.reportID}`); diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx index d7ffca5cf536..662e3e8feaf1 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx @@ -19,7 +19,7 @@ function AddExpenseActionButton() { const {translate} = useLocalize(); const {iouReport, chatReportID, policy} = useReportPreviewData(); const currentUserDetails = useCurrentUserPersonalDetails(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'ReceiptPlus', 'Plus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Location', 'ReceiptPlus', 'Plus', 'Transfer']); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 97b805626bc7..77b5051e6d2d 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -297,7 +297,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic const addExpenseDropdownOptions = getAddExpenseDropdownOptions({ translate, - icons: useMemoizedLazyExpensifyIcons(['Plus', 'ReceiptPlus', 'Location', 'Feed', 'ArrowRight']), + icons: useMemoizedLazyExpensifyIcons(['Plus', 'ReceiptPlus', 'Location', 'Feed', 'ArrowRight', 'Transfer']), iouReportID: moneyRequestReport?.reportID, policy, userBillingGracePeriodEnds, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index bf9377b66d50..78cec41b006b 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -55,6 +55,7 @@ import {getApiRoot} from './ApiUtils'; import {getCategoryApproverRule, hasAnyCategoryRules} from './CategoryUtils'; import {convertToBackendAmount} from './CurrencyUtils'; import {isAnyHRConnected, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from './HRUtils'; +import isTeachersUnitePolicyID from './isTeachersUnitePolicyID'; import Navigation from './Navigation/Navigation'; import {getIsOffline} from './NetworkState'; import {formatMemberForList} from './OptionsListUtils'; @@ -2730,7 +2731,12 @@ function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection !!policy && !policy.isJoinRequestPending && (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && shouldShowPolicy(policy, false, currentUserLogin), + (policy): policy is Policy => + !!policy && + !policy.isJoinRequestPending && + (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && + shouldShowPolicy(policy, false, currentUserLogin) && + !isTeachersUnitePolicyID(policy.id), ); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index c2862b77cd55..7844b6a0b124 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -97,7 +97,6 @@ import Onyx from 'react-native-onyx'; import type {GuidedSetupData, TaskForParameters} from './actions/Report'; import type {OnboardingCompanySize, OnboardingMessage, OnboardingPurpose, OnboardingTaskLinks} from './actions/Welcome/OnboardingFlow'; import type {AddCommentOrAttachmentParams} from './API/parameters'; -import type EnvironmentType from './Environment/getEnvironment/types'; import type {FormulaContext, compute as computeFormula, computeWithMetadata as computeFormulaWithMetadata} from './Formula'; import type {MoneyRequestNavigatorParamList, ReportsSplitNavigatorParamList} from './Navigation/types'; import type {LastVisibleMessage} from './ReportActionsUtils'; @@ -125,9 +124,9 @@ import {getCategoryGLCode} from './CategoryUtils'; import {convertToDisplayStringEnLocale} from './CurrencyUtils'; import DateUtils from './DateUtils'; import {getEnvironmentURL} from './Environment/Environment'; -import getEnvironment from './Environment/getEnvironment'; import {getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from './ErrorUtils'; import getAttachmentDetails from './fileDownload/getAttachmentDetails'; +import isTeachersUnitePolicyID from './isTeachersUnitePolicyID'; import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber'; import {translateLocal} from './Localize'; import Log from './Log'; @@ -1027,10 +1026,6 @@ let deprecatedIsAnonymousUser = false; let environmentURL: string; getEnvironmentURL().then((url: string) => (environmentURL = url)); -let environment: EnvironmentType; -getEnvironment().then((env) => { - environment = env; -}); /** * Fallback title field used when a policy has an empty fieldList (matches OldDot behavior). @@ -3147,7 +3142,7 @@ function shouldCurrentUserSubmitReport(iouReport: OnyxEntry, chatReport: */ type GetAddExpenseDropdownOptionsParams = { translate: LocalizedTranslate; - icons: Record<'Location' | 'ReceiptPlus' | 'Plus', IconAsset>; + icons: Record<'Location' | 'ReceiptPlus' | 'Plus' | 'Transfer', IconAsset>; iouReportID: string | undefined; policy: OnyxEntry; userBillingGracePeriodEnds: OnyxCollection; @@ -3178,48 +3173,66 @@ function getAddExpenseDropdownOptions({ // (e.g. a Teachers Unite expense report preview shown inside a non-Teachers-Unite chat), so check the // iouReport's own policy rather than the surrounding `policy`. const isReportTeachersUnite = isTeachersUnitePolicyID(getReportOrDraftReport(iouReportID)?.policyID ?? policy?.id); + + // Teachers Unite doesn't support reimbursement, so members can only add an expense via Split expense. + if (isReportTeachersUnite) { + return [ + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.SPLIT_EXPENSE, + text: translate('iou.splitExpense'), + icon: icons.Transfer, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_SPLIT, + onSelected: () => { + if (!iouReportID) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SPLIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); + }, + }, + ]; + } + return [ - // Teachers Unite doesn't support reimbursement, so "Create expense" and "Track distance" are hidden for those reports. - ...(isReportTeachersUnite - ? [] - : [ - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, - text: translate('iou.createExpense'), - icon: icons.Plus, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_CREATE, - onSelected: () => { - if (!iouReportID) { - return; - } - if ( - policy && - policy.type !== CONST.POLICY.TYPE.PERSONAL && - shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) - ) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startMoneyRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); - }, - }, - { - value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, - text: translate('iou.trackDistance'), - icon: icons.Location, - sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_TRACK_DISTANCE, - onSelected: () => { - if (!iouReportID) { - return; - } - if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); - return; - } - startDistanceRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); - }, - }, - ]), + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.CREATE_NEW_EXPENSE, + text: translate('iou.createExpense'), + icon: icons.Plus, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_CREATE, + onSelected: () => { + if (!iouReportID) { + return; + } + if ( + policy && + policy.type !== CONST.POLICY.TYPE.PERSONAL && + shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID) + ) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, undefined, false, iouRequestBackToReport); + }, + }, + { + value: CONST.REPORT.ADD_EXPENSE_OPTIONS.TRACK_DISTANCE_EXPENSE, + text: translate('iou.trackDistance'), + icon: icons.Location, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.ADD_EXPENSE_TRACK_DISTANCE, + onSelected: () => { + if (!iouReportID) { + return; + } + if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, currentUserAccountID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); + return; + } + startDistanceRequest(CONST.IOU.TYPE.SUBMIT, iouReportID, draftTransactionIDs, lastDistanceExpenseType, false, iouRequestBackToReport); + }, + }, { value: CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE, text: translate('iou.addExistingExpense'), @@ -10534,15 +10547,6 @@ function isGroupChatAdmin(report: OnyxEntry, accountID: number) { return participant?.role === CONST.REPORT.ROLE.ADMIN; } -/** - * The Teachers Unite workspace doesn't allow submitting expenses for reimbursement (CONST.IOU.TYPE.SUBMIT), - * only tracking them (CONST.IOU.TYPE.TRACK). - */ -function isTeachersUnitePolicyID(policyID: string | undefined): boolean { - const teacherUnitePolicyID = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; - return policyID === teacherUnitePolicyID; -} - function isTeachersUniteReport(report: OnyxEntry): boolean { return isTeachersUnitePolicyID(report?.policyID); } @@ -10614,35 +10618,33 @@ function getMoneyRequestOptions( } if (canRequestMoney(report, policy, otherParticipants, currentUserAccountID)) { - // For Teachers Unite policy, don't show Create Expense option + // Teachers Unite doesn't support reimbursement, so members can only add an expense via Split expense: + // don't show Create Expense or Track distance (Track) for TU reports. if (!isTeachersUniteReportValue) { options = [...options, CONST.IOU.TYPE.SUBMIT]; if (!filterDeprecatedTypes) { options = [...options, CONST.IOU.TYPE.REQUEST]; } - } - // If the user can request money from the workspace report, they can also track expenses - if (isPolicyExpenseChat(report) || isExpenseReport(report)) { - options = [...options, CONST.IOU.TYPE.TRACK]; + // If the user can request money from the workspace report, they can also track expenses + if (isPolicyExpenseChat(report) || isExpenseReport(report)) { + options = [...options, CONST.IOU.TYPE.TRACK]; + } } } - // For expense reports on Teachers Unite workspace, disable "Create report" option - if (isExpenseReport(report) && isTeachersUniteReportValue) { - options = options.filter((option) => option !== CONST.IOU.TYPE.SUBMIT); - } - // User created policy rooms and default rooms like #admins or #announce will always have the Split Expense option // unless there are no other participants at all (e.g. #admins room for a policy with only 1 admin) // DM chats will have the Split Expense option. // Your own expense chats will have the split expense option. - // Only show Split Expense for TU policy + // Only show Split Expense for TU policy: either in the member's own policy expense chat, or in their own + // Teachers Unite expense report (e.g. when adding an expense from the report page rather than the chat). if ( (isChatRoom(report) && !isAnnounceRoom(report) && otherParticipants.length > 0) || (isDM(report) && otherParticipants.length > 0) || (isGroupChat(report) && otherParticipants.length > 0) || - (isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat && isTeachersUniteReportValue) + (isPolicyExpenseChat(report) && report?.isOwnPolicyExpenseChat && isTeachersUniteReportValue) || + (isExpenseReport(report) && isTeachersUniteReportValue && report?.ownerAccountID === (currentUserAccountID ?? deprecatedCurrentUserAccountID)) ) { options = [...options, CONST.IOU.TYPE.SPLIT]; } @@ -14306,6 +14308,7 @@ export { canDeleteCardTransactionByLiabilityType, getAddExpenseDropdownOptions, isTeachersUniteReport, + isTeachersUnitePolicyID, getTaskAssigneeChatOnyxData, getTransactionDetails, getTransactionReportName, diff --git a/src/libs/isTeachersUnitePolicyID.ts b/src/libs/isTeachersUnitePolicyID.ts new file mode 100644 index 000000000000..425f2c7630cd --- /dev/null +++ b/src/libs/isTeachersUnitePolicyID.ts @@ -0,0 +1,19 @@ +import CONST from '@src/CONST'; + +import type EnvironmentType from './Environment/getEnvironment/types'; + +import getEnvironment from './Environment/getEnvironment'; + +let environment: EnvironmentType; +getEnvironment().then((env) => { + environment = env; +}); + +/** + * The Teachers Unite workspace doesn't allow submitting expenses for reimbursement (CONST.IOU.TYPE.SUBMIT), + * only tracking or splitting them. + */ +export default function isTeachersUnitePolicyID(policyID: string | undefined): boolean { + const teacherUnitePolicyID = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; + return policyID === teacherUnitePolicyID; +} diff --git a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx index 4beca92fc1a1..d8be237854f2 100644 --- a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx +++ b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx @@ -33,7 +33,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {NewReportWorkspaceSelectionNavigatorParamList} from '@libs/Navigation/types'; import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils'; import {canSubmitPerDiemExpenseFromWorkspace, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; -import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; +import {getDefaultWorkspaceAvatar, isTeachersUnitePolicyID} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {buildTransactionsByReportID} from '@libs/TodosUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; @@ -236,7 +236,8 @@ function DynamicNewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelec policy?.isJoinRequestPending || !policy?.isPolicyExpenseChatEnabled || !shouldShowPolicy(policy, false, currentUserPersonalDetails?.login) || - (hasPerDiemTransactions && !canSubmitPerDiemExpenseFromWorkspace(policy)) + (hasPerDiemTransactions && !canSubmitPerDiemExpenseFromWorkspace(policy)) || + isTeachersUnitePolicyID(policy?.id) ) { continue; } diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index d7b6118bd80e..a41747ea2282 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -9,7 +9,6 @@ import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -27,7 +26,15 @@ import {isSafari} from '@libs/Browser'; import getIconForAction from '@libs/getIconForAction'; import Navigation from '@libs/Navigation/Navigation'; import {isGroupPolicyByType} from '@libs/PolicyUtils'; -import {canCreateTaskInReport, getPayeeName, hasViolations as hasViolationsReportUtils, isPolicyExpenseChat, isReportOwner, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; +import { + canCreateTaskInReport, + getPayeeName, + hasViolations as hasViolationsReportUtils, + isPolicyExpenseChat, + isReportOwner, + isTeachersUniteReport, + temporary_getMoneyRequestOptions, +} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {startDistanceRequest, startMoneyRequest} from '@userActions/IOU/MoneyRequest'; @@ -169,7 +176,6 @@ function AttachmentPickerWithMenuItems({ const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const [lastDistanceExpenseType] = useOnyx(ONYXKEYS.NVP_LAST_DISTANCE_EXPENSE_TYPE); const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - const {isProduction} = useEnvironment(); const {isRestrictedToPreferredPolicy} = usePreferredPolicy(); const {setIsLoaderVisible} = useFullScreenLoaderActions(); const isReportArchived = useReportIsArchived(report?.reportID); @@ -230,8 +236,7 @@ function AttachmentPickerWithMenuItems({ } }; - const teacherUnitePolicyID = isProduction ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; - const isTeachersUniteReport = report?.policyID === teacherUnitePolicyID; + const isReportTeachersUnite = isTeachersUniteReport(report); /** * Returns the list of IOU Options @@ -454,7 +459,7 @@ function AttachmentPickerWithMenuItems({ }; const menuItems = [ ...moneyRequestOptions, - ...(!isTeachersUniteReport ? createReportOption : []), + ...(!isReportTeachersUnite ? createReportOption : []), ...taskOption, { icon: icons.Paperclip, diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx index 79bc4d833aef..7fc22b1698e7 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx @@ -11,7 +11,7 @@ import {createNewReport} from '@libs/actions/Report'; import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspaceSelection} from '@libs/Navigation/helpers/getCreateReportRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultChatEnabledPolicy, isGroupPolicy, shouldShowPolicy} from '@libs/PolicyUtils'; -import {hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; +import {hasViolations as hasViolationsReportUtils, isTeachersUnitePolicyID} from '@libs/ReportUtils'; import isOnSearchMoneyRequestReportPage from '@navigation/helpers/isOnSearchMoneyRequestReportPage'; @@ -39,7 +39,13 @@ const chatEnabledPaidGroupPoliciesSelector = (policies: OnyxCollection !selectedParticipantKeys.has(getParticipantOptionKey(option))); + const workspaceChats = (availableOptions.workspaceChats ?? []).filter( + (option) => !selectedParticipantKeys.has(getParticipantOptionKey(option)) && !isTeachersUnitePolicyID(option.policyID), + ); if (workspaceChats.length > 0) { sections.push({ title: translate('workspace.common.workspace'), diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 8c4bd932764f..3535d8adefed 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -4687,8 +4687,9 @@ describe('ReportUtils', () => { [CONST.BETAS.ALL], ); - // Should not include SUBMIT (Create Expense) + // Should not include SUBMIT (Create Expense) or TRACK (Track distance) — members can only split expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT)).toBe(false); + expect(moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK)).toBe(false); // Should include SPLIT (Split Expense) expect(moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)).toBe(true); @@ -20122,7 +20123,12 @@ describe('ReportUtils', () => { describe('getAddExpenseDropdownOptions', () => { const mockTranslate: LocaleContextProps['translate'] = (path, ...params) => translate(CONST.LOCALES.EN, path, ...params); - const mockIcons = createMock>({Location: jest.fn(), ReceiptPlus: jest.fn(), Plus: jest.fn()}); + const mockIcons = createMock>({ + Location: jest.fn(), + ReceiptPlus: jest.fn(), + Plus: jest.fn(), + Transfer: jest.fn(), + }); const mockIouReportID = '12345'; it('should return exactly 3 dropdown options', () => { @@ -20344,7 +20350,7 @@ describe('ReportUtils', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.RESTRICTED_ACTION.getRoute(mockPolicy.id)); }); - it('should hide the CREATE_NEW_EXPENSE and TRACK_DISTANCE_EXPENSE options for a Teachers Unite report', () => { + it('should show only SPLIT_EXPENSE for a Teachers Unite report', () => { const mockPolicy = createRandomPolicy(0); mockPolicy.id = CONST.TEACHERS_UNITE.TEST_POLICY_ID; @@ -20361,7 +20367,7 @@ describe('ReportUtils', () => { }); expect(result).toHaveLength(1); - expect(result.at(0)?.value).toBe(CONST.REPORT.ADD_EXPENSE_OPTIONS.ADD_EXISTING_EXPENSE); + expect(result.at(0)?.value).toBe(CONST.REPORT.ADD_EXPENSE_OPTIONS.SPLIT_EXPENSE); }); }); describe('GBR: draft report with delayed submission off then on (issue #69891)', () => { @@ -20753,7 +20759,12 @@ describe('ReportUtils', () => { describe('getAddExpenseDropdownOptions', () => { const mockTranslate: LocaleContextProps['translate'] = (path, ...params) => translate(CONST.LOCALES.EN, path, ...params); - const mockIcons = createMock>({Location: jest.fn(), ReceiptPlus: jest.fn(), Plus: jest.fn()}); + const mockIcons = createMock>({ + Location: jest.fn(), + ReceiptPlus: jest.fn(), + Plus: jest.fn(), + Transfer: jest.fn(), + }); const policyID = '5001'; const iouReportID = 'reportABC'; const ownerAccountID = 999; From 61c466892631dd18f28d62df938aa9aa7ac744dd Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:51:55 +0700 Subject: [PATCH 223/466] update guard --- src/hooks/useDefaultExpensePolicy.tsx | 8 +++++--- src/hooks/useOutstandingReports.ts | 8 +++++++- src/hooks/usePolicyForMovingExpenses.ts | 10 +++++++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/hooks/useDefaultExpensePolicy.tsx b/src/hooks/useDefaultExpensePolicy.tsx index f9a3b0a68ec7..4c7c9ca2796f 100644 --- a/src/hooks/useDefaultExpensePolicy.tsx +++ b/src/hooks/useDefaultExpensePolicy.tsx @@ -1,3 +1,4 @@ +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import {isGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -20,7 +21,7 @@ function getSingleGroupPolicyID(policies: OnyxCollection, login: string) let singlePolicyID: string | undefined; for (const policy of Object.values(policies)) { - if (!policy || !isGroupPolicy(policy) || !isPolicyAccessible(policy, login)) { + if (!policy || !isGroupPolicy(policy) || !isPolicyAccessible(policy, login) || isTeachersUnitePolicyID(policy.id)) { continue; } if (!singlePolicyID) { @@ -48,11 +49,12 @@ export default function useDefaultExpensePolicy() { // Per-key lookup for the single group policy (only fires when that specific policy changes) const [singleGroupPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${singleGroupPolicyID}`); - if (isRestrictedToPreferredPolicy && isGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login)) { + // Teachers Unite doesn't support reimbursement, so it can never be the default destination for a new expense. + if (isRestrictedToPreferredPolicy && isGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login) && !isTeachersUnitePolicyID(preferredPolicy?.id)) { return preferredPolicy; } - if (isGroupPolicy(activePolicy) && isPolicyAccessible(activePolicy, login)) { + if (isGroupPolicy(activePolicy) && isPolicyAccessible(activePolicy, login) && !isTeachersUnitePolicyID(activePolicy?.id)) { return activePolicy; } diff --git a/src/hooks/useOutstandingReports.ts b/src/hooks/useOutstandingReports.ts index 0b5fe3af1f6d..10b7febbbd07 100644 --- a/src/hooks/useOutstandingReports.ts +++ b/src/hooks/useOutstandingReports.ts @@ -1,3 +1,4 @@ +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import {getOutstandingReportsForUser, isSelfDM} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -28,7 +29,8 @@ export default function useOutstandingReports(selectedReportID: string | undefin if (shouldUseAllPolicies) { const result = []; for (const policyID of Object.values(allPoliciesID ?? {})) { - if (!policyID || policyID === personalPolicyID) { + // Teachers Unite doesn't support reimbursement, so its reports can never be a move-expense destination. + if (!policyID || policyID === personalPolicyID || isTeachersUnitePolicyID(policyID)) { continue; } @@ -38,5 +40,9 @@ export default function useOutstandingReports(selectedReportID: string | undefin return result; } + if (isTeachersUnitePolicyID(selectedPolicyID)) { + return []; + } + return getOutstandingReportsForUser(selectedPolicyID, ownerAccountID, reportNameValuePairs, outstandingReportsByPolicyID?.[selectedPolicyID ?? CONST.DEFAULT_NUMBER_ID] ?? {}, isEditing); } diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 801bece7e8d6..be09beaa39f9 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -1,6 +1,7 @@ import {useActivePolicyContext} from '@components/ActivePolicyProvider'; import {useSession} from '@components/OnyxListItemProvider'; +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import {canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; @@ -30,6 +31,8 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string isPolicyMemberByRole(policy) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && + // Teachers Unite doesn't support reimbursement, so it can never be a destination for moving/reporting an expense. + !isTeachersUnitePolicyID(policy?.id) && (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(policy)) && (!isTimeRequest || isTimeTrackingEnabled(policy)) ); @@ -123,7 +126,12 @@ function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; } - if (activePolicy && (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && (!isTimeRequest || isTimeTrackingEnabled(activePolicy))) { + if ( + activePolicy && + !isTeachersUnitePolicyID(activePolicy.id) && + (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && + (!isTimeRequest || isTimeTrackingEnabled(activePolicy)) + ) { return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false, shouldNavigateToUpgradePath: false}; } From 5df4b47e0cc556b146d98cff637832a30028d163 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:55:30 +0700 Subject: [PATCH 224/466] remove comments --- src/libs/ReportUtils.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7844b6a0b124..f00f4b26d3db 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3169,12 +3169,8 @@ function getAddExpenseDropdownOptions({ lastDistanceExpenseType, currentUserAccountID, }: GetAddExpenseDropdownOptionsParams): Array>> { - // The iouReport being added to may belong to a different policy than the chat surrounding it is rendered in - // (e.g. a Teachers Unite expense report preview shown inside a non-Teachers-Unite chat), so check the - // iouReport's own policy rather than the surrounding `policy`. const isReportTeachersUnite = isTeachersUnitePolicyID(getReportOrDraftReport(iouReportID)?.policyID ?? policy?.id); - // Teachers Unite doesn't support reimbursement, so members can only add an expense via Split expense. if (isReportTeachersUnite) { return [ { @@ -10618,8 +10614,6 @@ function getMoneyRequestOptions( } if (canRequestMoney(report, policy, otherParticipants, currentUserAccountID)) { - // Teachers Unite doesn't support reimbursement, so members can only add an expense via Split expense: - // don't show Create Expense or Track distance (Track) for TU reports. if (!isTeachersUniteReportValue) { options = [...options, CONST.IOU.TYPE.SUBMIT]; if (!filterDeprecatedTypes) { From 1398dd080fbd7cce16aa45975efee7b3c1d963de Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:12:17 +0700 Subject: [PATCH 225/466] Fix circular-import test failure by importing isTeachersUnitePolicyID directly instead of through ReportUtils --- src/libs/ReportUtils.ts | 1 - src/pages/DynamicNewReportWorkspaceSelectionPage.tsx | 3 ++- .../FABPopoverContent/menuItems/CreateReportMenuItem.tsx | 3 ++- src/pages/iou/request/ParticipantSearchResults.tsx | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f00f4b26d3db..fec404c498b0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14302,7 +14302,6 @@ export { canDeleteCardTransactionByLiabilityType, getAddExpenseDropdownOptions, isTeachersUniteReport, - isTeachersUnitePolicyID, getTaskAssigneeChatOnyxData, getTransactionDetails, getTransactionReportName, diff --git a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx index d8be237854f2..dc9b39b25ccf 100644 --- a/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx +++ b/src/pages/DynamicNewReportWorkspaceSelectionPage.tsx @@ -27,13 +27,14 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsByID from '@hooks/useTransactionsByID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import Navigation from '@libs/Navigation/Navigation'; import type {NewReportWorkspaceSelectionNavigatorParamList} from '@libs/Navigation/types'; import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils'; import {canSubmitPerDiemExpenseFromWorkspace, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; -import {getDefaultWorkspaceAvatar, isTeachersUnitePolicyID} from '@libs/ReportUtils'; +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {buildTransactionsByReportID} from '@libs/TodosUtils'; import {isPerDiemRequest} from '@libs/TransactionUtils'; diff --git a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx index 7fc22b1698e7..ef291734922d 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx @@ -8,10 +8,11 @@ import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {createNewReport} from '@libs/actions/Report'; +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspaceSelection} from '@libs/Navigation/helpers/getCreateReportRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultChatEnabledPolicy, isGroupPolicy, shouldShowPolicy} from '@libs/PolicyUtils'; -import {hasViolations as hasViolationsReportUtils, isTeachersUnitePolicyID} from '@libs/ReportUtils'; +import {hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import isOnSearchMoneyRequestReportPage from '@navigation/helpers/isOnSearchMoneyRequestReportPage'; diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index 7b2b524ffa75..da0d01a34bb2 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -25,6 +25,7 @@ import useUserToInviteReports from '@hooks/useUserToInviteReports'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import goToSettings from '@libs/goToSettings'; import {isMovingTransactionFromTrackExpense} from '@libs/IOUUtils'; +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import Navigation from '@libs/Navigation/Navigation'; import {formatSectionsFromSearchTerm, getHeaderMessage, getParticipantsOption, getPolicyExpenseReportOption, isCurrentUser} from '@libs/OptionsListUtils'; import type {Option} from '@libs/OptionsListUtils'; @@ -32,7 +33,7 @@ import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMa import type {OptionWithKey} from '@libs/OptionsListUtils/types'; import {getActiveAdminWorkspaces, isGroupPolicy as isGroupPolicyUtil} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {getReportOrDraftReport, isInvoiceRoom, isTeachersUnitePolicyID} from '@libs/ReportUtils'; +import {getReportOrDraftReport, isInvoiceRoom} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {expensifyLoginsSelector} from '@libs/UserUtils'; From 6fab29a6f084632d32804017135c19f48e59ea50 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:12:43 -1000 Subject: [PATCH 226/466] Update Manage-Personal-Karma-and-Corporate-Karma.md --- .../Manage-Personal-Karma-and-Corporate-Karma.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md index 4954567723e6..53d90770e6bd 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma.md @@ -28,9 +28,11 @@ Before you can enable either program, you must have a valid payment card on file ## How to enable or disable Personal Karma -Personal Karma can only be managed in a web browser. In the Expensify mobile app (iOS and Android), the **Enable Personal Karma** toggle is disabled and shows "Manage your personal karma preferences from web." +Personal Karma can only be managed in a web browser. -1. In a web browser, go to **Account** from the navigation tabs on the left. +On web: + +1. In the navigation tabs on the left, go to **Account**. 2. Select **Save the world**. 3. In the **Enable Personal Karma** section, turn Personal Karma on or off. 4. Add a payment card if prompted. From 60c7aaf50b9f92ec854b31ed48992121547888b1 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:16:37 -1000 Subject: [PATCH 227/466] Delete docs/articles/new-expensify/settings/Personal-Karma.md --- .../new-expensify/settings/Personal-Karma.md | 82 ------------------- 1 file changed, 82 deletions(-) delete mode 100644 docs/articles/new-expensify/settings/Personal-Karma.md diff --git a/docs/articles/new-expensify/settings/Personal-Karma.md b/docs/articles/new-expensify/settings/Personal-Karma.md deleted file mode 100644 index 73d51205c068..000000000000 --- a/docs/articles/new-expensify/settings/Personal-Karma.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: Personal Karma -description: Learn how Personal Karma works, how donation amounts are calculated, and how to enable or disable automatic donations to Expensify.org. -keywords: [New Expensify, personal karma, donations, expensify.org, save the world, Expensify.org donation, automatic donation, billing card charge] ---- - -# Personal Karma - -Personal Karma lets you automatically donate a small percentage of your monthly added expenses to [Expensify.org](https://www.expensify.org/about). - -For every $500 in expenses you add, $1 is donated to a related Expensify.org fund. Donations are calculated monthly and charged to the billing card on file. You'll receive a donation receipt by email after each charge. - ---- - -## How to enable Personal Karma - -Personal Karma can only be managed in a web browser. In the Expensify mobile app (iOS and Android), the **Enable Personal Karma** toggle is disabled and shows "Manage your personal karma preferences from web." - -1. In a web browser, select **Account** from the navigation tabs on the left. -2. Select **Save the world**. -3. Toggle **Enable Personal Karma** on. - -If you don't have a payment card on file, you'll be prompted to add one before Personal Karma can be enabled. If you dismiss the payment card prompt without adding a card, the toggle will revert to disabled. - -Once enabled, your billing card details are displayed below the toggle. - ---- - -## How to disable Personal Karma - -Personal Karma can only be managed in a web browser. In the Expensify mobile app (iOS and Android), the **Enable Personal Karma** toggle is disabled and shows "Manage your personal karma preferences from web." - -1. In a web browser, select **Account** from the navigation tabs on the left. -2. Select **Save the world**. -3. Toggle **Enable Personal Karma** off. -4. A confirmation prompt will ask if you want to stop donating to Expensify.org. Select **Disable** to confirm, or **Cancel** to keep Personal Karma enabled. - ---- - -## How Personal Karma calculates your donation amount - -Your donation is based on the total amount of expenses added during the month. - -This includes: - - Reported expenses - - Unreported expenses - - Invoice expenses - -For every $500 in total expenses, $1 is donated. For example: - - - $500 in expenses = $1 donation - - $2,500 in expenses = $5 donation - -The total donation amount is calculated at the end of each month. - ---- - -## How Expensify charges you for Personal Karma donations - -At the end of each month, Expensify calculates your total donation amount and charges your billing card on file. A donation receipt is sent to your email. - -To change the card where Personal Karma is charged, [update your Expensify billing card](https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Add-a-payment-card-and-view-your-subscription#how-to-update-your-expensify-billing-card). - ---- - -# FAQ - -## Does Personal Karma include unreported expenses? - -Yes. All expenses on the Expenses page — reported and unreported — are included in the monthly total used to calculate your donation. - -## Where does my donation go? - -Donations are directed to one of Expensify.org's five funds — Climate Justice, Food Security, Housing Equity, Reentry Services, or Youth Advocacy — based on the Merchant Category Code of each expense. - -## Can I choose which Expensify.org fund receives my donation? - -No. The fund is automatically selected based on the Merchant Category Code (MCC) of each expense. - -## How can I learn more about Expensify.org's fund categories? - -To learn more about Expensify.org funding categories, visit [expensify.org/funds](https://www.expensify.org/funds). From 316fe9cecf26dd35330f8a7c3a4de9f585a7575f Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:17:47 -1000 Subject: [PATCH 228/466] Update redirects.csv --- docs/redirects.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/redirects.csv b/docs/redirects.csv index f6b26d58d13a..d7e45143347a 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -1017,3 +1017,4 @@ https://help.expensify.com/articles/new-expensify/expensify-card/Set-Up-and-Mana https://help.expensify.com/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-or-Google-Pay,https://help.expensify.com/articles/new-expensify/expensify-card/Add-Expensify-Card-to-Apple-Pay-or-Google-Wallet https://help.expensify.com/articles/expensify-classic/settings/Magic-Code-Access-Troubleshooting,https://help.expensify.com/articles/expensify-classic/settings/Security-Code-Access-Troubleshooting https://help.expensify.com/Hidden/issues-receiving-the-magic-code,https://help.expensify.com/Hidden/issues-receiving-the-security-code +https://help.expensify.com/articles/new-expensify/settings/Personal-Karma,https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Manage-Personal-Karma-and-Corporate-Karma From b16d549be9465de5e1dc0bfaa27e8bef119a5915 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:10:26 +0530 Subject: [PATCH 229/466] Remove unsafe Plaid connection assertions --- src/libs/actions/Plaid.ts | 7 ++++--- .../companyCards/addNew/PlaidConnectionStep.tsx | 17 +++++++---------- src/types/onyx/AssignCard.ts | 2 +- src/types/onyx/CardFeeds.ts | 2 +- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/libs/actions/Plaid.ts b/src/libs/actions/Plaid.ts index 71a8cc08623d..4e5a791d6bbe 100644 --- a/src/libs/actions/Plaid.ts +++ b/src/libs/actions/Plaid.ts @@ -7,7 +7,7 @@ import getPlaidLinkTokenParameters from '@libs/getPlaidLinkTokenParameters'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeedWithDomainID, CardFeedWithNumber, CompanyCardFeedWithDomainID} from '@src/types/onyx/CardFeeds'; +import type {CardFeedWithNumber, CompanyCardFeedWithDomainID} from '@src/types/onyx/CardFeeds'; import Onyx from 'react-native-onyx'; @@ -144,7 +144,7 @@ function openPlaidBankAccountSelector(publicToken: string, bankName: string, all function importPlaidAccounts( publicToken: string, - feed: CardFeedWithNumber | CardFeedWithDomainID, + feed: string, feedName: string, country: string, domainName: string, @@ -152,9 +152,10 @@ function importPlaidAccounts( plaidAccessToken: string | undefined, domainAccountID?: number, ) { + const feedWithoutDomainID = feed.split(CONST.COMPANY_CARD.FEED_KEY_SEPARATOR).at(0) ?? feed; const parameters: ImportPlaidAccountsParams = { publicToken, - feed: getCompanyCardFeed(feed), + feed: feedWithoutDomainID, feedName, country, domainName, diff --git a/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx b/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx index e3e2e1f58fbe..dd9eaa5bd864 100644 --- a/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx +++ b/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx @@ -26,12 +26,8 @@ import {importPlaidAccounts, openPlaidCompanyCardLogin} from '@userActions/Plaid import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; -import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {LinkSuccessMetadata} from 'react-native-plaid-link-sdk'; -import type {PlaidLinkOnSuccessMetadata} from 'react-plaid-link/src/types'; - import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; @@ -53,8 +49,7 @@ function PlaidConnectionStep({feed, policyID, onExit, title}: PlaidConnectionSte const plaidErrors = plaidData?.errors; const subscribedKeyboardShortcuts = useRef void>>([]); const previousNetworkState = useRef(undefined); - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - const plaidDataErrorMessage = !isEmptyObject(plaidErrors) ? (Object.values(plaidErrors).at(0) as string) : ''; + const plaidDataErrorMessage = !isEmptyObject(plaidErrors) ? (Object.values(plaidErrors).at(0) ?? '') : ''; const {isOffline} = useNetwork(); const domain = getDomainNameForPolicy(policyID); @@ -134,10 +129,12 @@ function PlaidConnectionStep({feed, policyID, onExit, title}: PlaidConnectionSte // on success we need to move to bank connection screen with token, bank name = plaid Log.info('[PlaidLink] Success!'); - const plaidConnectedFeed = ((metadata?.institution as PlaidLinkOnSuccessMetadata['institution'])?.institution_id ?? - (metadata?.institution as LinkSuccessMetadata['institution'])?.id) as CardFeedWithNumber; - const plaidConnectedFeedName = - (metadata?.institution as PlaidLinkOnSuccessMetadata['institution'])?.name ?? (metadata?.institution as LinkSuccessMetadata['institution'])?.name; + const institution = metadata.institution; + let plaidConnectedFeed: string | undefined; + if (institution) { + plaidConnectedFeed = 'institution_id' in institution ? institution.institution_id : institution.id; + } + const plaidConnectedFeedName = institution?.name; if (feed) { if (plaidConnectedFeed && addNewCard?.data?.selectedCountry && plaidConnectedFeedName) { diff --git a/src/types/onyx/AssignCard.ts b/src/types/onyx/AssignCard.ts index 65a134a905ea..485165fa3a65 100644 --- a/src/types/onyx/AssignCard.ts +++ b/src/types/onyx/AssignCard.ts @@ -60,7 +60,7 @@ type AssignCardData = { dateOption: string; /** Bank ID for Plaid */ - institutionId?: CardFeedWithNumber; + institutionId?: string; /** Access token for Plaid bank */ plaidAccessToken?: string; diff --git a/src/types/onyx/CardFeeds.ts b/src/types/onyx/CardFeeds.ts index 03ab9bed2b0d..f353fe9aa076 100644 --- a/src/types/onyx/CardFeeds.ts +++ b/src/types/onyx/CardFeeds.ts @@ -307,7 +307,7 @@ type AddNewCardFeedData = { publicToken?: string; /** Feed from Plaid connection */ - plaidConnectedFeed?: CardFeedWithNumber; + plaidConnectedFeed?: string; /** Feed name from Plaid connection */ plaidConnectedFeedName?: string; From b8ef3878ec5f1740124af3f29cd978381a8aa902 Mon Sep 17 00:00:00 2001 From: VH Date: Fri, 7 Aug 2026 22:10:43 +0700 Subject: [PATCH 230/466] refactor(reportUtils): narrow transaction-message getters to a name string Add parseMovedTransactionReportIDs, getMovedTransactionReportID and getUnreportedTransactionReportID, and narrow getMovedTransactionMessage / getUnreportedTransactionMessage to take a precomputed derivedReportName string instead of the whole reportAttributes Record. MovedTransactionAction and UnreportedTransactionAction now source that single name via useDerivedReportNameByReportID, dropping their whole-Record useReportAttributes() subscription. The remaining callers (OptionsListUtils, SidebarUtils, ReportNameUtils, ContextMenuActions) extract the one report's name before calling, and are otherwise unchanged. getChatListItemReportName and getReportPreviewMessageForCopy keep their deprecatedGetReportName path for now; they are narrowed in the follow-up PR. Part of #66427 Phase 2. --- .../MovedTransactionAction.tsx | 13 +- .../UnreportedTransactionAction.tsx | 13 +- src/libs/OptionsListUtils/index.ts | 12 +- src/libs/ReportNameUtils.ts | 10 +- src/libs/ReportUtils.ts | 29 +++- src/libs/SidebarUtils.ts | 12 +- .../report/ContextMenu/ContextMenuActions.tsx | 8 +- tests/unit/OptionsListUtilsTest.tsx | 2 +- tests/unit/ReportUtilsTest.ts | 133 ++++++++++++++++++ 9 files changed, 202 insertions(+), 30 deletions(-) diff --git a/src/components/ReportActionItem/MovedTransactionAction.tsx b/src/components/ReportActionItem/MovedTransactionAction.tsx index 5e4a28ae305f..1028b94f8eb4 100644 --- a/src/components/ReportActionItem/MovedTransactionAction.tsx +++ b/src/components/ReportActionItem/MovedTransactionAction.tsx @@ -2,12 +2,12 @@ import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Parser from '@libs/Parser'; -import {getOriginalMessage, hasReasoning} from '@libs/ReportActionsUtils'; -import {getMovedTransactionMessage} from '@libs/ReportUtils'; +import {hasReasoning} from '@libs/ReportActionsUtils'; +import {getMovedTransactionMessage, getMovedTransactionReportID} from '@libs/ReportUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; import ReportActionItemMessageWithExplain from '@pages/inbox/report/ReportActionItemMessageWithExplain'; @@ -30,16 +30,15 @@ type MovedTransactionActionProps = { function MovedTransactionAction({action, originalReport}: MovedTransactionActionProps) { const {translate} = useLocalize(); - const movedTransactionOriginalMessage = getOriginalMessage(action); - const fromReportID = movedTransactionOriginalMessage?.fromReportID; + const fromReportID = getMovedTransactionReportID(action); const [fromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action.childReportID)}`); - const reportAttributes = useReportAttributes(); const isPendingDelete = fromReport?.pendingFields?.preview === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const message = getMovedTransactionMessage(translate, action, reportAttributes); + const derivedReportName = useDerivedReportNameByReportID(fromReportID); + const message = getMovedTransactionMessage(translate, action, derivedReportName); if (hasReasoning(action)) { return ( diff --git a/src/components/ReportActionItem/UnreportedTransactionAction.tsx b/src/components/ReportActionItem/UnreportedTransactionAction.tsx index 066e78010f1d..4f5f24270eee 100644 --- a/src/components/ReportActionItem/UnreportedTransactionAction.tsx +++ b/src/components/ReportActionItem/UnreportedTransactionAction.tsx @@ -2,12 +2,12 @@ import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useReportAttributes from '@hooks/useReportAttributes'; +import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Parser from '@libs/Parser'; -import {getOriginalMessage, hasReasoning} from '@libs/ReportActionsUtils'; -import {getUnreportedTransactionMessage} from '@libs/ReportUtils'; +import {hasReasoning} from '@libs/ReportActionsUtils'; +import {getUnreportedTransactionMessage, getUnreportedTransactionReportID} from '@libs/ReportUtils'; import ReportActionItemBasicMessage from '@pages/inbox/report/ReportActionItemBasicMessage'; import ReportActionItemMessageWithExplain from '@pages/inbox/report/ReportActionItemMessageWithExplain'; @@ -29,16 +29,15 @@ type UnreportedTransactionActionProps = { }; function UnreportedTransactionAction({action, originalReport}: UnreportedTransactionActionProps) { - const unreportedTransactionOriginalMessage = getOriginalMessage(action); - const fromReportID = unreportedTransactionOriginalMessage?.fromReportID; + const fromReportID = getUnreportedTransactionReportID(action); const {translate} = useLocalize(); const [fromReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`); const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(action.childReportID)}`); - const reportAttributes = useReportAttributes(); const isPendingDelete = fromReport?.pendingFields?.preview === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - const unreportedTransactionMessage = getUnreportedTransactionMessage(translate, action, reportAttributes); + const derivedReportName = useDerivedReportNameByReportID(fromReportID); + const unreportedTransactionMessage = getUnreportedTransactionMessage(translate, action, derivedReportName); if (hasReasoning(action)) { return ( diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 33a557b3ebdf..f5f6a33963b4 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -131,6 +131,7 @@ import { getIcons, getMovedActionMessage, getMovedTransactionMessage, + getMovedTransactionReportID, getParticipantsAccountIDsForDisplay, getPolicyChangeLogCopyMessage, getPolicyChangeMessage, @@ -144,6 +145,7 @@ import { getReportSubtitlePrefix, getReportTransactions, getUnreportedTransactionMessage, + getUnreportedTransactionReportID, getViolatingReportIDForRBRInLHN, hasIOUWaitingOnCurrentUserBankAccount, isArchivedNonExpenseReport, @@ -831,7 +833,10 @@ function getLastMessageTextForReport({ const properSchemaForModifiedExpenseMessage = Parser.htmlToText(properSchemaForModifiedExpenseMessageWithHTML); lastMessageTextFromReport = formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true); } else if (isMovedTransactionAction(lastReportAction)) { - lastMessageTextFromReport = Parser.htmlToText(getMovedTransactionMessage(translate, lastReportAction, reportAttributesDerived)); + const movedTransactionReportID = getMovedTransactionReportID(lastReportAction); + lastMessageTextFromReport = Parser.htmlToText( + getMovedTransactionMessage(translate, lastReportAction, movedTransactionReportID ? reportAttributesDerived?.[movedTransactionReportID]?.reportName : undefined), + ); } else if (isTaskAction(lastReportAction)) { lastMessageTextFromReport = formatReportLastMessageText(getTaskReportActionMessage(translate, lastReportAction).text); } else if (isCreatedTaskReportAction(lastReportAction)) { @@ -937,7 +942,10 @@ function getLastMessageTextForReport({ } else if (isMovedAction(lastReportAction)) { lastMessageTextFromReport = Parser.htmlToText(getMovedActionMessage(translate, lastReportAction, report)); } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { - lastMessageTextFromReport = Parser.htmlToText(getUnreportedTransactionMessage(translate, lastReportAction, reportAttributesDerived)); + const unreportedFromReportID = getUnreportedTransactionReportID(lastReportAction); + lastMessageTextFromReport = Parser.htmlToText( + getUnreportedTransactionMessage(translate, lastReportAction, unreportedFromReportID ? reportAttributesDerived?.[unreportedFromReportID]?.reportName : undefined), + ); } else if (isActionableMentionWhisper(lastReportAction)) { const targetAccountIDs = getOriginalMessage(lastReportAction)?.inviteeAccountIDs; lastMessageTextFromReport = Parser.htmlToText(getActionableMentionWhisperMessage(translate, lastReportAction, getPersonalDetailsListByIDs(targetAccountIDs, personalDetails))); diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index da9b38a04e3e..fc7746891549 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -144,6 +144,7 @@ import { getMoneyRequestSpendBreakdown, getMovedActionMessage, getMovedTransactionMessage, + getMovedTransactionReportID, getParentReport, getPolicyChangeLogCopyMessage, getPolicyChangeMessage, @@ -155,6 +156,7 @@ import { getReportOrDraftReport, getTransactionReportName, getUnreportedTransactionMessage, + getUnreportedTransactionReportID, getWorkspaceNameUpdatedMessage, isAdminRoom, isArchivedNonExpenseReport, @@ -625,11 +627,15 @@ function computeReportNameBasedOnReportAction({ } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { - return Parser.htmlToText(getUnreportedTransactionMessage(translate, parentReportAction, reportAttributes)); + const unreportedReportID = getUnreportedTransactionReportID(parentReportAction); + const reportName = unreportedReportID ? reportAttributes?.[unreportedReportID]?.reportName : undefined; + return Parser.htmlToText(getUnreportedTransactionMessage(translate, parentReportAction, reportName)); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { - return Parser.htmlToText(getMovedTransactionMessage(translate, parentReportAction, reportAttributes)); + const movedReportID = getMovedTransactionReportID(parentReportAction); + const reportName = movedReportID ? reportAttributes?.[movedReportID]?.reportName : undefined; + return Parser.htmlToText(getMovedTransactionMessage(translate, parentReportAction, reportName)); } if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MAX_EXPENSE_AMOUNT)) { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7c069b8092ad..c1b16fdbd282 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -236,7 +236,7 @@ import { // The functions imported here are pure utility functions that don't create initialization-time dependencies. // ReportNameUtils imports helper functions from ReportUtils, and ReportUtils imports name generation functions from ReportNameUtils. // eslint-disable-next-line import/no-cycle -import {deprecatedGetReportName, getGroupChatName, getInvoicePayerName, getInvoiceReportName} from './ReportNameUtils'; +import {deprecatedGetReportName, getGroupChatName, getInvoicePayerName, getInvoiceReportName, getReportName} from './ReportNameUtils'; import {getAllPersonalDetailLogins} from './ShortMentionLogins'; import {shouldRestrictUserBillableActions} from './SubscriptionUtils'; import {isTaskCompleted} from './TaskUtils'; @@ -7363,16 +7363,26 @@ function getDeletedTransactionMessage(translate: LocalizedTranslate, action: Rep return message; } -function getMovedTransactionMessage(translate: LocalizedTranslate, action: ReportAction, reportAttributes?: ReportAttributesDerivedValue['reports']) { +function parseMovedTransactionReportIDs(action: ReportAction): {fromReportID: string | undefined; toReportID: string | undefined} { const movedTransactionOriginalMessage = getOriginalMessage(action) ?? {}; const {toReportID, fromReportID} = movedTransactionOriginalMessage as OriginalMessageMovedTransaction; + return {toReportID, fromReportID}; +} + +function getMovedTransactionReportID(action: ReportAction): string | undefined { + const {toReportID, fromReportID} = parseMovedTransactionReportIDs(action); + return fromReportID ?? toReportID; +} + +function getMovedTransactionMessage(translate: LocalizedTranslate, action: ReportAction, derivedReportName?: string | undefined) { + const {toReportID, fromReportID} = parseMovedTransactionReportIDs(action); const toReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${toReportID}`]; const fromReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; const report = fromReport ?? toReport; - const reportName = Parser.htmlToText(deprecatedGetReportName(report, reportAttributes) ?? report?.reportName ?? ''); + const reportName = Parser.htmlToText(getReportName(report, derivedReportName)); const reportUrl = getReportURLForCurrentContext(report?.reportID); if (typeof fromReportID === 'undefined') { return reportName ? translate('iou.movedTransactionTo', reportUrl, reportName) : translate('iou.movedTransactionToAnotherReport'); @@ -7380,13 +7390,17 @@ function getMovedTransactionMessage(translate: LocalizedTranslate, action: Repor return reportName ? translate('iou.movedTransactionFrom', reportUrl, reportName) : translate('iou.movedTransactionFromAnotherReport'); } -function getUnreportedTransactionMessage(translate: LocalizedTranslate, action: ReportAction, reportAttributes?: ReportAttributesDerivedValue['reports']) { - const movedTransactionOriginalMessage = getOriginalMessage(action) ?? {}; - const {fromReportID} = movedTransactionOriginalMessage as OriginalMessageMovedTransaction; +function getUnreportedTransactionReportID(action: ReportAction): string | undefined { + const {fromReportID} = parseMovedTransactionReportIDs(action); + return fromReportID; +} + +function getUnreportedTransactionMessage(translate: LocalizedTranslate, action: ReportAction, derivedReportName?: string | undefined) { + const fromReportID = getUnreportedTransactionReportID(action); const fromReport = deprecatedAllReports?.[`${ONYXKEYS.COLLECTION.REPORT}${fromReportID}`]; - const reportName = Parser.htmlToText(deprecatedGetReportName(fromReport, reportAttributes) ?? fromReport?.reportName ?? ''); + const reportName = Parser.htmlToText(getReportName(fromReport, derivedReportName)); let reportUrl = getReportURLForCurrentContext(fromReportID); @@ -14529,6 +14543,7 @@ export { isUploadingAttachmentRemovedFromDraft, getUploadingAttachmentHtmlFromComment, buildEditedCommentWithAttachment, + parseMovedTransactionReportIDs, }; export type { diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 722dc6b7c810..1d3dd4993658 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -169,6 +169,7 @@ import { getDisplayNamesWithTooltips, getIcons, getMovedTransactionMessage, + getMovedTransactionReportID, getParticipantsAccountIDsForDisplay, getPolicyChangeLogCopyMessage, getPolicyName, @@ -179,6 +180,7 @@ import { getReportParticipantsTitle, getReportSubtitlePrefix, getUnreportedTransactionMessage, + getUnreportedTransactionReportID, getViolatingReportIDForRBRInLHN, getWorkspaceNameUpdatedMessage, hasReportErrorsOtherThanFailedReceipt, @@ -1243,7 +1245,10 @@ function getOptionData({ } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_EMPLOYEE) { result.alternateText = getPolicyChangeLogDeleteMemberMessage(translate, lastAction); } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { - result.alternateText = Parser.htmlToText(getUnreportedTransactionMessage(translate, lastAction, reportAttributesDerived)); + const unreportedFromReportID = getUnreportedTransactionReportID(lastAction); + result.alternateText = Parser.htmlToText( + getUnreportedTransactionMessage(translate, lastAction, unreportedFromReportID ? reportAttributesDerived?.[unreportedFromReportID]?.reportName : undefined), + ); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_CUSTOM_UNIT_RATE) { result.alternateText = getReportActionMessageText(lastAction) ?? ''; } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION) { @@ -1327,7 +1332,10 @@ function getOptionData({ } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OWNERSHIP) { result.alternateText = Parser.htmlToText(getUpdatedOwnershipMessage(translate, lastAction, policy)); } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { - result.alternateText = Parser.htmlToText(getMovedTransactionMessage(translate, lastAction, reportAttributesDerived)); + const movedTransactionReportID = getMovedTransactionReportID(lastAction); + result.alternateText = Parser.htmlToText( + getMovedTransactionMessage(translate, lastAction, movedTransactionReportID ? reportAttributesDerived?.[movedTransactionReportID]?.reportName : undefined), + ); } else if (isActionOfType(lastAction, CONST.REPORT.ACTIONS.TYPE.SETTLEMENT_ACCOUNT_LOCKED)) { result.alternateText = Parser.htmlToText(getSettlementAccountLockedMessage(translate, lastAction)); } else if (lastAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && lastActorDisplayName && lastMessageTextFromReport) { diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 101d4557ce38..14a801f3f59f 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -182,6 +182,7 @@ import { getIOUReportActionDisplayMessage, getMovedActionMessage, getMovedTransactionMessage, + getMovedTransactionReportID, getPolicyChangeLogCopyMessage, getPolicyChangeMessage, getReimbursementDeQueuedOrCanceledActionMessage, @@ -189,6 +190,7 @@ import { getReportOrDraftReport, getReportPreviewMessageForCopy, getUnreportedTransactionMessage, + getUnreportedTransactionReportID, getWorkspaceNameUpdatedMessage, isExpenseReport, isSelfDM, @@ -1164,7 +1166,8 @@ const ContextMenuActions: ContextMenuAction[] = [ } else if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_OWNERSHIP) { setClipboardMessage(Parser.htmlToText(getUpdatedOwnershipMessage(translate, reportAction, policy) ?? '')); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { - setClipboardMessage(getUnreportedTransactionMessage(translate, reportAction, reportAttributes)); + const unreportedReportID = getUnreportedTransactionReportID(reportAction); + setClipboardMessage(getUnreportedTransactionMessage(translate, reportAction, unreportedReportID ? reportAttributes?.[unreportedReportID]?.reportName : undefined)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MARKED_REIMBURSED)) { Clipboard.setString(getMarkedReimbursedMessage(translate, reportAction)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSED)) { @@ -1368,7 +1371,8 @@ const ContextMenuActions: ContextMenuAction[] = [ ) { setClipboardMessage(getDelegateSubmitMessage(translate, reportAction, currentUserPersonalDetails.email)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { - setClipboardMessage(getMovedTransactionMessage(translate, reportAction, reportAttributes)); + const movedReportID = getMovedTransactionReportID(reportAction); + setClipboardMessage(getMovedTransactionMessage(translate, reportAction, movedReportID ? reportAttributes?.[movedReportID]?.reportName : undefined)); } else if (isMovedAction(reportAction)) { setClipboardMessage(getMovedActionMessage(translate, reportAction, originalReport)); } else if (isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_CARD_FRAUD_ALERT)) { diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 3ad1188fa3fa..5c319ffb7ccc 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -5541,7 +5541,7 @@ describe('OptionsListUtils', () => { currentUserLogin: CURRENT_USER_EMAIL, }); - expect(lastMessage).toBe(Parser.htmlToText(getMovedTransactionMessage(translateLocal, movedTransactionAction))); + expect(lastMessage).toBe(Parser.htmlToText(getMovedTransactionMessage(translateLocal, movedTransactionAction, undefined))); }); describe('SUBMITTED action', () => { it('should return automatic submitted message if submitted via harvesting', async () => { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 7616dc6df04f..9ecea3817556 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -114,6 +114,7 @@ import { getMostRecentlyVisitedReport, getMovedActionMessage, getMovedTransactionMessage, + getMovedTransactionReportID, getNextApproverAccountID, getNonHeldAndFullAmount, getOriginalReportID, @@ -151,6 +152,7 @@ import { getTransactionsWithReceipts, getUnheldReimbursableTotal, getUnreportedTransactionMessage, + getUnreportedTransactionReportID, getUserDetailTooltipText, getViolatingReportIDForRBRInLHN, getWorkspaceIcon, @@ -190,6 +192,7 @@ import { isUnread, isUploadingAttachmentRemovedFromDraft, isWorkspaceMemberLeavingWorkspaceRoom, + parseMovedTransactionReportIDs, parseReportRouteParams, prepareOnboardingOnyxData, pushTransactionAutoSelectionsOnyxData, @@ -21348,6 +21351,136 @@ describe('ReportUtils', () => { }); }); + describe('parseMovedTransactionReportIDs', () => { + it('should return both fromReportID and toReportID when both are present', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + originalMessage: { + fromReportID: '111', + toReportID: '222', + }, + }); + + const result = parseMovedTransactionReportIDs(action); + expect(result).toEqual({fromReportID: '111', toReportID: '222'}); + }); + + it('should return undefined for missing IDs', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + originalMessage: {}, + }); + + const result = parseMovedTransactionReportIDs(action); + expect(result.fromReportID).toBeUndefined(); + expect(result.toReportID).toBeUndefined(); + }); + + it('should return only fromReportID when toReportID is missing', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + originalMessage: { + fromReportID: '111', + }, + }); + + const result = parseMovedTransactionReportIDs(action); + expect(result.fromReportID).toBe('111'); + expect(result.toReportID).toBeUndefined(); + }); + + it('should return only toReportID when fromReportID is missing', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + originalMessage: { + toReportID: '222', + }, + }); + + const result = parseMovedTransactionReportIDs(action); + expect(result.fromReportID).toBeUndefined(); + expect(result.toReportID).toBe('222'); + }); + }); + + describe('getMovedTransactionReportID', () => { + it('should return fromReportID when both IDs are present', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + originalMessage: { + fromReportID: '111', + toReportID: '222', + }, + }); + + expect(getMovedTransactionReportID(action)).toBe('111'); + }); + + it('should fall back to toReportID when fromReportID is undefined', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + originalMessage: { + toReportID: '222', + }, + }); + + expect(getMovedTransactionReportID(action)).toBe('222'); + }); + + it('should return undefined when both IDs are missing', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION, + originalMessage: {}, + }); + + expect(getMovedTransactionReportID(action)).toBeUndefined(); + }); + }); + + describe('getUnreportedTransactionReportID', () => { + it('should return fromReportID', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION, + originalMessage: { + fromReportID: '333', + }, + }); + + expect(getUnreportedTransactionReportID(action)).toBe('333'); + }); + + it('should return undefined when fromReportID is missing', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION, + originalMessage: {}, + }); + + expect(getUnreportedTransactionReportID(action)).toBeUndefined(); + }); + + it('should ignore toReportID and only return fromReportID', () => { + const action = createMock({ + ...LHNTestUtils.getFakeReportAction(), + actionName: CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION, + originalMessage: { + fromReportID: '333', + toReportID: '444', + }, + }); + + expect(getUnreportedTransactionReportID(action)).toBe('333'); + }); + }); + describe('getMovedActionMessage', () => { it('should return empty string for non-moved action', () => { const action = createMock({ From 50310f5f3494e6f6580a5e7241447de52e9ccb46 Mon Sep 17 00:00:00 2001 From: Caio Coan Date: Tue, 18 Aug 2026 09:13:49 -0300 Subject: [PATCH 231/466] Add test case --- tests/unit/TransactionUtilsTest.ts | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 7729b08ff99c..8b5a378542dd 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1887,6 +1887,60 @@ describe('TransactionUtils', () => { expect(TransactionUtils.shouldShowViolation(iouReport, policy, CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT, 'test@example.com')).toBe(false); }); + + it('should return true for auto approval limit violation when the submitter is also the approver of the processing report', () => { + const iouReport: Report = { + ...createRandomReport(0, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + ownerAccountID: CURRENT_USER_ID, + managerID: CURRENT_USER_ID, + }; + + const policy: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + role: CONST.POLICY.ROLE.ADMIN, + }; + + expect(TransactionUtils.shouldShowViolation(iouReport, policy, CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT, CURRENT_USER_EMAIL)).toBe(true); + }); + + it('should return false for auto approval limit violation when the submitter is not the approver of the processing report', () => { + const iouReport: Report = { + ...createRandomReport(0, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + ownerAccountID: CURRENT_USER_ID, + managerID: 2, + }; + + const policy: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + role: CONST.POLICY.ROLE.ADMIN, + }; + + expect(TransactionUtils.shouldShowViolation(iouReport, policy, CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT, CURRENT_USER_EMAIL)).toBe(false); + }); + + it('should return true for auto approval limit violation when the approver is not the submitter of the processing report', () => { + const iouReport: Report = { + ...createRandomReport(0, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + ownerAccountID: 2, + managerID: CURRENT_USER_ID, + }; + + const policy: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + role: CONST.POLICY.ROLE.ADMIN, + }; + + expect(TransactionUtils.shouldShowViolation(iouReport, policy, CONST.VIOLATIONS.OVER_AUTO_APPROVAL_LIMIT, CURRENT_USER_EMAIL)).toBe(true); + }); }); describe('getReportOwnerAsAttendee', () => { From 3d006435a6025cf3006d6956fb8e2cb9039aa93b Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Tue, 18 Aug 2026 16:46:36 +0200 Subject: [PATCH 232/466] Display short date labels on chart X axis --- .../Charts/BarChart/BarChartContent.tsx | 4 +- .../Charts/LineChart/LineChartContent.tsx | 4 +- .../Charts/hooks/useChartLabelMeasurements.ts | 15 ++++---- src/components/Charts/types/index.ts | 5 ++- src/components/Charts/utils/index.ts | 6 +++ src/components/Search/SearchBarChart.tsx | 3 +- src/components/Search/SearchChartView.tsx | 3 +- src/components/Search/SearchLineChart.tsx | 3 +- .../Search/SearchList/ListItem/types.ts | 9 +++++ src/components/Search/chartGroupByConfig.ts | 11 ++++++ src/components/Search/types.ts | 3 ++ src/libs/DateUtils.ts | 38 +++++++++++++++++++ src/libs/SearchUIUtils.ts | 14 ++++--- tests/unit/Search/SearchUIUtilsTest.ts | 9 +++++ tests/unit/Search/useInsightDataTest.ts | 1 + 15 files changed, 108 insertions(+), 20 deletions(-) diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index 58ea85e105a8..f89b8e00a2c2 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -14,7 +14,7 @@ import { useDynamicYDomain, useLabelHitTesting, } from '@components/Charts/hooks'; -import {calculateMinDomainPadding, getYAxisLabelWidth} from '@components/Charts/utils'; +import {calculateMinDomainPadding, getXAxisLabel, getYAxisLabelWidth} from '@components/Charts/utils'; import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING} from '@components/Charts/VictoryTheme'; import useTheme from '@hooks/useTheme'; @@ -88,7 +88,7 @@ function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'l const totalDomainPadding = domainPadding.left + domainPadding.right; const paddingScale = barAreaWidth > 0 ? barAreaWidth / (barAreaWidth + totalDomainPadding) : 0; - const originalLabels = data.map((p) => p.label); + const originalLabels = data.map(getXAxisLabel); const measurements = useChartLabelMeasurements(data, fontManager, variables.iconSizeExtraSmall); diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index 131a831f7b75..614aa459136b 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -16,7 +16,7 @@ import { useDynamicYDomain, useLabelHitTesting, } from '@components/Charts/hooks'; -import {getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; +import {getXAxisLabel, getYAxisLabelWidth, labelOverhang} from '@components/Charts/utils'; import VictoryTheme, {CHART_CONTENT_MIN_HEIGHT, GLYPH_PADDING, LABEL_PADDING, LABEL_ROTATIONS, SIN_45} from '@components/Charts/VictoryTheme'; import useTheme from '@hooks/useTheme'; @@ -129,7 +129,7 @@ function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = ' measurements, }); - const originalLabels = data.map((p) => p.label); + const originalLabels = data.map(getXAxisLabel); const {isCursorOverLabel, findLabelCursorX, updateTickPositions} = useLabelHitTesting({ fontManager, diff --git a/src/components/Charts/hooks/useChartLabelMeasurements.ts b/src/components/Charts/hooks/useChartLabelMeasurements.ts index eb9cb0e302a6..9d88d37befcf 100644 --- a/src/components/Charts/hooks/useChartLabelMeasurements.ts +++ b/src/components/Charts/hooks/useChartLabelMeasurements.ts @@ -1,5 +1,5 @@ import type {ChartDataPoint} from '@components/Charts/types'; -import {getFontLineMetrics, measureTextWidth} from '@components/Charts/utils'; +import {getFontLineMetrics, getXAxisLabel, measureTextWidth} from '@components/Charts/utils'; import {ELLIPSIS, MIN_TRUNCATED_CHARS} from '@components/Charts/VictoryTheme'; import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; @@ -15,22 +15,23 @@ function useChartLabelMeasurements(data: ChartDataPoint[], fontManager: SkTypefa const {ascent, descent} = getFontLineMetrics(fontManager, fontSize); const lineHeight = Math.abs(ascent) + Math.abs(descent); const ellipsisWidth = measureTextWidth(ELLIPSIS, fontManager, fontSize); - const labelWidths = data.map((point) => measureTextWidth(point.label, fontManager, fontSize)); + const labels = data.map(getXAxisLabel); + const labelWidths = labels.map((label) => measureTextWidth(label, fontManager, fontSize)); const maxLabelWidth = Math.max(...labelWidths); const firstLabelWidth = labelWidths.at(0) ?? 0; const lastLabelWidth = labelWidths.at(-1) ?? 0; const minTruncatedWidth = Math.max( - ...data.map((point, index) => { - if (point.label.length <= MIN_TRUNCATED_CHARS) { + ...labels.map((label, index) => { + if (label.length <= MIN_TRUNCATED_CHARS) { return labelWidths.at(index) ?? 0; } - return measureTextWidth(point.label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize); + return measureTextWidth(label.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize); }), ); - const firstLabel = data.at(0)?.label ?? ''; - const lastLabel = data.at(-1)?.label ?? ''; + const firstLabel = labels.at(0) ?? ''; + const lastLabel = labels.at(-1) ?? ''; const firstMinTrunc = firstLabel.length <= MIN_TRUNCATED_CHARS ? firstLabelWidth : measureTextWidth(firstLabel.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize); const lastMinTrunc = lastLabel.length <= MIN_TRUNCATED_CHARS ? lastLabelWidth : measureTextWidth(lastLabel.slice(0, MIN_TRUNCATED_CHARS) + ELLIPSIS, fontManager, fontSize); diff --git a/src/components/Charts/types/index.ts b/src/components/Charts/types/index.ts index 529a45556401..e220ec1162fa 100644 --- a/src/components/Charts/types/index.ts +++ b/src/components/Charts/types/index.ts @@ -4,9 +4,12 @@ import type {SkParagraph} from '@shopify/react-native-skia'; import type {ValueOf} from 'type-fest'; type ChartDataPoint = { - /** Label displayed under the data point (e.g., "Amazon", "Nov 2025") */ + /** Full label for the data point (e.g., "Amazon", "November 2025") */ label: string; + /** Compact label for the x-axis (e.g., "Nov ’25"). Defaults to `label`. */ + shortLabel?: string; + /** Total amount (pre-formatted, e.g., dollars not cents) */ total: number; diff --git a/src/components/Charts/utils/index.ts b/src/components/Charts/utils/index.ts index 0ed4c688edeb..692b073cc38a 100644 --- a/src/components/Charts/utils/index.ts +++ b/src/components/Charts/utils/index.ts @@ -257,6 +257,11 @@ function processDataIntoSlices( ).slices; } +/** Label to render on the x-axis for a data point: the compact one when provided, otherwise the full label. */ +function getXAxisLabel(point: ChartDataPoint): string { + return point.shortLabel ?? point.label; +} + /** Truncate `label` so its pixel width fits within `maxWidth`, adding ellipsis. */ function truncateLabel(label: string, labelWidth: number, maxWidth: number, ellipsisWidth: number): string { if (labelWidth <= maxWidth) { @@ -460,6 +465,7 @@ export { isAngleInSlice, findSliceAtPosition, processDataIntoSlices, + getXAxisLabel, truncateLabel, effectiveWidth, effectiveHeight, diff --git a/src/components/Search/SearchBarChart.tsx b/src/components/Search/SearchBarChart.tsx index 3556c4cc3113..ee5f66145356 100644 --- a/src/components/Search/SearchBarChart.tsx +++ b/src/components/Search/SearchBarChart.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type {SearchChartProps} from './types'; -function SearchBarChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { +function SearchBarChart({data, getLabel, getShortLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { const {getCurrencyDecimals} = useCurrencyListActions(); const chartData: ChartDataPoint[] = data.map((item) => { const currency = item.currency ?? 'USD'; @@ -18,6 +18,7 @@ function SearchBarChart({data, getLabel, getFilterQuery, onItemPress, isLoading, return { label: getLabel(item), + shortLabel: getShortLabel?.(item), total: totalInDisplayUnits, }; }); diff --git a/src/components/Search/SearchChartView.tsx b/src/components/Search/SearchChartView.tsx index 69650d56bfb4..f5df6f65b5c4 100644 --- a/src/components/Search/SearchChartView.tsx +++ b/src/components/Search/SearchChartView.tsx @@ -54,7 +54,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar const {preferredLocale} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); - const {getLabel, getFilterQuery} = CHART_GROUP_BY_CONFIG[groupBy]; + const {getLabel, getShortLabel, getFilterQuery} = CHART_GROUP_BY_CONFIG[groupBy]; const ChartComponent = CHART_VIEW_TO_COMPONENT[view]; const handleItemPress = (filterQuery: string) => { @@ -90,6 +90,7 @@ function SearchChartView({queryJSON, view, groupBy, data, isLoading}: SearchChar StringUtils.normalize(getLabel(item))} + getShortLabel={getShortLabel} getFilterQuery={getFilterQuery} onItemPress={handleItemPress} isLoading={isLoading} diff --git a/src/components/Search/SearchLineChart.tsx b/src/components/Search/SearchLineChart.tsx index 0e19a3755587..41a9424be910 100644 --- a/src/components/Search/SearchLineChart.tsx +++ b/src/components/Search/SearchLineChart.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type {SearchChartProps} from './types'; -function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { +function SearchLineChart({data, getLabel, getShortLabel, getFilterQuery, onItemPress, isLoading, unit, unitPosition}: SearchChartProps) { const {getCurrencyDecimals} = useCurrencyListActions(); const chartData: ChartDataPoint[] = data.map((item) => { const currency = item.currency ?? 'USD'; @@ -18,6 +18,7 @@ function SearchLineChart({data, getLabel, getFilterQuery, onItemPress, isLoading return { label: getLabel(item), + shortLabel: getShortLabel?.(item), total: totalInDisplayUnits, }; }); diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 9baaa9850982..5ec6c2b87c44 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -420,6 +420,9 @@ type TransactionMonthGroupListItemType = TransactionGroupListItemType & {grouped /** Final and formatted "month" value used for displaying */ formattedMonth: string; + /** Compact "month" value used where space is tight, e.g. chart axis labels */ + shortFormattedMonth: string; + /** Key used for sorting */ sortKey: number; }; @@ -450,6 +453,9 @@ type TransactionTagGroupListItemType = TransactionGroupListItemType & {groupedBy type TransactionWeekGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.WEEK} & SearchWeekGroup & { /** Final and formatted "week" value used for displaying */ formattedWeek: string; + + /** Compact "week" value used where space is tight, e.g. chart axis labels */ + shortFormattedWeek: string; }; type TransactionYearGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.YEAR} & SearchYearGroup & { @@ -464,6 +470,9 @@ type TransactionQuarterGroupListItemType = TransactionGroupListItemType & {group /** Final and formatted "quarter" value used for displaying */ formattedQuarter: string; + /** Compact "quarter" value used where space is tight, e.g. chart axis labels */ + shortFormattedQuarter: string; + /** Sort key for sorting */ sortKey: number; }; diff --git a/src/components/Search/chartGroupByConfig.ts b/src/components/Search/chartGroupByConfig.ts index 9bc14e9acf0b..59e3f39f364f 100644 --- a/src/components/Search/chartGroupByConfig.ts +++ b/src/components/Search/chartGroupByConfig.ts @@ -17,8 +17,16 @@ import type { import type {GroupedItem, SearchGroupBy} from './types'; type ChartGroupByConfig = { + /** Name of the icon rendered next to the chart title */ titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar'; + + /** Returns the full label for a group (e.g. "Amazon", "November 2025") */ getLabel: (item: GroupedItem) => string; + + /** Returns the compact label for chart axes, or undefined to fall back to `getLabel` */ + getShortLabel?: (item: GroupedItem) => string | undefined; + + /** Builds the query fragment appended to the current query to drill into a group's transactions */ getFilterQuery: (item: GroupedItem) => string; }; @@ -61,6 +69,7 @@ const CHART_GROUP_BY_CONFIG: Record = { [CONST.SEARCH.GROUP_BY.MONTH]: { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionMonthGroupListItemType).formattedMonth ?? '', + getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.MONTH ? item.shortFormattedMonth : undefined), getFilterQuery: (item: GroupedItem) => { const monthItem = item as TransactionMonthGroupListItemType; const {start, end} = DateUtils.getMonthDateRange(monthItem.year, monthItem.month); @@ -70,6 +79,7 @@ const CHART_GROUP_BY_CONFIG: Record = { [CONST.SEARCH.GROUP_BY.WEEK]: { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionWeekGroupListItemType).formattedWeek ?? '', + getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.WEEK ? item.shortFormattedWeek : undefined), getFilterQuery: (item: GroupedItem) => { const weekItem = item as TransactionWeekGroupListItemType; const {start, end} = DateUtils.getWeekDateRange(weekItem.week); @@ -88,6 +98,7 @@ const CHART_GROUP_BY_CONFIG: Record = { [CONST.SEARCH.GROUP_BY.QUARTER]: { titleIconName: 'Calendar', getLabel: (item: GroupedItem) => (item as TransactionQuarterGroupListItemType).formattedQuarter ?? '', + getShortLabel: (item: GroupedItem) => (item.groupedBy === CONST.SEARCH.GROUP_BY.QUARTER ? item.shortFormattedQuarter : undefined), getFilterQuery: (item: GroupedItem) => { const quarterItem = item as TransactionQuarterGroupListItemType; const {start, end} = DateUtils.getQuarterDateRange(quarterItem.year, quarterItem.quarter); diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 64ddbaedb3ed..a255ff30f41a 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -448,6 +448,9 @@ type SearchChartProps = { /** Function to extract label from grouped item */ getLabel: (item: GroupedItem) => string; + /** Function to extract the compact axis label from grouped item. When it returns undefined, `getLabel` is used. */ + getShortLabel?: (item: GroupedItem) => string | undefined; + /** Function to build filter query from grouped item */ getFilterQuery: (item: GroupedItem) => string; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 1ba4ee4e460e..2b5557532b9b 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -1118,6 +1118,20 @@ function isDateStringInMonth(dateString: string, year: number, month: number): b return datePart >= monthStart && datePart <= monthEnd; } +/** + * Returns a month label, e.g. "September 2025". + */ +function getFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string { + return format(new Date(year, month - 1, 1), 'MMMM yyyy', {locale: dateFnsLocale}); +} + +/** + * Returns a compact month label, e.g. "Sep ’25". + */ +function getShortFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string { + return format(new Date(year, month - 1, 1), 'MMM ’yy', {locale: dateFnsLocale}); +} + /** * Returns a formatted date range. */ @@ -1133,6 +1147,19 @@ function getFormattedDateRangeForSearch(startDate: string, endDate: string, date return `${format(start, 'MMM d', {locale: dateFnsLocale})} - ${format(end, 'MMM d, yyyy', {locale: dateFnsLocale})}`; } +/** + * Returns a compact date range, e.g. "Sep 1 - 7 ’25". + */ +function getShortFormattedDateRangeForSearch(startDate: string, endDate: string, dateFnsLocale: DateFnsLocale | undefined): string { + const start = parse(startDate, 'yyyy-MM-dd', new Date()); + const end = parse(endDate, 'yyyy-MM-dd', new Date()); + if (!isSameYear(start, end)) { + return `${format(start, 'MMM d ’yy', {locale: dateFnsLocale})} - ${format(end, 'MMM d ’yy', {locale: dateFnsLocale})}`; + } + const formattedEnd = isSameMonth(start, end) ? format(end, 'd ’yy', {locale: dateFnsLocale}) : format(end, 'MMM d ’yy', {locale: dateFnsLocale}); + return `${format(start, 'MMM d', {locale: dateFnsLocale})} - ${formattedEnd}`; +} + function getYearDateRange(year: number): {start: string; end: string} { return { start: `${year}-01-01`, @@ -1163,6 +1190,13 @@ function getFormattedQuarterForSearch(year: number, quarter: number, dateFnsLoca return `Q${quarter} ${year} (${format(quarterStart, 'MMM d', {locale: dateFnsLocale})} - ${format(quarterEnd, 'MMM d', {locale: dateFnsLocale})})`; } +/** + * Returns a compact quarter label, e.g. "Q3 ’25". + */ +function getShortFormattedQuarterForSearch(year: number, quarter: number): string { + return `Q${quarter} ’${String(year).slice(-2)}`; +} + function getNextNthOfMonth(nth: number) { const now = new Date(); const year = now.getFullYear(); @@ -1246,10 +1280,14 @@ const DateUtils = { getMonthDateRange, getWeekDateRange, isDateStringInMonth, + getFormattedMonthForSearch, + getShortFormattedMonthForSearch, getFormattedDateRangeForSearch, + getShortFormattedDateRangeForSearch, getYearDateRange, getQuarterDateRange, getFormattedQuarterForSearch, + getShortFormattedQuarterForSearch, getNextNthOfMonth, }; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 38a4d2093515..f95c3d3b56c8 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3726,16 +3726,14 @@ function getMonthSections( queryJSON && monthGroup.year && monthGroup.month ? buildDateRangeGroupQuery(queryJSON, DateUtils.getMonthDateRange(monthGroup.year, monthGroup.month)) : undefined; const transactionsQueryJSON = dateResult?.transactionsQueryJSON; - const monthDate = new Date(monthGroup.year, monthGroup.month - 1, 1); - const formattedMonth = format(monthDate, 'MMMM yyyy', {locale: dateFnsLocale}); - monthSections[key] = { groupedBy: CONST.SEARCH.GROUP_BY.MONTH, transactions: [], transactionsQueryJSON, keyForList: key, ...monthGroup, - formattedMonth, + formattedMonth: DateUtils.getFormattedMonthForSearch(monthGroup.year, monthGroup.month, dateFnsLocale), + shortFormattedMonth: DateUtils.getShortFormattedMonthForSearch(monthGroup.year, monthGroup.month, dateFnsLocale), sortKey: monthGroup.year * 100 + monthGroup.month, }; } @@ -3764,7 +3762,10 @@ function getWeekSections( const rawRange = DateUtils.getWeekDateRange(weekGroup.week); const dateResult = queryJSON && weekGroup.week ? buildDateRangeGroupQuery(queryJSON, rawRange) : undefined; const transactionsQueryJSON = dateResult?.transactionsQueryJSON; - const formattedWeek = DateUtils.getFormattedDateRangeForSearch(dateResult?.start ?? rawRange.start, dateResult?.end ?? rawRange.end, dateFnsLocale); + const weekStart = dateResult?.start ?? rawRange.start; + const weekEnd = dateResult?.end ?? rawRange.end; + const formattedWeek = DateUtils.getFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale); + const shortFormattedWeek = DateUtils.getShortFormattedDateRangeForSearch(weekStart, weekEnd, dateFnsLocale); weekSections[key] = { groupedBy: CONST.SEARCH.GROUP_BY.WEEK, @@ -3772,6 +3773,7 @@ function getWeekSections( transactionsQueryJSON, ...weekGroup, formattedWeek, + shortFormattedWeek, keyForList: key, }; } @@ -3830,6 +3832,7 @@ function getQuarterSections( ? buildDateRangeGroupQuery(queryJSON, DateUtils.getQuarterDateRange(quarterGroup.year, quarterGroup.quarter))?.transactionsQueryJSON : undefined; const formattedQuarter = DateUtils.getFormattedQuarterForSearch(quarterGroup.year, quarterGroup.quarter, dateFnsLocale); + const shortFormattedQuarter = DateUtils.getShortFormattedQuarterForSearch(quarterGroup.year, quarterGroup.quarter); quarterSections[key] = { groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, @@ -3837,6 +3840,7 @@ function getQuarterSections( transactionsQueryJSON, ...quarterGroup, formattedQuarter, + shortFormattedQuarter, sortKey: quarterGroup.year * 10 + quarterGroup.quarter, // Sort by year*10 + quarter (e.g., 20241, 20242, etc.) keyForList: key, }; diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index c8690c37bda4..39ccc75726fc 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -3600,6 +3600,7 @@ describe('SearchUIUtils', () => { total: 250, groupedBy: CONST.SEARCH.GROUP_BY.MONTH, formattedMonth: 'January 2026', + shortFormattedMonth: 'Jan ’26', sortKey: 202601, transactions: [], transactionsQueryJSON: undefined, @@ -3613,6 +3614,7 @@ describe('SearchUIUtils', () => { total: 75, groupedBy: CONST.SEARCH.GROUP_BY.MONTH, formattedMonth: 'December 2025', + shortFormattedMonth: 'Dec ’25', sortKey: 202512, transactions: [], transactionsQueryJSON: undefined, @@ -3713,6 +3715,7 @@ describe('SearchUIUtils', () => { total: 250, groupedBy: CONST.SEARCH.GROUP_BY.MONTH, formattedMonth: 'January 2026', + shortFormattedMonth: 'Jan ’26', sortKey: 202601, transactions: [], transactionsQueryJSON: undefined, @@ -3730,6 +3733,7 @@ describe('SearchUIUtils', () => { total: 250, groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Jan 25 - Jan 31, 2026', + shortFormattedWeek: 'Jan 25 - 31 ’26', transactions: [], transactionsQueryJSON: undefined, keyForList: '2026-01-25-01-25', @@ -4190,6 +4194,7 @@ describe('SearchUIUtils', () => { total: 250, groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, formattedQuarter: 'Q1 2026 (Jan 1 - Mar 31)', + shortFormattedQuarter: 'Q1 ’26', sortKey: 20261, transactions: [], transactionsQueryJSON: undefined, @@ -4203,6 +4208,7 @@ describe('SearchUIUtils', () => { total: 75, groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, formattedQuarter: 'Q4 2025 (Oct 1 - Dec 31)', + shortFormattedQuarter: 'Q4 ’25', sortKey: 20254, transactions: [], transactionsQueryJSON: undefined, @@ -4303,6 +4309,7 @@ describe('SearchUIUtils', () => { total: 250, groupedBy: CONST.SEARCH.GROUP_BY.QUARTER, formattedQuarter: 'Q1 2026 (Jan 1 - Mar 31)', + shortFormattedQuarter: 'Q1 ’26', sortKey: 20261, transactions: [], transactionsQueryJSON: undefined, @@ -4320,6 +4327,7 @@ describe('SearchUIUtils', () => { total: 250, groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Jan 25 - Jan 31, 2026', + shortFormattedWeek: 'Jan 25 - 31 ’26', transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2026-01-25', @@ -4331,6 +4339,7 @@ describe('SearchUIUtils', () => { total: 75, groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Dec 21 - Dec 27, 2025', + shortFormattedWeek: 'Dec 21 - 27 ’25', transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2025-12-21', diff --git a/tests/unit/Search/useInsightDataTest.ts b/tests/unit/Search/useInsightDataTest.ts index b3e098b85c0e..88bc92406eef 100644 --- a/tests/unit/Search/useInsightDataTest.ts +++ b/tests/unit/Search/useInsightDataTest.ts @@ -51,6 +51,7 @@ const makeData = (count: number): GroupedItem[] => total: 0, currency: CONST.CURRENCY.USD, formattedMonth: `Month ${i + 1}`, + shortFormattedMonth: `M${i + 1}`, sortKey: i, })); From 15791c1950c7d85439ca260b84b8a62f55f8eb5e Mon Sep 17 00:00:00 2001 From: "truph01 (via MelvinBot)" Date: Tue, 18 Aug 2026 15:09:01 +0000 Subject: [PATCH 233/466] Docs: document bulk Download receipts in Search for reports and expenses Co-authored-by: truph01 --- .../How-to-Export-Expenses.md | 26 ++++++++++++++++--- .../How-to-Export-Reports.md | 18 ++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md index 59d28cf6bef1..699e29e2fa5d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md @@ -1,8 +1,8 @@ --- title: How to Export Expenses -description: Learn how to export expenses from the Expenses page to CSV using default or custom export templates in New Expensify. -keywords: [New Expensify, export expenses, CSV export, download expenses, expense export template, bulk export expenses, expense data] -internalScope: Audience is all members. Covers exporting expenses from the Expenses page to CSV using export templates, both individually and in bulk. Does not cover exporting reports to CSV or PDF (see Export-Reports.md), exporting to an accounting integration, or company card reconciliation. +description: Learn how to export expenses from the Expenses page to CSV using default or custom export templates, and how to download their receipts, in New Expensify. +keywords: [New Expensify, export expenses, CSV export, download expenses, download receipts, expense receipts, receipt zip, expense export template, bulk export expenses, expense data] +internalScope: Audience is all members. Covers exporting expenses from the Expenses page to CSV using export templates (both individually and in bulk) and downloading the receipts on selected expenses. Does not cover exporting reports to CSV or PDF (see Export-Reports.md), exporting to an accounting integration, or company card reconciliation. --- # How to export expenses @@ -34,6 +34,18 @@ You'll receive the exported CSV file in a message from Concierge. You'll receive the exported CSV file in a message from Concierge. +## How to download the receipts on selected expenses + +You can download the receipts attached to your selected expenses in a single ZIP file. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend** > **Expenses**. +2. Select the checkbox next to each expense whose receipt you want to download, or use the top checkbox to select all. +3. Select **Selected** at the top. +4. Select **Download receipts**. +5. Wait for the files to finish preparing. The zip file downloads automatically when it's ready. If it doesn't, select **Download file** to save it to your device. + +**Note:** **Download receipts** only appears when at least one selected expense has a receipt. Deleted expenses are not included in the download. + ## How to export the columns currently displayed using Export current view Use **Export current view** to download a CSV that matches the columns currently shown on the **Expenses** page, instead of a fixed export template. @@ -69,6 +81,14 @@ Yes. Select **Export current view** to download a CSV that matches the columns c If all selected expenses are deleted, only the **Basic export** template is available. Other export templates require a report, and deleted expenses are not associated with any report. +## Why don't I see Download receipts for my selected expenses? + +**Download receipts** only appears when at least one selected expense has a receipt attached. If none of the selected expenses have receipts, the option is hidden. + +## Are deleted expenses included when I download receipts? + +No. Deleted expenses are excluded from the download, even if they had a receipt. Only the receipts on your live selected expenses are included in the zip file. + ## The data looks wrong in Excel. How can I fix it? Long IDs may appear in scientific notation. To fix this: diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md index 334e404ef2b6..e9070b6ffdbb 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md @@ -1,8 +1,8 @@ --- title: How to Export Reports description: Learn how to export reports to CSV, download them as PDFs, or download their receipts. -keywords: [New Expensify, export reports, CSV export, PDF download, download report, download receipts, report receipts, receipt zip, report export template, bulk export reports, bulk PDF download, report data] -internalScope: Audience is all members. Covers exporting reports to CSV using export templates (individually and in bulk), downloading reports as PDF (individually and in bulk), and downloading the receipts on a report. Does not cover exporting individual expenses from the Expenses page (see Export-Expenses.md), exporting to an accounting integration, or company card reconciliation. +keywords: [New Expensify, export reports, CSV export, PDF download, download report, download receipts, download receipts for multiple reports, report receipts, receipt zip, report export template, bulk export reports, bulk PDF download, report data] +internalScope: Audience is all members. Covers exporting reports to CSV using export templates (individually and in bulk), downloading reports as PDF (individually and in bulk), and downloading the receipts on a report (individually and in bulk). Does not cover exporting individual expenses from the Expenses page (see Export-Expenses.md), exporting to an accounting integration, or company card reconciliation. --- # How to export reports @@ -92,6 +92,18 @@ You can download every receipt attached to a report in a single ZIP file. **Note:** **Download receipts** only appears when at least one expense on the report has a receipt. +## How to download the receipts on multiple reports + +You can download the receipts on several reports at once in a single ZIP file. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend > Reports**. +2. Select the checkbox next to each report whose receipts you want to download, or use the top checkbox to select all. +3. Select **Selected** at the top. +4. Select **Download receipts**. +5. Wait for the files to finish preparing. The zip file downloads automatically when it's ready. If it doesn't, select **Download file** to save it to your device. + +**Note:** **Download receipts** only appears when at least one selected report has an expense with a receipt. + --- # FAQ @@ -106,7 +118,7 @@ When you download multiple reports as PDFs, a message tells you how many of the ## Why don't I see Download receipts on a report? -**Download receipts** only appears in the **More** menu when at least one expense on the report has a receipt attached. If none of the expenses have receipts, the option is hidden. +**Download receipts** only appears when at least one expense has a receipt attached. In a single report's **More** menu, it appears when that report has an expense with a receipt. When you select multiple reports and open **Selected**, it appears only if at least one selected report has an expense with a receipt. If none of the expenses have receipts, the option is hidden. ## What happens if some receipts fail to download? From 14cfd208921710b6ccb6f242999e369329274791 Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:08:14 +0700 Subject: [PATCH 234/466] Hide TU workspace when changing workspaces --- src/libs/ReportUtils.ts | 2 +- src/libs/isTeachersUnitePolicyID.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5d674b84849c..8edfa50e7cc5 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13284,7 +13284,7 @@ function doesReportContainRequestsFromMultipleUsers(iouReport: OnyxEntry * Determines whether the report can be moved to the workspace. */ function isWorkspaceEligibleForReportChange(submitterEmail: string | undefined, newPolicy: OnyxEntry, report?: Report): boolean { - if (!submitterEmail || !newPolicy || !isGroupPolicyPolicyUtils(newPolicy)) { + if (!submitterEmail || !newPolicy || !isGroupPolicyPolicyUtils(newPolicy) || isTeachersUnitePolicyID(newPolicy.id)) { return false; } if (report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && !isPolicyAdminPolicyUtils(newPolicy)) { diff --git a/src/libs/isTeachersUnitePolicyID.ts b/src/libs/isTeachersUnitePolicyID.ts index 425f2c7630cd..56857fdcf994 100644 --- a/src/libs/isTeachersUnitePolicyID.ts +++ b/src/libs/isTeachersUnitePolicyID.ts @@ -9,10 +9,6 @@ getEnvironment().then((env) => { environment = env; }); -/** - * The Teachers Unite workspace doesn't allow submitting expenses for reimbursement (CONST.IOU.TYPE.SUBMIT), - * only tracking or splitting them. - */ export default function isTeachersUnitePolicyID(policyID: string | undefined): boolean { const teacherUnitePolicyID = environment === CONST.ENVIRONMENT.PRODUCTION ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; return policyID === teacherUnitePolicyID; From 4cceaf3d7d15e0132861b090d26df1483b18f8db Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 19 Aug 2026 10:28:08 +0200 Subject: [PATCH 235/466] Use the standalone month format for chart labels --- src/libs/DateUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 2b5557532b9b..e831b61cd5fa 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -1122,14 +1122,14 @@ function isDateStringInMonth(dateString: string, year: number, month: number): b * Returns a month label, e.g. "September 2025". */ function getFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string { - return format(new Date(year, month - 1, 1), 'MMMM yyyy', {locale: dateFnsLocale}); + return format(new Date(year, month - 1, 1), 'LLLL yyyy', {locale: dateFnsLocale}); } /** * Returns a compact month label, e.g. "Sep ’25". */ function getShortFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string { - return format(new Date(year, month - 1, 1), 'MMM ’yy', {locale: dateFnsLocale}); + return format(new Date(year, month - 1, 1), 'LLL ’yy', {locale: dateFnsLocale}); } /** @@ -1194,7 +1194,7 @@ function getFormattedQuarterForSearch(year: number, quarter: number, dateFnsLoca * Returns a compact quarter label, e.g. "Q3 ’25". */ function getShortFormattedQuarterForSearch(year: number, quarter: number): string { - return `Q${quarter} ’${String(year).slice(-2)}`; + return `Q${quarter} ${format(new Date(year, 0, 1), '’yy')}`; } function getNextNthOfMonth(nth: number) { From 947648521dab27efd181f4f6bb273428a359f24d Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 19 Aug 2026 10:32:26 +0200 Subject: [PATCH 236/466] Fix typescript in date list item tests --- tests/ui/MonthListItemHeaderTest.tsx | 1 + tests/ui/WeekListItemHeaderTest.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/ui/MonthListItemHeaderTest.tsx b/tests/ui/MonthListItemHeaderTest.tsx index 6cd25d8d73c4..922d2ba79f46 100644 --- a/tests/ui/MonthListItemHeaderTest.tsx +++ b/tests/ui/MonthListItemHeaderTest.tsx @@ -70,6 +70,7 @@ const createMonthListItem = (year: number, month: number, options: Partial = {}): TransactionWeekGroupListItemType => ({ week, formattedWeek: options.formattedWeek ?? 'Jan 25 - Jan 31, 2026', + shortFormattedWeek: options.shortFormattedWeek ?? 'Jan 25 - 31 ’26', count: options.count ?? 5, currency: options.currency ?? 'USD', total: options.total ?? 250, From cee06abd76aeac47c5403e213353f25bc323b36b Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:34:46 +0700 Subject: [PATCH 237/466] update filter policies --- src/selectors/Policy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index dfdeab799c23..38ca4574438e 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -1,5 +1,6 @@ import {hasSynchronizationErrorMessage, isConnectionUnverified} from '@libs/actions/connections'; import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy'; +import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; import { getActiveAdminWorkspaces, getActivePoliciesWithExpenseChat, @@ -255,7 +256,7 @@ const createFilteredPoliciesInfoSelector = let filteredPoliciesCount = 0; let firstPolicyID: string | undefined; for (const policy of Object.values(policies ?? {})) { - if (!policy || !shouldShowPolicy(policy, false, email)) { + if (!policy || !shouldShowPolicy(policy, false, email) || isTeachersUnitePolicyID(policy.id)) { continue; } if (filteredPoliciesCount === 0) { From 7fae321ebc211e306cb43212fc3ec7e711b31243 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 19 Aug 2026 11:32:14 +0200 Subject: [PATCH 238/466] Add comma to the short formatted week --- src/components/Search/chartGroupByConfig.ts | 2 +- src/libs/DateUtils.ts | 16 +++++----------- tests/ui/WeekListItemHeaderTest.tsx | 2 +- tests/unit/Search/SearchUIUtilsTest.ts | 6 +++--- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/components/Search/chartGroupByConfig.ts b/src/components/Search/chartGroupByConfig.ts index 59e3f39f364f..ffec7aa79997 100644 --- a/src/components/Search/chartGroupByConfig.ts +++ b/src/components/Search/chartGroupByConfig.ts @@ -20,7 +20,7 @@ type ChartGroupByConfig = { /** Name of the icon rendered next to the chart title */ titleIconName: 'Users' | 'CreditCard' | 'Send' | 'Folder' | 'Basket' | 'Tag' | 'Calendar'; - /** Returns the full label for a group (e.g. "Amazon", "November 2025") */ + /** Returns the full label for a group */ getLabel: (item: GroupedItem) => string; /** Returns the compact label for chart axes, or undefined to fall back to `getLabel` */ diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index e831b61cd5fa..70c5d95bae30 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -1118,16 +1118,12 @@ function isDateStringInMonth(dateString: string, year: number, month: number): b return datePart >= monthStart && datePart <= monthEnd; } -/** - * Returns a month label, e.g. "September 2025". - */ +/** Returns a month label, e.g. "September 2025". */ function getFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string { return format(new Date(year, month - 1, 1), 'LLLL yyyy', {locale: dateFnsLocale}); } -/** - * Returns a compact month label, e.g. "Sep ’25". - */ +/** Returns a compact month label, e.g. "Sep ’25". */ function getShortFormattedMonthForSearch(year: number, month: number, dateFnsLocale: DateFnsLocale | undefined): string { return format(new Date(year, month - 1, 1), 'LLL ’yy', {locale: dateFnsLocale}); } @@ -1147,16 +1143,14 @@ function getFormattedDateRangeForSearch(startDate: string, endDate: string, date return `${format(start, 'MMM d', {locale: dateFnsLocale})} - ${format(end, 'MMM d, yyyy', {locale: dateFnsLocale})}`; } -/** - * Returns a compact date range, e.g. "Sep 1 - 7 ’25". - */ +/** Returns a compact date range, e.g. "Sep 1 - 7, ’25". */ function getShortFormattedDateRangeForSearch(startDate: string, endDate: string, dateFnsLocale: DateFnsLocale | undefined): string { const start = parse(startDate, 'yyyy-MM-dd', new Date()); const end = parse(endDate, 'yyyy-MM-dd', new Date()); if (!isSameYear(start, end)) { - return `${format(start, 'MMM d ’yy', {locale: dateFnsLocale})} - ${format(end, 'MMM d ’yy', {locale: dateFnsLocale})}`; + return `${format(start, 'MMM d, ’yy', {locale: dateFnsLocale})} - ${format(end, 'MMM d, ’yy', {locale: dateFnsLocale})}`; } - const formattedEnd = isSameMonth(start, end) ? format(end, 'd ’yy', {locale: dateFnsLocale}) : format(end, 'MMM d ’yy', {locale: dateFnsLocale}); + const formattedEnd = isSameMonth(start, end) ? format(end, 'd, ’yy', {locale: dateFnsLocale}) : format(end, 'MMM d, ’yy', {locale: dateFnsLocale}); return `${format(start, 'MMM d', {locale: dateFnsLocale})} - ${formattedEnd}`; } diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx index 36230fbd703c..23f78427d678 100644 --- a/tests/ui/WeekListItemHeaderTest.tsx +++ b/tests/ui/WeekListItemHeaderTest.tsx @@ -68,7 +68,7 @@ const mockSearchActionsContext = { const createWeekListItem = (week: string, options: Partial = {}): TransactionWeekGroupListItemType => ({ week, formattedWeek: options.formattedWeek ?? 'Jan 25 - Jan 31, 2026', - shortFormattedWeek: options.shortFormattedWeek ?? 'Jan 25 - 31 ’26', + shortFormattedWeek: options.shortFormattedWeek ?? 'Jan 25 - 31, ’26', count: options.count ?? 5, currency: options.currency ?? 'USD', total: options.total ?? 250, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index a87c1c47bd25..07f691bd8cfe 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -3733,7 +3733,7 @@ describe('SearchUIUtils', () => { total: 250, groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Jan 25 - Jan 31, 2026', - shortFormattedWeek: 'Jan 25 - 31 ’26', + shortFormattedWeek: 'Jan 25 - 31, ’26', transactions: [], transactionsQueryJSON: undefined, keyForList: '2026-01-25-01-25', @@ -4327,7 +4327,7 @@ describe('SearchUIUtils', () => { total: 250, groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Jan 25 - Jan 31, 2026', - shortFormattedWeek: 'Jan 25 - 31 ’26', + shortFormattedWeek: 'Jan 25 - 31, ’26', transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2026-01-25', @@ -4339,7 +4339,7 @@ describe('SearchUIUtils', () => { total: 75, groupedBy: CONST.SEARCH.GROUP_BY.WEEK, formattedWeek: 'Dec 21 - Dec 27, 2025', - shortFormattedWeek: 'Dec 21 - 27 ’25', + shortFormattedWeek: 'Dec 21 - 27, ’25', transactions: [], transactionsQueryJSON: undefined, keyForList: 'group_2025-12-21', From ef8dbb1fd56190293341d4b2bb36f9d62ec1b777 Mon Sep 17 00:00:00 2001 From: Ted Harris Date: Wed, 19 Aug 2026 15:38:09 +0100 Subject: [PATCH 239/466] Enhance Configuring Booking Restrictions documentation Added a section on using booking restrictions for phased rollouts or pilots, including steps for implementation. --- .../Configuring-Booking-Restrictions.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md index ddffe6fa8c86..91415140f763 100644 --- a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md +++ b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md @@ -1,7 +1,7 @@ --- title: Configuring Booking Restrictions description: Learn how travel admins can restrict travel bookings in Expensify by user, country, or legal entity. -keywords: [booking restrictions, restrict travel, block countries, user restrictions, legal entity, expensify travel, classic, new expensify] +keywords: [booking restrictions, restrict travel, block countries, user restrictions, legal entity, expensify travel, classic, new expensify, pilot, rollout] --- @@ -72,6 +72,17 @@ Restrictions will block travelers from booking specific types of travel. If a re --- +## Use booking restrictions for a phased rollout or pilot + +Booking restrictions let you test Expensify Travel with a small group before rolling it out to the whole company: + +1. Enable Expensify Travel on your workspace. +2. Create a user restriction that includes everyone **except** your test group. For larger companies, restrict by legal entity rather than adding users one by one. +3. Add a reason, such as *"Expensify Travel is in a limited test. Please continue using your current travel booking process."* Restricted members see this message if they attempt to book. +4. When testing is complete, delete the restriction. All members can book immediately. + +--- + # FAQ ## Can I apply restrictions to just one travel type? From 7b050a654b63560e45a2d6bae615ea2c9d84eb46 Mon Sep 17 00:00:00 2001 From: "Ted Harris (via MelvinBot)" Date: Wed, 19 Aug 2026 16:27:52 +0000 Subject: [PATCH 240/466] Address review comments: terminology, enablement prerequisite, legal entity caveat, delete control Co-authored-by: Ted Harris --- .../company-setup/Configuring-Booking-Restrictions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md index 91415140f763..05122441c811 100644 --- a/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md +++ b/docs/articles/travel/company-setup/Configuring-Booking-Restrictions.md @@ -76,10 +76,10 @@ Restrictions will block travelers from booking specific types of travel. If a re Booking restrictions let you test Expensify Travel with a small group before rolling it out to the whole company: -1. Enable Expensify Travel on your workspace. -2. Create a user restriction that includes everyone **except** your test group. For larger companies, restrict by legal entity rather than adding users one by one. +1. Make sure Expensify Travel is enabled on your Workspace. If it isn't, reach out to your Account Manager or Concierge to get it enabled. +2. Create a user restriction that includes everyone **except** your test group. For larger companies, you can restrict by legal entity instead of adding members one by one. Only do this if your test group belongs to a separate legal entity that isn't part of the restriction. If your pilot members share a legal entity with the rest of the company, select the non-pilot members individually so your test group isn't blocked. 3. Add a reason, such as *"Expensify Travel is in a limited test. Please continue using your current travel booking process."* Restricted members see this message if they attempt to book. -4. When testing is complete, delete the restriction. All members can book immediately. +4. When testing is complete, click **Delete** to remove the restriction. All members can book immediately. --- From 9a5b2c4cdacd8e017629b5f2e61991d3bb546cfd Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Wed, 19 Aug 2026 13:14:52 -0700 Subject: [PATCH 241/466] Address review feedback on non-USD card feed linking Co-Authored-By: Claude Opus 5 --- src/languages/de.ts | 2 +- src/languages/el.ts | 2 +- src/languages/es.ts | 3 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 3 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 3 +- src/languages/pt-BR.ts | 3 +- src/languages/zh-hans.ts | 2 +- ...WorkspaceExpensifyCardFeedSelectorPage.tsx | 14 +++++---- .../WorkspaceExpensifyCardPageEmptyState.tsx | 30 +++++++++++-------- 12 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index d2e2012bcdc4..2e473fdd4aaf 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7235,7 +7235,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, chooseAnExisting: 'Wähle ein bestehendes Bankkonto zum Bezahlen von Ausgaben oder füge ein neues hinzu.', changeBankAccount: 'Bankkonto ändern', updateCurrencyForExpensifyCard: - 'Die Expensify Karte funktioniert mit Bankkonten in USD, GBP und EUR. Aktualisieren Sie diesen Workspace auf eine unterstützte Währung oder verwenden Sie einen anderen Workspace für die Registrierung.', + 'Die Expensify Karte kann in USD, GBP und EUR ausgegeben werden. Aktualisiere diesen Arbeitsbereich auf eine unterstützte Währung oder verwende einen anderen Arbeitsbereich.', updateCurrencyForExpensifyCardTitle: 'Holen Sie sich die Expensify Karte', }, changeOwner: { diff --git a/src/languages/el.ts b/src/languages/el.ts index d46b37c935cd..e47a9defed7d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7494,7 +7494,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ 'Ο χώρος εργασίας σας είναι ρυθμισμένος σε μη υποστηριζόμενο νόμισμα. Επικοινωνήστε με έναν διαχειριστή χώρου εργασίας που έχει δικαίωμα να το αλλάξει.', changeBankAccount: 'Αλλαγή τραπεζικού λογαριασμού', updateCurrencyForExpensifyCard: - 'Η κάρτα Expensify λειτουργεί με τραπεζικούς λογαριασμούς σε USD, GBP και EUR. Ενημερώστε αυτόν τον χώρο εργασίας σε υποστηριζόμενο νόμισμα ή χρησιμοποιήστε έναν διαφορετικό χώρο εργασίας για να εγγραφείτε.', + 'Η κάρτα Expensify μπορεί να εκδοθεί σε USD, GBP και EUR. Ενημερώστε αυτόν τον χώρο εργασίας σε υποστηριζόμενο νόμισμα ή χρησιμοποιήστε έναν διαφορετικό χώρο εργασίας.', updateCurrencyForExpensifyCardTitle: 'Αποκτήστε την κάρτα Expensify', }, changeOwner: { diff --git a/src/languages/es.ts b/src/languages/es.ts index eeedfe7ef2cb..da43b598772d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7199,8 +7199,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, yourWorkspace: `Tu espacio de trabajo está configurado en una moneda no soportada. Consulta la lista de monedas soportadas.`, chooseAnExisting: 'Elige una cuenta bancaria existente para pagar gastos o añade una nueva.', changeBankAccount: 'Cambiar cuenta bancaria', - updateCurrencyForExpensifyCard: - 'La Tarjeta Expensify funciona con cuentas bancarias en USD, GBP y EUR. Actualiza este espacio de trabajo a una divisa compatible o usa otro espacio de trabajo para inscribirte.', + updateCurrencyForExpensifyCard: 'La Tarjeta Expensify se puede emitir en USD, GBP y EUR. Actualiza este espacio de trabajo a una moneda soportada o usa otro espacio de trabajo.', updateCurrencyForExpensifyCardTitle: 'Consigue la Tarjeta Expensify', }, changeOwner: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 24bae7c9c934..c6485db04a95 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7258,7 +7258,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, chooseAnExisting: 'Choisissez un compte bancaire existant pour payer les dépenses ou ajoutez-en un nouveau.', changeBankAccount: 'Changer de compte bancaire', updateCurrencyForExpensifyCard: - 'La Carte Expensify fonctionne avec des comptes bancaires en USD, GBP et EUR. Mettez à jour cet espace de travail vers une devise prise en charge ou utilisez un autre espace de travail pour vous inscrire.', + 'La Carte Expensify peut être émise en USD, GBP et EUR. Mettez à jour cet espace de travail vers une devise prise en charge ou utilisez un autre espace de travail.', updateCurrencyForExpensifyCardTitle: 'Obtenez la Carte Expensify', }, changeOwner: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 23d218164fd6..80edbb1a106b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7203,7 +7203,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, chooseAnExisting: 'Scegli un conto bancario esistente per pagare le spese oppure aggiungine uno nuovo.', changeBankAccount: 'Cambia conto bancario', updateCurrencyForExpensifyCard: - 'La Carta Expensify funziona con conti bancari in USD, GBP ed EUR. Aggiorna questo spazio di lavoro a una valuta supportata oppure usa un altro spazio di lavoro per iscriverti.', + 'La Carta Expensify può essere emessa in USD, GBP ed EUR. Aggiorna questo spazio di lavoro a una valuta supportata oppure usa un altro spazio di lavoro.', updateCurrencyForExpensifyCardTitle: 'Ottieni la Carta Expensify', }, changeOwner: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 08280a84933d..6db396c33a9a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7116,8 +7116,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から yourWorkspace: `ご利用のワークスペースはサポートされていない通貨に設定されています。サポートされている通貨の一覧を表示します。`, chooseAnExisting: '既存の銀行口座を選択して経費を支払うか、新しい口座を追加してください。', changeBankAccount: '銀行口座を変更', - updateCurrencyForExpensifyCard: - 'Expensify カードは、USD・GBP・EUR の銀行口座でご利用いただけます。このワークスペースを対応通貨に更新するか、別のワークスペースで登録してください。', + updateCurrencyForExpensifyCard: 'Expensify カードは USD、GBP、EUR で発行できます。このワークスペースをサポートされている通貨に更新するか、別のワークスペースをご利用ください。', updateCurrencyForExpensifyCardTitle: 'Expensify カードを取得する', }, changeOwner: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 68b7fc8db667..9982844a6d2d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7187,7 +7187,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, chooseAnExisting: 'Kies een bestaande bankrekening om onkosten te betalen of voeg een nieuwe toe.', changeBankAccount: 'Bankrekening wijzigen', updateCurrencyForExpensifyCard: - 'Expensify Kaart werkt met bankrekeningen in USD, GBP en EUR. Werk deze workspace bij naar een ondersteunde valuta, of gebruik een andere workspace om je aan te melden.', + 'De Expensify Kaart kan worden uitgegeven in USD, GBP en EUR. Werk deze werkruimte bij naar een ondersteunde valuta of gebruik een andere werkruimte.', updateCurrencyForExpensifyCardTitle: 'Ontvang de Expensify Kaart', }, changeOwner: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index fa0d6cf45195..e9c5d041490d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7167,8 +7167,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, yourWorkspace: `Twoje miejsce pracy jest ustawione na nieobsługiwaną walutę. Zobacz listę obsługiwanych walut.`, chooseAnExisting: 'Wybierz istniejące konto bankowe do opłacania wydatków lub dodaj nowe.', changeBankAccount: 'Zmień konto bankowe', - updateCurrencyForExpensifyCard: - 'Karta Expensify działa z kontami bankowymi w USD, GBP i EUR. Zmień walutę tego workspace’a na obsługiwaną albo użyj innego workspace’a, żeby się zarejestrować.', + updateCurrencyForExpensifyCard: 'Kartę Expensify można wydać w USD, GBP i EUR. Zmień walutę tej przestrzeni roboczej na obsługiwaną albo użyj innej przestrzeni roboczej.', updateCurrencyForExpensifyCardTitle: 'Zdobądź Kartę Expensify', }, changeOwner: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 8ecc1692ceae..a58b8163ac38 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7188,8 +7188,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, yourWorkspace: `Seu workspace está configurado para uma moeda não compatível. Veja a lista de moedas compatíveis.`, chooseAnExisting: 'Escolha uma conta bancária existente para pagar despesas ou adicione uma nova.', changeBankAccount: 'Alterar conta bancária', - updateCurrencyForExpensifyCard: - 'O Cartão Expensify funciona com contas bancárias em USD, GBP e EUR. Atualize este workspace para uma moeda compatível ou use outro workspace para se inscrever.', + updateCurrencyForExpensifyCard: 'O Cartão Expensify pode ser emitido em USD, GBP e EUR. Atualize este workspace para uma moeda compatível ou use outro workspace.', updateCurrencyForExpensifyCardTitle: 'Obtenha o Cartão Expensify', }, changeOwner: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index facfda50a427..5bd3d9bf8285 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6944,7 +6944,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM yourWorkspace: `您的工作区当前使用不受支持的货币。请查看支持的货币列表。`, chooseAnExisting: '选择现有银行账户来支付报销,或添加新账户。', changeBankAccount: '更改银行账户', - updateCurrencyForExpensifyCard: 'Expensify 卡支持美元、英镑和欧元银行账户。请将此工作区更新为支持的货币,或使用其他工作区进行注册。', + updateCurrencyForExpensifyCard: 'Expensify 卡可以以美元、英镑和欧元发行。请将此工作区更新为受支持的货币,或使用其他工作区。', updateCurrencyForExpensifyCardTitle: '获取 Expensify 卡', }, changeOwner: { diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx index 38f97baefa86..e383146420d2 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage.tsx @@ -30,7 +30,7 @@ import {isEmailPublicDomain} from '@libs/LoginUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {canMemberWrite} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, canMemberWrite} from '@libs/PolicyUtils'; import {expensifyLoginsSelector} from '@libs/UserUtils'; import Navigation from '@navigation/Navigation'; @@ -85,6 +85,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const policy = usePolicy(policyID); const canWriteExpensifyCard = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD); const canEnrollNewCardProgram = useCanEnrollNewExpensifyCardProgram(policyID); + const canStartBankAccountSetup = canEditWorkspaceSettings(policy, currentUserLogin); const getIssueCardFundID = () => { if (primaryFeeds.length === 0) { @@ -101,6 +102,7 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF }; const issueCardFundID = getIssueCardFundID(); + const hasIssueCardFundID = issueCardFundID !== undefined; const handleAddCardPress = () => { if (issueCardFundID === undefined) { @@ -202,17 +204,17 @@ function WorkspaceExpensifyCardFeedSelectorPage({route}: WorkspaceExpensifyCardF const primaryListData = primaryFeeds.map((entry) => toListItem(entry, false)); - // Suppress the new-program branch on workspaces with unsupported currencies - // These workspaces may only issue cards on existing feeds - const shouldShowIssueCardButton = issueCardFundID !== undefined || canEnrollNewCardProgram; + // Suppress the new-program branch on workspaces with unsupported currencies, and for members who cannot + // reach the bank account setup page. These workspaces may only issue cards on existing feeds + const shouldShowIssueCardButton = hasIssueCardFundID || (canEnrollNewCardProgram && canStartBankAccountSetup); const issueNewCardAndOtherFeedsFooter = canWriteExpensifyCard ? ( {shouldShowIssueCardButton && ( )} diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx index 1a8ca8b3d678..01f90e0687c1 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardPageEmptyState.tsx @@ -15,6 +15,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldBlockCurrencyChange from '@hooks/useShouldBlockCurrencyChange'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -65,19 +66,21 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif const {canWrite: canWriteExpensifyCard, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.EXPENSIFY_CARD); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); - // Dismiss the "Update to USD" modal if the currency changes to USD externally (e.g. from another device) + const isSetupUnfinished = hasInProgressUSDVBBA(reimbursementAccount?.achData); + const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policy?.id); + const canEnrollNewCardProgram = useCanEnrollNewExpensifyCardProgram(policy?.id); + const shouldBlockCurrencyChange = useShouldBlockCurrencyChange(policy?.id); + + // Dismiss the currency modal if the workspace currency becomes a supported one externally (e.g. from another device) const isCurrencyModalOpen = useRef(false); useEffect(() => { - if (policy?.outputCurrency !== CONST.CURRENCY.USD || !isCurrencyModalOpen.current) { + if (!canEnrollNewCardProgram || !isCurrencyModalOpen.current) { return; } closeModal(); isCurrencyModalOpen.current = false; - }, [policy?.outputCurrency, closeModal]); + }, [canEnrollNewCardProgram, closeModal]); - const isSetupUnfinished = hasInProgressUSDVBBA(reimbursementAccount?.achData); - const isUkEuCurrencySupported = useExpensifyCardUkEuSupported(policy?.id); - const canEnrollNewCardProgram = useCanEnrollNewExpensifyCardProgram(policy?.id); const {allFeeds} = useExpensifyCardFeedsForFeedSelector(policy?.id); const hasAccessibleFeeds = allFeeds.length > 0; @@ -124,20 +127,21 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif }, ]; - const promptCurrencyChangeAndStartFlow = async () => { + const promptCurrencyChange = async () => { isCurrencyModalOpen.current = true; + // An open or partially set up bank account blocks the currency page, so only offer the change when it can be completed const result = await showConfirmModal({ title: translate('workspace.bankAccount.updateCurrencyForExpensifyCardTitle'), prompt: translate('workspace.bankAccount.updateCurrencyForExpensifyCard'), - confirmText: translate('workspace.bankAccount.updateWorkspaceCurrency'), - cancelText: translate('common.cancel'), - danger: true, + confirmText: translate(shouldBlockCurrencyChange ? 'common.buttonConfirm' : 'workspace.bankAccount.updateWorkspaceCurrency'), + cancelText: shouldBlockCurrencyChange ? undefined : translate('common.cancel'), + shouldShowCancelButton: !shouldBlockCurrencyChange, }); isCurrencyModalOpen.current = false; - if (result.action !== ModalActions.CONFIRM || !policy) { + if (shouldBlockCurrencyChange || result.action !== ModalActions.CONFIRM || !policy) { return; } - Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policy.id, true)); + Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW_CURRENCY.getRoute(policy.id)); }; return ( @@ -177,7 +181,7 @@ function WorkspaceExpensifyCardPageEmptyState({route, policy}: WorkspaceExpensif // The supported currency restriction only applies to enrolling a brand-new card program. // If hasAccessibleFeeds is true, allow the flow to start in order to link an existing feed if (!hasAccessibleFeeds && !canEnrollNewCardProgram) { - promptCurrencyChangeAndStartFlow(); + promptCurrencyChange(); return; } startFlow(); From de54934b9879e44ce5be5e797a96ee2f48d39562 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 20 Aug 2026 09:27:31 +0700 Subject: [PATCH 242/466] split IBAN and SWIFT/BIC code --- .../substeps/ConfirmationStep.tsx | 15 +++++++-- .../subPages/Confirmation.tsx | 31 ++++++++++++------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/pages/AddPersonalBankAccountPage/substeps/ConfirmationStep.tsx b/src/pages/AddPersonalBankAccountPage/substeps/ConfirmationStep.tsx index 68188d196b6b..e6d13e9a03a9 100644 --- a/src/pages/AddPersonalBankAccountPage/substeps/ConfirmationStep.tsx +++ b/src/pages/AddPersonalBankAccountPage/substeps/ConfirmationStep.tsx @@ -95,9 +95,18 @@ function ConfirmationStep({onNext, onMove, isEditing}: SubPageProps) { bankAccountPersonalDetails?.iban && bankAccountPersonalDetails?.swiftCode ? [ { - id: 'international-bank-account-details', - description: `${translate('bankAccount.iban')} / ${translate('bankAccount.swiftBicCode')}`, - title: `${bankAccountPersonalDetails?.iban} / ${bankAccountPersonalDetails?.swiftCode}`, + id: 'iban', + description: translate('bankAccount.iban'), + title: bankAccountPersonalDetails.iban, + shouldShowRightIcon: true, + onPress: () => { + moveToEditStep(1); + }, + }, + { + id: 'swift-code', + description: translate('bankAccount.swiftBicCode'), + title: bankAccountPersonalDetails.swiftCode, shouldShowRightIcon: true, onPress: () => { moveToEditStep(1); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index dcc1560189ac..1c6a0b419659 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -14,7 +14,6 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getInternationalBankAccountDetailsValues, hasValidInternationalBankAccountDetails} from '@libs/BankAccountUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; @@ -126,17 +125,27 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp }); } - if (hasValidInternationalBankAccountDetails(formValues.iban, formValues.swiftCode, formValues.accountNumber, formValues.swiftBicCode)) { - const {iban, swiftCode} = getInternationalBankAccountDetailsValues(formValues.iban, formValues.swiftCode, formValues.accountNumber, formValues.swiftBicCode); - summaryItems.push({ - id: CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS, - description: `${translate('bankAccount.iban')} / ${translate('bankAccount.swiftBicCode')}`, - title: `${iban} / ${swiftCode}`, - shouldShowRightIcon: true, - onPress: () => { - onMove(STEP_INDEXES.INTERNATIONAL_BANK_ACCOUNT_DETAILS); + if (formValues.iban && formValues.swiftCode) { + summaryItems.push( + { + id: `${CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS}-iban`, + description: translate('bankAccount.iban'), + title: formValues.iban, + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.INTERNATIONAL_BANK_ACCOUNT_DETAILS); + }, }, - }); + { + id: `${CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS}-swiftCode`, + description: translate('bankAccount.swiftBicCode'), + title: formValues.swiftCode, + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.INTERNATIONAL_BANK_ACCOUNT_DETAILS); + }, + }, + ); } for (const [fieldName, field] of Object.entries(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE] ?? {})) { From c488a9ceac636cb3c6f7efb689d147bc8c856afe Mon Sep 17 00:00:00 2001 From: "Hans Vu (via MelvinBot)" Date: Thu, 20 Aug 2026 04:36:17 +0000 Subject: [PATCH 243/466] Docs: document View user history profile shortcut for E/A#98091 Co-authored-by: Hans Vu --- .../Use-Search-Operators-to-Filter-and-Analyze.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md index 51866f76568b..088c49c43b41 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md +++ b/docs/articles/new-expensify/reports-and-expenses/Use-Search-Operators-to-Filter-and-Analyze.md @@ -231,3 +231,10 @@ If the search operator isn’t recognized, the system will ignore it and return ## Do I need to use quotes for everything? Only use quotes for values that include spaces or exact phrases, like `description:"client lunch"` or `in:"#general"`. + +## How do I find all messages from a specific person? + +You have two options: + +- Enter `type:chat from:` followed by the person's email, phone, or display name in the search bar (for example, `type:chat from:alice@acme.com`). +- Open the person's profile and select **View user history** (or **View agent history** for an agent). This opens the **Search** page filtered to that person's messages. From 18f4f487753adf56d464d790172123c19d35484c Mon Sep 17 00:00:00 2001 From: "Stephanie Elliott (via MelvinBot)" Date: Thu, 20 Aug 2026 05:56:33 +0000 Subject: [PATCH 244/466] Update help docs for Release 2: Auto-updating government rates Co-authored-by: Stephanie Elliott --- .../workspaces/Set-distance-rates.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index a4910aeeb21b..e5e53be8fad6 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -67,6 +67,31 @@ To prevent members from using a Distance rate, you can either disable it or dele --- +## How to enable Auto-update government rate + +Control workspaces can have Expensify keep the government mileage rate current automatically. When enabled, Expensify adds a new effective-dated rate whenever the government publishes an updated rate, so you don't have to update it manually each year. + +Auto-update is available for workspaces using USD, CAD, GBP, AUD, or NZD. It is turned on by default for new workspaces and turned off by default for existing workspaces. + +1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace name]**. +2. Click **Distance rates**. +3. Turn on **Auto-update government rate**. + +--- + +## What happens when a government rate updates + +When Auto-update government rate is enabled and a new government rate is published: + +- Expensify adds a new rate with a **Start date** matching the date the new rate takes effect. +- Previous rates are preserved so historical expenses keep the rate that applied on their expense date. +- If the previous government rate had no **End date**, Expensify sets its **End date** to the day before the new rate starts. +- Admins are notified of the change in the workspace **#admins** room and in **Home > Announcements**. + +You keep full control of your rates — you can edit or disable any auto-added rate at any time. + +--- + # FAQ ## Can I create multiple Distance rates? @@ -97,6 +122,10 @@ No. Updating a Distance rate only affects future Distance expenses. Existing exp Yes. When **Distance Rates** is enabled, the Workspace must always have at least one active Distance rate. +## Why isn't Auto-update government rate turned on for my workspace? + +Auto-update government rate is turned on by default only for new workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for workspaces using USD, CAD, GBP, AUD, or NZD. + --- ## How Expensify Selects a Rate When Multiple Rates Exist From d81653b75a8a19b9ea6b783172c56285c9aee7ad Mon Sep 17 00:00:00 2001 From: "Nyoman Jyotisa (via MelvinBot)" Date: Thu, 20 Aug 2026 06:32:23 +0000 Subject: [PATCH 245/466] Always show all company card CSV import fields and add downloadable template - Remove the 'Use advanced fields (not recommended)' toggle so all mapping fields are always available after uploading a CSV - Add a 'Download our template' button on the file-upload step - Rename mapping labels to match the Spend page: Original amount -> Purchase amount, Original currency -> Purchase currency, Comment -> Description - Remove the now-unused useAdvancedFields flag plumbing Co-authored-by: Nyoman Jyotisa --- src/languages/de.ts | 8 +-- src/languages/el.ts | 8 +-- src/languages/en.ts | 8 +-- src/languages/es.ts | 8 +-- src/languages/fr.ts | 8 +-- src/languages/it.ts | 8 +-- src/languages/ja.ts | 8 +-- src/languages/nl.ts | 8 +-- src/languages/pl.ts | 8 +-- src/languages/pt-BR.ts | 8 +-- src/languages/zh-hans.ts | 8 +-- .../WorkspaceCompanyCardsSettingsPage.tsx | 12 ----- .../addNew/CompanyCardsImportedPage.tsx | 50 +++++++------------ .../addNew/ImportFromFileStep.tsx | 46 +++++++++++------ src/types/onyx/CardFeeds.ts | 3 -- 15 files changed, 92 insertions(+), 107 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index c1bd8cea6dfa..afbfc6124aed 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5806,7 +5806,7 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU createFileFeedHelpText: `Bitte folge dieser Hilfsanleitung, um die Ausgaben deiner Firmenkarte zu importieren!`, companyCardLayoutName: 'Name des Firmenkarten-Layouts', cardLayoutNameRequired: 'Der Name des Firmenkarten-Layouts ist erforderlich', - useAdvancedFields: 'Erweiterte Felder verwenden (nicht empfohlen)', + downloadTemplate: 'Vorlage herunterladen', cardProviders: { gl1025: 'American Express Corporate Cards', cdf: 'Mastercard Firmenkarten', @@ -5896,9 +5896,9 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU currency: 'Währung', ignore: 'Ignorieren', originalTransactionDate: 'Ursprüngliches Transaktionsdatum', - originalAmount: 'Ursprünglicher Betrag', - originalCurrency: 'Ursprüngliche Währung', - comment: 'Kommentar', + originalAmount: 'Kaufbetrag', + originalCurrency: 'Kaufwährung', + comment: 'Beschreibung', category: 'Kategorie', tag: 'Tag', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index b2e70b991e20..fd03a3694cda 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -5924,7 +5924,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ createFileFeedHelpText: `Ακολουθήστε αυτόν τον οδηγό βοήθειας για να εισαχθούν οι δαπάνες των εταιρικών καρτών σας!`, companyCardLayoutName: 'Όνομα διάταξης εταιρικής κάρτας', cardLayoutNameRequired: 'Απαιτείται όνομα διάταξης εταιρικής κάρτας', - useAdvancedFields: 'Χρησιμοποιήστε προηγμένα πεδία (δεν συνιστάται)', + downloadTemplate: 'Κατεβάστε το πρότυπό μας', cardProviders: { gl1025: 'Εταιρικές κάρτες American Express', cdf: 'Επαγγελματικές κάρτες Mastercard', @@ -6016,9 +6016,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ currency: 'Νόμισμα', ignore: 'Αγνοήστε', originalTransactionDate: 'Αρχική ημερομηνία συναλλαγής', - originalAmount: 'Αρχικό ποσό', - originalCurrency: 'Αρχικό νόμισμα', - comment: 'Σχόλιο', + originalAmount: 'Ποσό αγοράς', + originalCurrency: 'Νόμισμα αγοράς', + comment: 'Περιγραφή', category: 'Κατηγορία', tag: 'Ετικέτα', cardName: 'Όνομα κάρτας', diff --git a/src/languages/en.ts b/src/languages/en.ts index 9ce81da43e8b..edb6129c210b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5905,7 +5905,7 @@ const translations = { createFileFeedHelpText: `Please follow this help guide to get your company card expenses imported!`, companyCardLayoutName: 'Company card layout name', cardLayoutNameRequired: 'The Company card layout name is required', - useAdvancedFields: 'Use advanced fields (not recommended)', + downloadTemplate: 'Download our template', cardProviders: { gl1025: 'American Express Corporate Cards', cdf: 'Mastercard Commercial Cards', @@ -5980,9 +5980,9 @@ const translations = { currency: 'Currency', ignore: 'Ignore', originalTransactionDate: 'Original transaction date', - originalAmount: 'Original amount', - originalCurrency: 'Original currency', - comment: 'Comment', + originalAmount: 'Purchase amount', + originalCurrency: 'Purchase currency', + comment: 'Description', category: 'Category', tag: 'Tag', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 693a7c7015cc..e11688c640b5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5694,7 +5694,7 @@ ${amount} para ${merchant} - ${date}`, createFileFeedHelpText: `Siga este guía de ayuda para importar las transacciones de su tarjeta de empresa!`, companyCardLayoutName: 'Nombre del layout de la tarjeta de empresa', cardLayoutNameRequired: 'El nombre del layout de la tarjeta de empresa es requerido', - useAdvancedFields: 'Usar campos avanzados (no recomendado)', + downloadTemplate: 'Descarga nuestra plantilla', cardProviders: { gl1025: 'Tarjetas de empresa American Express', cdf: 'Tarjetas comerciales Mastercard', @@ -5766,9 +5766,9 @@ ${amount} para ${merchant} - ${date}`, currency: 'Moneda', ignore: 'Ignorar', originalTransactionDate: 'Fecha original de la transacción', - originalAmount: 'Importe original', - originalCurrency: 'Moneda original', - comment: 'Comentario', + originalAmount: 'Importe de la compra', + originalCurrency: 'Moneda de la compra', + comment: 'Descripción', category: 'Categoría', tag: 'Etiqueta', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0aefec5e1eeb..5ea4e3c65649 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5816,7 +5816,7 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. createFileFeedHelpText: `Veuillez suivre ce guide d’aide pour importer les dépenses de votre carte d’entreprise !`, companyCardLayoutName: 'Nom de la disposition de carte entreprise', cardLayoutNameRequired: 'Le nom du layout de la carte entreprise est requis', - useAdvancedFields: 'Utiliser les champs avancés (non recommandé)', + downloadTemplate: 'Télécharger notre modèle', cardProviders: { gl1025: 'Cartes American Express Corporate', cdf: 'Cartes commerciales Mastercard', @@ -5906,9 +5906,9 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. currency: 'Devise', ignore: 'Ignorer', originalTransactionDate: 'Date de transaction d’origine', - originalAmount: 'Montant d’origine', - originalCurrency: 'Devise d’origine', - comment: 'Commentaire', + originalAmount: 'Montant de l’achat', + originalCurrency: 'Devise de l’achat', + comment: 'Description', category: 'Catégorie', tag: 'Étiquette', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index f8a7f6379a7a..6503653ee264 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5775,7 +5775,7 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. createFileFeedHelpText: `Segui questa guida di supporto per importare le spese della carta aziendale!`, companyCardLayoutName: 'Nome layout carta aziendale', cardLayoutNameRequired: 'Il nome del layout della carta aziendale è obbligatorio', - useAdvancedFields: 'Usa i campi avanzati (non consigliato)', + downloadTemplate: 'Scarica il nostro modello', cardProviders: { gl1025: 'Carte Corporate American Express', cdf: 'Carte Commerciali Mastercard', @@ -5864,9 +5864,9 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. currency: 'Valuta', ignore: 'Ignora', originalTransactionDate: 'Data originale della transazione', - originalAmount: 'Importo originale', - originalCurrency: 'Valuta originale', - comment: 'Commento', + originalAmount: 'Importo dell’acquisto', + originalCurrency: 'Valuta dell’acquisto', + comment: 'Descrizione', category: 'Categoria', tag: 'Tag', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 976481906bc2..280749ae9e62 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5712,7 +5712,7 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO createFileFeedHelpText: `会社カードの経費をインポートするには、このヘルプガイドに従ってください。`, companyCardLayoutName: '法人カードレイアウト名', cardLayoutNameRequired: '法人カードレイアウト名は必須です', - useAdvancedFields: '詳細フィールドを使用(非推奨)', + downloadTemplate: 'テンプレートをダウンロード', cardProviders: { gl1025: 'American Express コーポレートカード', cdf: 'Mastercard コマーシャルカード', @@ -5799,9 +5799,9 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO currency: '通貨', ignore: '無視', originalTransactionDate: '元の取引日', - originalAmount: '元の金額', - originalCurrency: '元の通貨', - comment: 'コメント', + originalAmount: '購入金額', + originalCurrency: '購入通貨', + comment: '説明', category: 'カテゴリ', tag: 'タグ', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b016bea91f90..caa894d5512a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5766,7 +5766,7 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ createFileFeedHelpText: `Volg deze hulpgids om de uitgaven op je bedrijfskaarten te importeren!`, companyCardLayoutName: 'Naam van lay-out van bedrijfskaart', cardLayoutNameRequired: 'De naam van de lay-out van de bedrijfskaart is verplicht', - useAdvancedFields: 'Geavanceerde velden gebruiken (niet aanbevolen)', + downloadTemplate: 'Download ons sjabloon', cardProviders: { gl1025: 'American Express Corporate Cards', cdf: 'Mastercard Zakelijke Kaarten', @@ -5854,9 +5854,9 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ currency: 'Valuta', ignore: 'Negeren', originalTransactionDate: 'Oorspronkelijke transactiedatum', - originalAmount: 'Oorspronkelijk bedrag', - originalCurrency: 'Oorspronkelijke valuta', - comment: 'Opmerking', + originalAmount: 'Aankoopbedrag', + originalCurrency: 'Aankoopvaluta', + comment: 'Omschrijving', category: 'Categorie', tag: 'Label', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 5de5d5591e32..47e0a7ef9713 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5750,7 +5750,7 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy createFileFeedHelpText: `Postępuj zgodnie z tym przewodnikiem pomocy, aby zaimportować wydatki z kart firmowych!`, companyCardLayoutName: 'Nazwa układu karty firmowej', cardLayoutNameRequired: 'Nazwa układu karty firmowej jest wymagana', - useAdvancedFields: 'Użyj pól zaawansowanych (niezalecane)', + downloadTemplate: 'Pobierz nasz szablon', cardProviders: { gl1025: 'Karty korporacyjne American Express', cdf: 'Firmowe karty Mastercard', @@ -5838,9 +5838,9 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy currency: 'Waluta', ignore: 'Ignoruj', originalTransactionDate: 'Data pierwotnej transakcji', - originalAmount: 'Kwota pierwotna', - originalCurrency: 'Pierwotna waluta', - comment: 'Komentarz', + originalAmount: 'Kwota zakupu', + originalCurrency: 'Waluta zakupu', + comment: 'Opis', category: 'Kategoria', tag: 'Tag', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 40f5a92b1b82..2189db00fcbf 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5767,7 +5767,7 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS createFileFeedHelpText: `Siga este guia de ajuda para importar as despesas do cartão corporativo!`, companyCardLayoutName: 'Nome do layout do cartão corporativo', cardLayoutNameRequired: 'O nome do layout do cartão corporativo é obrigatório', - useAdvancedFields: 'Usar campos avançados (não recomendado)', + downloadTemplate: 'Baixe nosso modelo', cardProviders: { gl1025: 'Cartões Corporativos American Express', cdf: 'Cartões Comerciais Mastercard', @@ -5855,9 +5855,9 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS currency: 'Moeda', ignore: 'Ignorar', originalTransactionDate: 'Data original da transação', - originalAmount: 'Valor original', - originalCurrency: 'Moeda original', - comment: 'Comentário', + originalAmount: 'Valor da compra', + originalCurrency: 'Moeda da compra', + comment: 'Descrição', category: 'Categoria', tag: 'Etiqueta', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 87868905f11f..21f9ce930297 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5578,7 +5578,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM createFileFeedHelpText: `请按照此帮助指南操作,将您的公司卡费用导入!`, companyCardLayoutName: '公司卡片布局名称', cardLayoutNameRequired: '公司卡片布局名称是必填的', - useAdvancedFields: '使用高级字段(不推荐)', + downloadTemplate: '下载我们的模板', cardProviders: { gl1025: 'American Express 公司卡', cdf: '万事达商业卡', @@ -5665,9 +5665,9 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM currency: '货币', ignore: '忽略', originalTransactionDate: '原始交易日期', - originalAmount: '原始金额', - originalCurrency: '原始货币', - comment: '评论', + originalAmount: '购买金额', + originalCurrency: '购买货币', + comment: '描述', category: '类别', tag: '标签', }, diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx index f2c2dccb7f5c..9415c5463f2f 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx @@ -39,15 +39,6 @@ import {isUserValidatedSelector} from '@selectors/Account'; import React, {useMemo} from 'react'; import {View} from 'react-native'; -const ADVANCED_CSV_COLUMNS = new Set([ - CONST.CSV_IMPORT_COLUMNS.ORIGINAL_TRANSACTION_DATE, - CONST.CSV_IMPORT_COLUMNS.ORIGINAL_AMOUNT, - CONST.CSV_IMPORT_COLUMNS.ORIGINAL_CURRENCY, - CONST.CSV_IMPORT_COLUMNS.COMMENT, - CONST.CSV_IMPORT_COLUMNS.CATEGORY, - CONST.CSV_IMPORT_COLUMNS.TAG, -]); - type WorkspaceCompanyCardsSettingsPageProps = PlatformStackScreenProps; function WorkspaceCompanyCardsSettingsPage({ @@ -81,8 +72,6 @@ function WorkspaceCompanyCardsSettingsPage({ const isPending = !!selectedFeedData?.pending; const isDirectFeedType = isDirectFeed(feed); const isCsvFeed = isCSVUploadFeed(feed); - const storedMappings = selectedFeedData?.uploadLayoutSettings?.columnMappings; - const hadAdvancedFields = !!storedMappings && Object.keys(storedMappings).some((col) => ADVANCED_CSV_COLUMNS.has(col)); const statementCloseDate = useMemo(() => { if (!selectedFeedData?.statementPeriodEndDay) { @@ -205,7 +194,6 @@ function WorkspaceCompanyCardsSettingsPage({ data: { layoutType: feed, companyCardLayoutName: selectedFeedData?.customFeedName ?? feedName ?? '', - useAdvancedFields: hadAdvancedFields, existingInstanceID: selectedFeedData?.uploadLayoutSettings?.instanceID ?? null, domainAccountID: domainOrWorkspaceAccountID, }, diff --git a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx index 524f0a22069a..5a2027740602 100644 --- a/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx +++ b/src/pages/workspace/companyCards/addNew/CompanyCardsImportedPage.tsx @@ -48,7 +48,6 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const [isImportingTransactions, setIsImportingTransactions] = useState(false); const {setIsClosing} = useCloseImportPage(); const showImportSpreadsheetConfirmModal = useImportSpreadsheetConfirmModal(); - const shouldUseAdvancedFields = addNewCard?.data?.useAdvancedFields ?? false; const layoutName = addNewCard?.data?.companyCardLayoutName ?? ''; const prefilledLayoutType = addNewCard?.data?.layoutType; const generatedLayoutType = useMemo( @@ -59,44 +58,31 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); - const columnRoles: ColumnRole[] = (() => { - const baseRoles: ColumnRole[] = [ - {text: translate('workspace.companyCards.addNewCard.csvColumns.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.cardNumber'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.cardName'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NAME}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.postedDate'), value: CONST.CSV_IMPORT_COLUMNS.POSTED_DATE, isRequired: true}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.merchant'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT, isRequired: true}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.amount'), value: CONST.CSV_IMPORT_COLUMNS.AMOUNT, isRequired: true}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.currency'), value: CONST.CSV_IMPORT_COLUMNS.CURRENCY, isRequired: true}, - ]; - - if (!shouldUseAdvancedFields) { - return baseRoles; - } - - const advancedRoles: ColumnRole[] = [ - {text: translate('workspace.companyCards.addNewCard.csvColumns.originalTransactionDate'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_TRANSACTION_DATE}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.originalAmount'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_AMOUNT}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.originalCurrency'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_CURRENCY}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.comment'), value: CONST.CSV_IMPORT_COLUMNS.COMMENT}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.category'), value: CONST.CSV_IMPORT_COLUMNS.CATEGORY}, - {text: translate('workspace.companyCards.addNewCard.csvColumns.tag'), value: CONST.CSV_IMPORT_COLUMNS.TAG}, - ]; - - return [...baseRoles, ...advancedRoles]; - })(); + const columnRoles: ColumnRole[] = [ + {text: translate('workspace.companyCards.addNewCard.csvColumns.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.cardNumber'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NUMBER}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.cardName'), value: CONST.CSV_IMPORT_COLUMNS.CARD_NAME}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.postedDate'), value: CONST.CSV_IMPORT_COLUMNS.POSTED_DATE, isRequired: true}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.merchant'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT, isRequired: true}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.amount'), value: CONST.CSV_IMPORT_COLUMNS.AMOUNT, isRequired: true}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.currency'), value: CONST.CSV_IMPORT_COLUMNS.CURRENCY, isRequired: true}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.originalTransactionDate'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_TRANSACTION_DATE}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.originalAmount'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_AMOUNT}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.originalCurrency'), value: CONST.CSV_IMPORT_COLUMNS.ORIGINAL_CURRENCY}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.comment'), value: CONST.CSV_IMPORT_COLUMNS.COMMENT}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.category'), value: CONST.CSV_IMPORT_COLUMNS.CATEGORY}, + {text: translate('workspace.companyCards.addNewCard.csvColumns.tag'), value: CONST.CSV_IMPORT_COLUMNS.TAG}, + ]; const savedColumnMappings = Object.entries(workspaceCardFeeds?.settings?.companyCards ?? {}).find(([feedKey]) => feedKey === layoutType)?.[1]?.uploadLayoutSettings?.columnMappings; const hasAppliedSavedMappings = useRef(false); const lastProcessedDataRef = useRef(spreadsheet?.data); - const lastAdvancedFieldsRef = useRef(shouldUseAdvancedFields); useEffect(() => { - // Reset the flag when new spreadsheet data is loaded, or when the set of selectable roles changes. - if (spreadsheet?.data !== lastProcessedDataRef.current || shouldUseAdvancedFields !== lastAdvancedFieldsRef.current) { + // Reset the flag when new spreadsheet data is loaded. + if (spreadsheet?.data !== lastProcessedDataRef.current) { hasAppliedSavedMappings.current = false; lastProcessedDataRef.current = spreadsheet?.data; - lastAdvancedFieldsRef.current = shouldUseAdvancedFields; } if (hasAppliedSavedMappings.current) { @@ -113,7 +99,7 @@ function CompanyCardsImportedPage({route}: CompanyCardsImportedPageProps) { savedColumnMappings, columnRoles.map((role) => role.value), ); - }, [spreadsheet?.data, savedColumnMappings, columnRoles, shouldUseAdvancedFields]); + }, [spreadsheet?.data, savedColumnMappings, columnRoles]); const requiredColumns = columnRoles.filter((role) => role.isRequired); const {containsHeader = true} = spreadsheet ?? {}; diff --git a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx index 98acb1f7284c..604ec2d22580 100644 --- a/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx +++ b/src/pages/workspace/companyCards/addNew/ImportFromFileStep.tsx @@ -4,14 +4,15 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import Switch from '@components/Switch'; -import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import localFileDownload from '@libs/localFileDownload'; + import Navigation from '@navigation/Navigation'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -27,13 +28,28 @@ import {useRoute} from '@react-navigation/native'; import React, {useState} from 'react'; import {View} from 'react-native'; +// cspell:disable +// Example CSV shared with customers so they can see how to structure a company card import file. +// Sourced from the Expensify Classic "Manage Company Cards" help article. +const CSV_TEMPLATE_FILE_NAME = 'Expensify_company_card_import_template.csv'; +const CSV_TEMPLATE_CONTENT = [ + 'Card Number,Posted Date,Merchant,Posted Amount,Posted Currency,Sales Date,Original Amount,Original Currency,Comment,Category,Tag,Unique ID', + '902212XXXXXX1234,2019-01-26,ACE Hardware,13.41,EUR,2019-01-23,12,USD,Impromptu supply run,Other,Client Project 4,982000000000000', + '902212XXXXXX1234,2019-01-24,McDonalds,4.48,EUR,2019-01-23,5,USD,Lunch at worksite,Meals,Client Project 4,375000000000000', + '902212XXXXXX8999,2019-01-27,United Airlines,"1,200.00",USD,2019-01-24,"1,200.00",USD,Flight to client site,Travel,Client Project 8,135000000000000', + '902212XXXXXX8999,2019-01-28,United Airlines,"-1,200.00",USD,2019-01-25,"-1,200.00",USD,Flight refund,Travel,Client Project 8,735000000000000', + '902212XXXXXX8999,2019-01-28,Sheraton NYC,357,USD,2019-01-24,357,USD,Overnight stay due to flight cancellation,Lodging,Client Project 8,529000000000000', + '902212XXXXXX8999,2019-01-29,Shell Oil,54,USD,2019-01-25,100,USD,Gas,Fuel,Client Project 8,765400000000000', +].join('\n'); +// cspell:enable + function ImportFromFileStep() { const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); + const icons = useMemoizedLazyExpensifyIcons(['Download']); const route = useRoute>(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); - const shouldUseAdvancedFields = addNewCard?.data?.useAdvancedFields ?? false; const companyCardLayoutName = addNewCard?.data?.companyCardLayoutName ?? ''; const [hasError, setHasError] = useState(false); const {policyID} = route.params; @@ -42,8 +58,8 @@ function ImportFromFileStep() { setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE}); }; - const toggleAdvancedFields = (value: boolean) => { - setAddNewCompanyCardStepAndData({data: {useAdvancedFields: value}}); + const downloadTemplate = () => { + localFileDownload(CSV_TEMPLATE_FILE_NAME, CSV_TEMPLATE_CONTENT, translate); }; const navigateToImport = () => { @@ -84,17 +100,15 @@ function ImportFromFileStep() { brickRoadIndicator={shouldShowLayoutNameError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={shouldShowLayoutNameError ? translate('workspace.companyCards.addNewCard.cardLayoutNameRequired') : undefined} /> - - - {translate('workspace.companyCards.addNewCard.useAdvancedFields')} - - - - + + ))} {timeFillerItem} diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index 01aeab028da2..85e7280a94d7 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -24,12 +24,12 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); - const pickUpDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); - const dropOffDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); + const pickUpDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.start.date)); + const dropOffDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.end.date)); let cancellationText = reservation.cancellationPolicy; if (reservation.cancellationDeadline) { - cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline, dateFnsLocale)}`; + cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(translate, dateFnsLocale, reservation.cancellationDeadline)}`; } if (reservation.cancellationPolicy === null && reservation.cancellationDeadline === null) { diff --git a/src/pages/Travel/FlightTripDetails.tsx b/src/pages/Travel/FlightTripDetails.tsx index aae10ad279f9..42485b9a8d86 100644 --- a/src/pages/Travel/FlightTripDetails.tsx +++ b/src/pages/Travel/FlightTripDetails.tsx @@ -42,8 +42,8 @@ function FlightTripDetails({reservation, prevReservation, personalDetails}: Flig FIRST: translate('travel.flightDetails.cabinClasses.first'), }; - const startDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); - const endDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); + const startDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.start.date)); + const endDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.end.date)); const prevFlightEndDate = prevReservation?.end.date; const layover = prevFlightEndDate && DateUtils.getFormattedDurationBetweenDates(translate, new Date(prevFlightEndDate), new Date(reservation.start.date)); diff --git a/src/pages/Travel/HotelTripDetails.tsx b/src/pages/Travel/HotelTripDetails.tsx index ed5b87801127..ec618c5e29e2 100644 --- a/src/pages/Travel/HotelTripDetails.tsx +++ b/src/pages/Travel/HotelTripDetails.tsx @@ -33,10 +33,10 @@ function HotelTripDetails({reservation, personalDetails}: HotelTripDetailsProps) [CONST.CANCELLATION_POLICY.PARTIALLY_REFUNDABLE]: translate('travel.hotelDetails.cancellationPolicies.partiallyRefundable'), }; - const checkInDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); - const checkOutDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); + const checkInDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.start.date)); + const checkOutDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.end.date)); const cancellationText = reservation.cancellationDeadline - ? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline, dateFnsLocale)}` + ? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(translate, dateFnsLocale, reservation.cancellationDeadline)}` : cancellationMapping[reservation.cancellationPolicy ?? CONST.CANCELLATION_POLICY.UNKNOWN]; const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; diff --git a/src/pages/Travel/TrainTripDetails.tsx b/src/pages/Travel/TrainTripDetails.tsx index 09a60aef5716..9a9d1edcf324 100644 --- a/src/pages/Travel/TrainTripDetails.tsx +++ b/src/pages/Travel/TrainTripDetails.tsx @@ -26,8 +26,8 @@ function TrainTripDetails({reservation, personalDetails}: TrainTripDetailsProps) const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); - const startDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); - const endDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); + const startDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.start.date)); + const endDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.end.date)); const trainRouteDescription = `${formatTransitLocationLabel(reservation.start)} ${translate('common.conjunctionTo')} ${formatTransitLocationLabel(reservation.end)}`; const trainDuration = DateUtils.getFormattedDurationBetweenDates(translate, new Date(reservation.start.date), new Date(reservation.end.date)); diff --git a/src/pages/inbox/report/ParticipantLocalTime.tsx b/src/pages/inbox/report/ParticipantLocalTime.tsx index 38900b3ee60d..c68f6a409e0a 100644 --- a/src/pages/inbox/report/ParticipantLocalTime.tsx +++ b/src/pages/inbox/report/ParticipantLocalTime.tsx @@ -18,7 +18,12 @@ type ParticipantLocalTimeProps = { participant: PersonalDetails; }; -function getParticipantLocalTime(participant: PersonalDetails, getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime'], dateFnsLocale: LocaleContextProps['dateFnsLocale']) { +function getParticipantLocalTime( + participant: PersonalDetails, + translate: LocaleContextProps['translate'], + getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime'], + dateFnsLocale: LocaleContextProps['dateFnsLocale'], +) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null const reportRecipientTimezone = participant.timezone || CONST.DEFAULT_TIME_ZONE; const reportTimezone = getLocalDateFromDatetime(undefined, reportRecipientTimezone.selected); @@ -26,26 +31,26 @@ function getParticipantLocalTime(participant: PersonalDetails, getLocalDateFromD const reportRecipientDay = DateUtils.formatToDayOfWeek(reportTimezone, dateFnsLocale); const currentUserDay = DateUtils.formatToDayOfWeek(currentTimezone, dateFnsLocale); if (reportRecipientDay !== currentUserDay) { - return `${DateUtils.formatToLocalTime(reportTimezone, dateFnsLocale)} ${reportRecipientDay}`; + return `${DateUtils.formatToLocalTime(translate, reportTimezone)} ${reportRecipientDay}`; } - return `${DateUtils.formatToLocalTime(reportTimezone, dateFnsLocale)}`; + return `${DateUtils.formatToLocalTime(translate, reportTimezone)}`; } function ParticipantLocalTime({participant}: ParticipantLocalTimeProps) { const {translate, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); const styles = useThemeStyles(); - const [localTime, setLocalTime] = useState(() => getParticipantLocalTime(participant, getLocalDateFromDatetime, dateFnsLocale)); + const [localTime, setLocalTime] = useState(() => getParticipantLocalTime(participant, translate, getLocalDateFromDatetime, dateFnsLocale)); useEffect(() => { const timer = Timers.register( setInterval(() => { - setLocalTime(getParticipantLocalTime(participant, getLocalDateFromDatetime, dateFnsLocale)); + setLocalTime(getParticipantLocalTime(participant, translate, getLocalDateFromDatetime, dateFnsLocale)); }, 1000), ); return () => { clearInterval(timer); }; - }, [participant, getLocalDateFromDatetime, dateFnsLocale]); + }, [participant, translate, getLocalDateFromDatetime, dateFnsLocale]); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null const reportRecipientDisplayName = participant.firstName || participant.displayName; diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 106bef45c67d..758495c99ac9 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -81,7 +81,7 @@ describe('DateUtils', () => { expect(weekDay).toBe('Monday'); }); it('formatToLocalTime should return a date in a local format', () => { - const localTime = DateUtils.formatToLocalTime(datetime, undefined); + const localTime = DateUtils.formatToLocalTime(translateLocal, datetime); expect(localTime).toBe('12:00 AM'); }); @@ -684,7 +684,7 @@ describe('DateUtils', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01T00:00:00Z')); // 2026-04-19T15:00:00+07:00 — venue is UTC+7, device timezone is UTC - const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00', undefined); + const result = DateUtils.getFormattedCancellationDate(translateLocal, undefined, '2026-04-19T15:00:00+07:00'); // Should display 3:00 PM in the venue's +07:00 timezone, not converted to device-local time expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, GMT+7'); }); @@ -693,19 +693,19 @@ describe('DateUtils', () => { // Pin "now" to 2026 so the 2026 date is treated as the current year and the year is omitted. jest.useFakeTimers(); jest.setSystemTime(new Date('2026-06-01T00:00:00Z')); - const result = DateUtils.getFormattedCancellationDate('2026-06-15T10:30:00+00:00', undefined); + const result = DateUtils.getFormattedCancellationDate(translateLocal, undefined, '2026-06-15T10:30:00+00:00'); expect(result).toBe('Monday, Jun 15 10:30 AM, UTC'); }); it('should return empty string for falsy input', () => { - expect(DateUtils.getFormattedCancellationDate('', undefined)).toBe(''); + expect(DateUtils.getFormattedCancellationDate(translateLocal, undefined, '')).toBe(''); }); it('should fall back to UTC when no timezone offset is present in the ISO string', () => { // Pin "now" before 2026 so the 2026 date is treated as a non-current year and the year is shown. jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01T00:00:00Z')); - const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00', undefined); + const result = DateUtils.getFormattedCancellationDate(translateLocal, undefined, '2026-04-19T15:00:00'); expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, UTC'); }); }); @@ -761,6 +761,27 @@ describe('DateUtils', () => { it('getTime12HourWithTranslatedPeriod returns an empty string when there is no date', () => { expect(DateUtils.getTime12HourWithTranslatedPeriod(getTranslateFn(CONST.LOCALES.DE), '')).toBe(''); }); + + it('formatDateTimeTo12Hour and the Until label use the same period as the row', () => { + const translateDE = getTranslateFn(CONST.LOCALES.DE); + expect(DateUtils.formatDateTimeTo12Hour(translateDE, '2026-08-04 14:30:00')).toBe('2026-08-04 02:30 PM'); + expect(DateUtils.getLocalizedTimePeriodDescription(translateDE, undefined, '2026-08-04 14:30:00')).toBe('2026-08-04 02:30 PM'); + }); + + it('datetimeToCalendarTime uses the same period as the row', () => { + // Pinned so the date lands outside the current week and the branch under test is stable. + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-25T00:00:00Z')); + expect(DateUtils.datetimeToCalendarTime(CONST.LOCALES.DE, '2026-08-04 14:30:00', timezone)).toBe('Aug. 4, 2026 um 2:30 PM'); + jest.useRealTimers(); + }); + + it('a time in the hour skipped by the local DST change keeps its own hour', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2026, 2, 8, 0, 30)); + expect(DateUtils.getTime12HourWithTranslatedPeriod(getTranslateFn(CONST.LOCALES.DE), '2026-08-04 02:30:00')).toBe('02:30 AM'); + jest.useRealTimers(); + }); }); describe('getTime12HourWithTranslatedPeriod in a locale that translates the period', () => { From 8fb30f35a471c5b9d6887838cf3d322e4cd7cea9 Mon Sep 17 00:00:00 2001 From: Mukher Date: Tue, 25 Aug 2026 11:22:33 +0500 Subject: [PATCH 338/466] fix: only force keyboard reload when entering edit mode on iOS handleEditFocus hardcoded forceKeyboardIfAlreadyFocused to true, so leaving edit mode (Cancel or Save) also went through the forced path. On iOS this tears down and rebuilds an already-visible keyboard via KeyboardController.setFocusTo('current'), causing a one-frame flicker in the report list ~150ms after exit. Only force it while entering edit mode. --- .../report/ReportActionCompose/ComposerWithSuggestions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 7e8841eea50e..c970a824a33c 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -353,7 +353,7 @@ function ComposerWithSuggestions({ const ignoreEditSelectionResetTimeoutRef = useRef | undefined>(undefined); const handleEditFocus = useCallback(() => { - focus(true, undefined, true); + focus(true, undefined, editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING); onFocus(); if (editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING) { From b80d21183af777e69224f9a8a7ca829558acf3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 25 Aug 2026 09:34:18 +0200 Subject: [PATCH 339/466] check delegate before locked account in close action --- src/pages/settings/Security/SecuritySettingsPage.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index 849c065b01a6..e2a0febbfb5b 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -183,6 +183,12 @@ function SecuritySettingsPage() { icon: icons.ClosedSign, sentryLabel: CONST.SENTRY_LABEL.SETTINGS_SECURITY.CLOSE_ACCOUNT, action: async () => { + // Copiloting into an agent is also a delegate session and must fall through to the + // agent-delete flow below, so only plain delegates are blocked here. + if (isActingAsDelegate && !isCopilotingIntoAgent) { + showDelegateNoAccessModal(); + return; + } if (isAccountLocked) { showLockedAccountModal(); return; @@ -230,12 +236,6 @@ function SecuritySettingsPage() { deleteAgent(agentAccountID, agentLogin, allPolicies, false); return; } - // Kept below the agent branch because copiloting into an agent is also a delegate session, - // and that flow needs to reach deleteAgent above. - if (isActingAsDelegate) { - showDelegateNoAccessModal(); - return; - } Navigation.navigate(ROUTES.SETTINGS_CLOSE); }, }); From ec22149dee96a1e6c5429c38709741e2ef444196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 25 Aug 2026 09:34:29 +0200 Subject: [PATCH 340/466] drop stale copilot example from rulebot guard comment --- src/pages/settings/Security/CloseAccountPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/settings/Security/CloseAccountPage.tsx b/src/pages/settings/Security/CloseAccountPage.tsx index 4d6d5b2c58e3..34021a827b28 100644 --- a/src/pages/settings/Security/CloseAccountPage.tsx +++ b/src/pages/settings/Security/CloseAccountPage.tsx @@ -37,7 +37,7 @@ function CloseAccountPage() { const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - // The account being closed can be a workspace's RuleBot agent (e.g. when accessed via copilot). Closing it would leave the workspace's Agent rules without an enforcer, so it stays open until those rules are removed. + // The account being closed can be a workspace's RuleBot agent. Closing it would leave the workspace's Agent rules without an enforcer, so it stays open until those rules are removed. const ruleBotEnforcedPolicy = getRuleBotEnforcedPolicy(session?.accountID, policies); const styles = useThemeStyles(); From 8f977ac4ab122dafcbd0357b75ea6dc73d7f6969 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Tue, 25 Aug 2026 10:29:20 +0200 Subject: [PATCH 341/466] remove ManualSendMessage and keep ManualSendMessageVisible --- .../flows/macros/android/send-message.ad | 2 +- .../agent-device/flows/macros/send-message.ad | 2 +- .../flows/macros/web/send-message.ad | 2 +- .../agent-device/flows/tests/send-message.ad | 4 +-- contributingGuides/OBSERVABILITY.md | 2 +- contributingGuides/OBSERVABILITY_METRICS.md | 32 ++++++------------- src/CONST/index.ts | 1 - .../useCancelSendMessageSpanOnSkeleton.ts | 2 +- .../telemetry/getSendMessageListWeight.ts | 2 +- .../Search/SearchMoneyRequestReportPage.tsx | 2 +- src/pages/inbox/ReportLifecycleHandler.tsx | 2 +- .../ReportActionCompose/useComposerSubmit.ts | 7 ---- .../report/comment/TextCommentFragment.tsx | 9 ------ .../useCancelSendMessageSpanOnSkeletonTest.ts | 6 ++-- 14 files changed, 22 insertions(+), 53 deletions(-) diff --git a/.claude/skills/agent-device/flows/macros/android/send-message.ad b/.claude/skills/agent-device/flows/macros/android/send-message.ad index 62039cd67e13..bdc93542173c 100644 --- a/.claude/skills/agent-device/flows/macros/android/send-message.ad +++ b/.claude/skills/agent-device/flows/macros/android/send-message.ad @@ -1,5 +1,5 @@ context platform=android -# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessage Sentry span, see flows/tests/send-message.ad. +# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad. # @pre label="Write something..." editable=true # @post label="Write something..." editable=true # @tag chat diff --git a/.claude/skills/agent-device/flows/macros/send-message.ad b/.claude/skills/agent-device/flows/macros/send-message.ad index 516e64a08e1b..98f869fbdc79 100644 --- a/.claude/skills/agent-device/flows/macros/send-message.ad +++ b/.claude/skills/agent-device/flows/macros/send-message.ad @@ -1,4 +1,4 @@ -# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessage Sentry span, see flows/tests/send-message.ad. +# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad. # @pre label="Write something..." editable=true # @post label="Write something..." editable=true # @tag chat diff --git a/.claude/skills/agent-device/flows/macros/web/send-message.ad b/.claude/skills/agent-device/flows/macros/web/send-message.ad index bcf386f0f49c..d34d111b9e7a 100644 --- a/.claude/skills/agent-device/flows/macros/web/send-message.ad +++ b/.claude/skills/agent-device/flows/macros/web/send-message.ad @@ -1,5 +1,5 @@ context platform=web -# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessage Sentry span, see flows/tests/send-message.ad. +# @desc Send a chat message from inside an already-open chat. Reusable helper for setting up state in other flows. Does not navigate or open a chat - assumes the composer is visible. For the QA scenario that exercises the ManualSendMessageVisible Sentry span, see flows/tests/send-message.ad. # @pre role="textbox" label="Write something..." # @post role="textbox" label="Write something..." # @tag chat diff --git a/.claude/skills/agent-device/flows/tests/send-message.ad b/.claude/skills/agent-device/flows/tests/send-message.ad index 841e749b9c29..97c8a8db6100 100644 --- a/.claude/skills/agent-device/flows/tests/send-message.ad +++ b/.claude/skills/agent-device/flows/tests/send-message.ad @@ -1,11 +1,11 @@ -# @desc From the Inbox tab, open a chat matching TARGET_CHAT in the LHN, type a message and send it. Triggers the ManualSendMessage Sentry span (Send press -> optimistic message fragment rendered). Assumes the user is signed in with at least one chat in the Inbox. +# @desc From the Inbox tab, open a chat matching TARGET_CHAT in the LHN, type a message and send it. Triggers the ManualSendMessageVisible Sentry span (Send press -> optimistic message fragment rendered). Assumes the user is signed in with at least one chat in the Inbox. # @pre text="Inbox" # @pre role="button" label="Search" # @post label="Write something..." editable=true # @tag chat # @tag navigation # @tag perf -# @tag sentry-ManualSendMessage +# @tag sentry-ManualSendMessageVisible # @param MESSAGE Message text to send in the opened chat. # @param TARGET_CHAT Visible report/chat title to open from Inbox LHN. diff --git a/contributingGuides/OBSERVABILITY.md b/contributingGuides/OBSERVABILITY.md index 9bcaca413aab..f844d3a26a06 100644 --- a/contributingGuides/OBSERVABILITY.md +++ b/contributingGuides/OBSERVABILITY.md @@ -58,7 +58,7 @@ In development, Sentry is always initialized but does not send data by default. ### Constants Defined in `src/CONST/index.ts` under `CONST.TELEMETRY`: -- Span names: `SPAN_OPEN_REPORT`, `SPAN_SEND_MESSAGE` +- Span names: `SPAN_OPEN_REPORT`, `SPAN_SEND_MESSAGE_VISIBLE` - Tag names: `TAGS.ACTIVE_POLICY`, `TAGS.AUTHENTICATION_ERROR_TYPE` - Attribute names: `ATTRIBUTE_REPORT_ID`, `ATTRIBUTE_MESSAGE_LENGTH` - Configuration: `CONFIG.MEMORY_TRACKING_INTERVAL` diff --git a/contributingGuides/OBSERVABILITY_METRICS.md b/contributingGuides/OBSERVABILITY_METRICS.md index 75aa7aab3860..604bdeebbfa0 100644 --- a/contributingGuides/OBSERVABILITY_METRICS.md +++ b/contributingGuides/OBSERVABILITY_METRICS.md @@ -145,35 +145,21 @@ This document lists all implemented telemetry metrics in the Expensify App. ### Send Message -**Constant**: `CONST.TELEMETRY.SPAN_SEND_MESSAGE` -**Sentry Name**: `ManualSendMessage` -**Threshold**: 100ms (P90) -**What's Measured**: Time from submitting message to the message's React flush. **Caveat**: the end is anchored in a `useEffect`, which fires before the frame is on screen — it does NOT measure visibility. Kept temporarily for comparison against `ManualSendMessageVisible` (below), then to be retired. -**Start**: Message submitted in composer, only when scrolled to bottom ([`src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts)) -**End**: -- User sees: nothing specific — the effect flush is invisible; the message usually appears later -- Technical: Passive effect of the message fragment runs ([`src/pages/inbox/report/comment/TextCommentFragment.tsx`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/comment/TextCommentFragment.tsx)) -**Span ID**: `${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}` (optimistic report action ID) -**Attributes**: `report_id`, `message_length`, `canceled_by_skeleton`, `send_message_source`, `report_action_count`, `money_request_preview_count` -**Cancellation (report-actions skeleton)**: While a report-actions skeleton is on screen, we listen for `ManualSendMessage` spans started for that report and cancel them immediately, tagging `canceled: true` plus `canceled_by_skeleton` with the skeleton that caused it. -- `canceled_by_skeleton` values (`CONST.TELEMETRY.CANCELED_BY_SKELETON`) based on skeleton condition -**Cancellation (report unmount / navigate away)**: If the user leaves the report before their message renders, any pending `ManualSendMessage` span is cancelled via `cancelSpansByPrefix()` to avoid orphaned spans. Cancelled this way the span gets `canceled: true` but **no** `canceled_by_skeleton` (a blanket cancel by span-id prefix, not scoped to one `report_id`). -**Notes**: `send_message_source` = `_` (+ `_rhp` in the RHP, + `_from_report` when drilled in from a report) — slice the metric by send path. -`report_action_count` / `money_request_preview_count` — how many renderable actions the chat's list holds and how many of them are `REPORT_PREVIEW` items — slice the metric by list weight (e.g. chats with many `MoneyRequestReportPreview` items). - -### Send Message (Visible) - **Constant**: `CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE` **Sentry Name**: `ManualSendMessageVisible` -**Threshold**: TBD — will read higher than `ManualSendMessage` by design; recalibrate after the comparison window -**What's Measured**: Time from submitting message to the message actually laid out on screen (`onLayout`), i.e. user-perceived send latency. Runs alongside the legacy `ManualSendMessage` span: same start, same attributes, different end anchor. Once validated in Sentry (~2 weeks), the legacy effect-anchored span gets removed and this becomes the send-message metric. -**Start**: Started back-to-back with `ManualSendMessage` in the composer submit ([`src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts)) +**Threshold**: 300ms (P90) +**What's Measured**: Time from submitting a message to the message actually laid out on screen (`onLayout`), i.e. user-perceived send latency +**Start**: Message submitted in composer, only when scrolled to bottom ([`src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts)) **End**: - User sees: Their message appears in chat - Technical: Message layout complete (`onLayout` event) in [`src/pages/inbox/report/comment/TextCommentFragment.tsx`](https://github.com/Expensify/App/blob/main/src/pages/inbox/report/comment/TextCommentFragment.tsx) **Span ID**: `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${reportActionID}` (optimistic report action ID) -**Attributes**: Same as `ManualSendMessage` (`report_id`, `message_length`, `send_message_source`, `report_action_count`, `money_request_preview_count`, plus cancellation tags) -**Cancellation**: Same paths as `ManualSendMessage` — the skeleton listener matches both ops, and prefix cancels cover it automatically because `ManualSendMessage` is a prefix of `ManualSendMessageVisible`. +**Attributes**: `report_id`, `message_length`, `canceled_by_skeleton`, `send_message_source`, `report_action_count`, `money_request_preview_count` +**Cancellation (report-actions skeleton)**: While a report-actions skeleton is on screen, we listen for `ManualSendMessageVisible` spans started for that report and cancel them immediately, tagging `canceled: true` plus `canceled_by_skeleton` with the skeleton that caused it. +- `canceled_by_skeleton` values (`CONST.TELEMETRY.CANCELED_BY_SKELETON`) based on skeleton condition +**Cancellation (report unmount / navigate away)**: If the user leaves the report before their message renders, any pending `ManualSendMessageVisible` span is cancelled via `cancelSpansByPrefix()` to avoid orphaned spans. Cancelled this way the span gets `canceled: true` but **no** `canceled_by_skeleton` (a blanket cancel by span-id prefix, not scoped to one `report_id`). +**Notes**: `send_message_source` = `_` (+ `_rhp` in the RHP, + `_from_report` when drilled in from a report) — slice the metric by send path. +`report_action_count` / `money_request_preview_count` — how many renderable actions the chat's list holds and how many of them are `REPORT_PREVIEW` items — slice the metric by list weight (e.g. chats with many `MoneyRequestReportPreview` items). ## Failure Rates diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 67c883682c2d..1f5cdf9b77e2 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2248,7 +2248,6 @@ const CONST = { SPAN_EXPENSE_SERVER_RESPONSE: 'ManualCreateExpenseServerResponse', SPAN_RECONNECT_SERVER_RESPONSE: 'ManualReconnectServerResponse', SPAN_GEOLOCATION_WAIT: 'ManualGeolocationWait', - SPAN_SEND_MESSAGE: 'ManualSendMessage', SPAN_SEND_MESSAGE_VISIBLE: 'ManualSendMessageVisible', SPAN_NOT_FOUND_PAGE: 'ManualNotFoundPage', SPAN_ODOMETER_TO_CONFIRMATION: 'ManualOdometerToConfirmation', diff --git a/src/hooks/useCancelSendMessageSpanOnSkeleton.ts b/src/hooks/useCancelSendMessageSpanOnSkeleton.ts index 40b8f948c00e..ea319a33a4a0 100644 --- a/src/hooks/useCancelSendMessageSpanOnSkeleton.ts +++ b/src/hooks/useCancelSendMessageSpanOnSkeleton.ts @@ -28,7 +28,7 @@ function useCancelSendMessageSpanOnSkeleton(reportID: string | undefined, skelet // `client.on` returns an unsubscribe function, used as the effect cleanup. return client.on('spanStart', (span) => { const {op, data} = Sentry.spanToJSON(span); - if ((op !== CONST.TELEMETRY.SPAN_SEND_MESSAGE && op !== CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE) || data[CONST.TELEMETRY.ATTRIBUTE_REPORT_ID] !== reportID) { + if (op !== CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE || data[CONST.TELEMETRY.ATTRIBUTE_REPORT_ID] !== reportID) { return; } // Defer so activeSpans has registered the span (set right after the `startInactiveSpan` that emits this). diff --git a/src/libs/telemetry/getSendMessageListWeight.ts b/src/libs/telemetry/getSendMessageListWeight.ts index 5b212f71a286..825ceb0ea19e 100644 --- a/src/libs/telemetry/getSendMessageListWeight.ts +++ b/src/libs/telemetry/getSendMessageListWeight.ts @@ -11,7 +11,7 @@ type SendMessageListWeight = { }; /** - * Counts the report's actions and preview cards for the `ManualSendMessage` span so we can group send latency + * Counts the report's actions and preview cards for the `ManualSendMessageVisible` span so we can group send latency * in Sentry by how "heavy" the chat is. We filter through `getSortedReportActionsForDisplay` to skip hidden/deleted * actions, and pass `reportID` so actions missing their own `reportID` aren't dropped (same as the report view does). * diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index c5782f30839a..1bf109687fc3 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -230,7 +230,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { return () => { // Cancel any pending send-message spans to prevent orphaned spans when navigating away - cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE); + cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE); }; }, [reportIDFromRoute]); diff --git a/src/pages/inbox/ReportLifecycleHandler.tsx b/src/pages/inbox/ReportLifecycleHandler.tsx index eec951c9e567..87a267c92ab9 100644 --- a/src/pages/inbox/ReportLifecycleHandler.tsx +++ b/src/pages/inbox/ReportLifecycleHandler.tsx @@ -51,7 +51,7 @@ function ReportLifecycleHandler({reportID}: ReportLifecycleHandlerProps) { cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${onyxReportID}`); // Cancel any pending send-message spans to prevent orphaned spans when navigating away - cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE); + cancelSpansByPrefix(CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE); }; }, [onyxReportID]); diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index b3c2c6533a5d..dfcb7ba214fb 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -171,13 +171,6 @@ function useComposerSubmit(reportID: string) { [CONST.TELEMETRY.ATTRIBUTE_REPORT_ACTION_COUNT]: reportActionCount, [CONST.TELEMETRY.ATTRIBUTE_MONEY_REQUEST_PREVIEW_COUNT]: moneyRequestPreviewCount, }; - startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${optimisticReportActionID}`, { - name: 'send-message', - op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, - attributes, - }); - // Dual-emit while validating the new anchor: same start, but ended at the message's onLayout - // (visibility) instead of the passive effect. Remove the effect-anchored span once compared. startSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${optimisticReportActionID}`, { name: 'send-message-visible', op: CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE, diff --git a/src/pages/inbox/report/comment/TextCommentFragment.tsx b/src/pages/inbox/report/comment/TextCommentFragment.tsx index 4cf8c26e8aaf..2ba09e309005 100644 --- a/src/pages/inbox/report/comment/TextCommentFragment.tsx +++ b/src/pages/inbox/report/comment/TextCommentFragment.tsx @@ -25,7 +25,6 @@ import type {StyleProp, TextStyle} from 'react-native'; import {Str} from 'expensify-common'; import isEmpty from 'lodash/isEmpty'; -import {useEffect} from 'react'; import {View} from 'react-native'; import RenderCommentHTML from './RenderCommentHTML'; @@ -71,14 +70,6 @@ function TextCommentFragment({fragment, styleAsDeleted, reportActionID, styleAsM const processedTextArray = splitTextWithEmojis(message); - // Original effect anchor, kept while the visible variant below is validated against it in Sentry. - useEffect(() => { - if (!reportActionID) { - return; - } - endSpan(`${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${reportActionID}`); - }, [reportActionID]); - const endSendMessageVisibleSpanOnLayout = () => { if (!reportActionID) { return; diff --git a/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts b/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts index e33178c9d0db..7d5f99ea95a0 100644 --- a/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts +++ b/tests/unit/useCancelSendMessageSpanOnSkeletonTest.ts @@ -48,10 +48,10 @@ jest.mock('@sentry/react-native', () => { /** Start a send-message span the way the composer does, for a given report. Returns the (typed-as-Sentry) span. */ function sendMessageWhileLoading(reportID: string) { - const spanID = `${CONST.TELEMETRY.SPAN_SEND_MESSAGE}_${Math.random()}`; + const spanID = `${CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE}_${Math.random()}`; const span = startSpan(spanID, { - name: 'send-message', - op: CONST.TELEMETRY.SPAN_SEND_MESSAGE, + name: 'send-message-visible', + op: CONST.TELEMETRY.SPAN_SEND_MESSAGE_VISIBLE, attributes: {[CONST.TELEMETRY.ATTRIBUTE_REPORT_ID]: reportID}, }); return {spanID, span}; From 38c70842746f18afca35ef19ccdfb7a8edf3a47f Mon Sep 17 00:00:00 2001 From: Nyoman Jyotisa Date: Tue, 25 Aug 2026 16:47:42 +0800 Subject: [PATCH 342/466] fix: address final add-existing review feedback --- src/libs/TransactionUtils/index.ts | 2 +- src/pages/AddExistingExpense.tsx | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index bfb7928563c6..8ce8c1747e82 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -3245,7 +3245,7 @@ type GetEligibleTransactionsToAddParams = { }; /** - * Returns the transactions that can be added to the supplied expense or IOU report. + * Returns the transactions that can be added to the given expense or IOU report. */ function getEligibleTransactionsToAdd({ transactions, diff --git a/src/pages/AddExistingExpense.tsx b/src/pages/AddExistingExpense.tsx index be8b4600f113..dd811d341198 100644 --- a/src/pages/AddExistingExpense.tsx +++ b/src/pages/AddExistingExpense.tsx @@ -43,6 +43,9 @@ import type SCREENS from '@src/SCREENS'; import {openExpenseReportIDsSelector} from '@src/selectors/Report'; import {validTransactionDraftIDsSelector} from '@src/selectors/TransactionDraft'; import type Transaction from '@src/types/onyx/Transaction'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; + +import type {OnyxCollection} from 'react-native-onyx'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -80,11 +83,12 @@ function AddExistingExpense({route}: AddExistingExpensePageType) { const [openReportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT, {selector: openExpenseReportIDsSelector}); const isInLandscapeMode = useIsInLandscapeMode(); - const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); - const transactions = useMemo( - () => getEligibleTransactionsToAdd({transactions: allTransactions, report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts}), - [allTransactions, report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts], + const transactionsSelector = useCallback( + (transactions: OnyxCollection) => + getEligibleTransactionsToAdd({transactions, report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts}), + [report, policy, cardList, currentUserAccountID, reportID, allOpenReports, openReportDrafts], ); + const [transactions = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: transactionsSelector}); const fetchMoreUnreportedTransactions = () => { if (!hasMoreUnreportedTransactionsResults || isLoadingUnreportedTransactions) { From 217ae34bf07a1b7a3859f11583fdad605eadbde5 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Tue, 25 Aug 2026 14:49:24 +0500 Subject: [PATCH 343/466] removed extra file --- 98793-proposal-revised.md | 58 --------------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 98793-proposal-revised.md diff --git a/98793-proposal-revised.md b/98793-proposal-revised.md deleted file mode 100644 index c51cd68b6dd3..000000000000 --- a/98793-proposal-revised.md +++ /dev/null @@ -1,58 +0,0 @@ -## Proposal (revised) - -### Please re-state the problem that we are trying to solve in this issue. - -APP-HQP: on web, during first load on `/home`, an unhandled promise rejection is captured whose value is the bare string `No data found for key reportActions_undefined`. Because the rejection value is a string rather than an `Error`, Sentry has nothing to symbolicate, so the producer cannot be identified from the event itself. - -### What is the root cause of that problem? - -**First, the evidence @brunovjk asked for twice — where the string is, and is not, produced.** - -The literal text `No data found for key ` does not exist anywhere in the shipping client: - -| Searched | Hits | -| --- | --- | -| App `main` @ `8785718`, entire repo excluding `node_modules/` and `.git/` | 0 | -| Entire `node_modules/` tree of a clean install | 0 | -| `react-native-onyx` published tarballs, 16 versions sampled from 2.0.50 → 3.0.98 | 0 | -| GitHub code search `org:Expensify "No data found for key"` | 0 code results | - -The obvious candidate is worth ruling out explicitly, because #66878 makes it tempting: **this is not the Onyx `canBeMissing` alert.** That alert read `useOnyx returned no data for key with canBeMissing set to false.` (react-native-onyx 2.0.95), later `…no data for key with canBeMissing set to false for key ${key}` (2.0.110+). Different wording, and it was a `Logger.logAlert` — it never rejected a promise. It is also gone: the identifier `canBeMissing` appears **nowhere** in the runtime of react-native-onyx `3.0.98`, the version App pins. So a `useOnyx({canBeMissing: false})` subscription resolving to `reportActions_undefined` cannot be the producer of this event, and guarding such a subscription would not fix APP-HQP. - -That points to the rejection value originating outside the App bundle. I want to be straight about the consequence: **no App-side change can be *proven* to close APP-HQP until one event carries a stack.** But the path this rejection travels has two real defects that (a) guarantee any such rejection surfaces unhandled, and (b) do serious damage when one occurs. Both are worth fixing on their own merits. - -**Defect 1 — the Pusher apply result is discarded.** This confirms the candidate @MelvinBot flagged above. - -[`User.ts:913`](https://github.com/Expensify/App/blob/878571878b1ca5a22015f9abcdc1cd82382ff3ec/src/libs/actions/User.ts#L913) calls `applyOnyxUpdatesReliably(updates);` and drops the returned `Promise`. Tracing the chain: `applyOnyxUpdatesReliably` → `OnyxUpdates.apply` → [`advanceLastUpdateIDAfterApply`](https://github.com/Expensify/App/blob/878571878b1ca5a22015f9abcdc1cd82382ff3ec/src/libs/actions/OnyxUpdates.ts#L211-L222), whose `.catch()` logs `[OnyxUpdateManagerError]` and then deliberately **rethrows** at `L221`. There is no `.catch()` boundary anywhere after that, so the rejection reaches `window.onunhandledrejection` with its value verbatim — matching the Sentry mechanism `auto.browser.global_handlers.onunhandledrejection` and the bare-string capture. - -**Defect 2 — one rejected batch permanently poisons the Pusher queue.** I don't think this has been raised yet, and it is the more serious of the two. - -[`pusherEventsPromise`](https://github.com/Expensify/App/blob/878571878b1ca5a22015f9abcdc1cd82382ff3ec/src/libs/actions/OnyxUpdates.ts#L51) is a module-level promise that [`applyPusherOnyxUpdates`](https://github.com/Expensify/App/blob/878571878b1ca5a22015f9abcdc1cd82382ff3ec/src/libs/actions/OnyxUpdates.ts#L105-L117) reassigns using `.then()` only — there is no `.catch()` in the chain. Once it rejects, every later batch is chained off a rejected promise, so the `reduce`'s `.then()` callbacks never run and `PusherUtils.triggerMultiEventHandler` is never invoked again. **For the remainder of the session the client applies no Pusher updates at all** — new messages, report updates and status changes stop arriving until the user reloads — and each subsequent event emits another unhandled rejection off the same poisoned chain. [`applyAirshipOnyxUpdates`](https://github.com/Expensify/App/blob/878571878b1ca5a22015f9abcdc1cd82382ff3ec/src/libs/actions/OnyxUpdates.ts#L119-L131) has the identical shape. This failure mode is independent of whatever produced the first rejection. - -### What changes do you think we should make in order to solve the problem? - -**1. `src/libs/actions/OnyxUpdates.ts` — make the serialized queues self-healing.** - -In `applyPusherOnyxUpdates`, keep returning the rejecting promise to the caller so `advanceLastUpdateIDAfterApply` still declines to move the watermark, but reset the module-level `pusherEventsPromise` that sequences the *next* batch back to a resolved promise. Apply the same treatment to `airshipEventsPromise`. This restores correctness regardless of what the producer turns out to be, and it is the change I would prioritise. - -**2. `src/libs/actions/User.ts:913` — handle the promise at the call site.** - -Attach a `.catch()` to `applyOnyxUpdatesReliably(updates)` in the `MULTIPLE_EVENTS` handler. Normalize a non-`Error` rejection into `new Error(String(reason))` preserving the original text, and `Log.alert` it with `lastUpdateID` / `previousUpdateID` and the update event types so the next occurrence carries actionable context. - -Deliberately **not** included: forcing a reconnect here. Recovery already exists by design — [`OnyxUpdates.ts:191-193`](https://github.com/Expensify/App/blob/878571878b1ca5a22015f9abcdc1cd82382ff3ec/src/libs/actions/OnyxUpdates.ts#L191-L193) states that keeping the watermark where it is "lets the next reconnect refetch and reapply the missed updates". Firing a reconnect per failed batch would storm as soon as a batch starts failing repeatedly. - -**3. `src/setup/telemetry/setupSentry.ts` — land @MelvinBot's `beforeSend` normalization too.** - -I originally listed this as an alternative and dismissed it as merely diagnostic. On reflection that was the wrong call: since the producer sits outside the bundle, this is the only change that will actually let us identify it. Steps 1 and 2 make the client resilient; step 3 makes the next occurrence diagnosable. I'd treat them as one PR rather than competing options. - -**Tests** - -- `tests/unit/OnyxUpdatesTest.ts` — reject one Pusher batch, then assert (a) that batch still rejects to its caller, (b) `lastUpdateIDAppliedToClient` is not advanced, and (c) a following valid batch is still applied. (c) fails on `main` today; that is the regression test for Defect 2. -- `tests/unit/PusherSubscribeTest.ts` — assert the `MULTIPLE_EVENTS` callback produces no unhandled rejection when the apply rejects, and that a `Log.alert` carrying the normalized `Error` and update context is emitted. -- A unit test on the `beforeSend` hook fed `hint.originalException = 'No data found for key reportActions_undefined'`, modeled on the APP-5J precedent at `tests/unit/RequestTest.ts:91-102`, asserting the event comes out carrying an `Error` with a stack and the original text preserved. - -### What alternative solutions did you explore? (Optional) - -- **Guarding a `useOnyx` subscription against an undefined report ID** — ruled out by the evidence above: subscriptions don't reject, and the alert that used to fire for them has different wording and no longer exists in `3.0.98`. -- **Catching inside `applyOnyxUpdatesReliably` rather than at the call site** — rejected, because it also serves [`subscribeToPushNotifications.ts:116`](https://github.com/Expensify/App/blob/878571878b1ca5a22015f9abcdc1cd82382ff3ec/src/libs/Notification/PushNotification/subscribeToPushNotifications.ts#L115-L117), which returns the promise to a native handler that should keep seeing failures. -- **`beforeSend` alone** — makes the next event diagnosable but leaves the poisoned Pusher queue in place, which is a live correctness bug today. From 7495dce0456926daabc045ba9cee34b0b8f6720e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 25 Aug 2026 15:43:56 +0530 Subject: [PATCH 344/466] Simplify delegateAccountID assignee-comment test Signed-off-by: krishna2323 --- tests/unit/ReportUtilsTest.ts | 62 +++++++++-------------------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 5f9e632e5b1d..645d74c1140a 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -761,56 +761,24 @@ describe('ReportUtils', () => { expect(result.optimisticAssigneeAddComment).toBeUndefined(); }); - describe('delegateAccountID', () => { - const MODULE_DELEGATE_EMAIL = 'module-delegate@vikings.net'; - const MODULE_DELEGATE_ACCOUNT_ID = 900; - const PASSED_DELEGATE_ACCOUNT_ID = 901; - - const buildAssigneeChatOnyxData = (delegateAccountID: number | undefined) => - getTaskAssigneeChatOnyxData({ - accountID: 1, - assigneeAccountID: 2, - taskReportID: 'taskReportID', - assigneeChatReportID: 'assigneeChatReportID', - parentReportID: 'parentReportID', - title: 'Task title', - assigneeChatReport: createMock>({}), - currentUserEmail: 'email@user.com', - currentUserAccountID: 50, - delegateAccountID, - }); - - beforeEach(async () => { - // The module-level delegate is what `buildOptimisticAddCommentReportAction` falls back to, so it has to - // differ from the passed value for these tests to prove which one wins. - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { - [MODULE_DELEGATE_ACCOUNT_ID]: {accountID: MODULE_DELEGATE_ACCOUNT_ID, login: MODULE_DELEGATE_EMAIL, displayName: 'Module Delegate'}, - }); - await Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess: {delegate: MODULE_DELEGATE_EMAIL}}); - await waitForBatchedUpdates(); - }); + it('sets the passed delegateAccountID on the optimistic assignee comment', () => { + const delegateAccountID = 901; - afterEach(async () => { - await Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess: {delegate: null}}); - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[MODULE_DELEGATE_ACCOUNT_ID]: null}); - await waitForBatchedUpdates(); - }); - - it('uses the passed delegateAccountID instead of the module-level delegate', () => { - const {optimisticAssigneeAddComment} = buildAssigneeChatOnyxData(PASSED_DELEGATE_ACCOUNT_ID); - - const reportAction = optimisticAssigneeAddComment?.reportAction as ReportAction | undefined; - expect(reportAction?.delegateAccountID).toBe(PASSED_DELEGATE_ACCOUNT_ID); + const {optimisticAssigneeAddComment} = getTaskAssigneeChatOnyxData({ + accountID: 1, + assigneeAccountID: 2, + taskReportID: 'taskReportID', + assigneeChatReportID: 'assigneeChatReportID', + parentReportID: 'parentReportID', + title: 'Task title', + assigneeChatReport: createMock>({}), + currentUserEmail: 'email@user.com', + currentUserAccountID: 50, + delegateAccountID, }); - // TODO: the fallback assertion below flips to `toBeUndefined()` once the module-level Onyx.connect is - // removed (https://github.com/Expensify/App/issues/66425). - it('falls back to the module-level delegate when no delegateAccountID is passed', () => { - const {optimisticAssigneeAddComment} = buildAssigneeChatOnyxData(undefined); - - const reportAction = optimisticAssigneeAddComment?.reportAction as ReportAction | undefined; - expect(reportAction?.delegateAccountID).toBe(MODULE_DELEGATE_ACCOUNT_ID); - }); + const reportAction = optimisticAssigneeAddComment?.reportAction as ReportAction | undefined; + expect(reportAction?.delegateAccountID).toBe(delegateAccountID); }); }); From 1404184445011f27ff267f0bd11c7f82b09bb997 Mon Sep 17 00:00:00 2001 From: "Shawn Borton (via MelvinBot)" Date: Tue, 25 Aug 2026 10:26:45 +0000 Subject: [PATCH 345/466] Rename empty-state__expenses.svg to emptystate__searchexpenses.svg per review Co-authored-by: Shawn Borton --- ...empty-state__expenses.svg => emptystate__searchexpenses.svg} | 0 src/components/Icon/chunks/illustrations.chunk.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename assets/images/product-illustrations/{empty-state__expenses.svg => emptystate__searchexpenses.svg} (100%) diff --git a/assets/images/product-illustrations/empty-state__expenses.svg b/assets/images/product-illustrations/emptystate__searchexpenses.svg similarity index 100% rename from assets/images/product-illustrations/empty-state__expenses.svg rename to assets/images/product-illustrations/emptystate__searchexpenses.svg diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index 618df2db00a4..cc7baaef5002 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -62,10 +62,10 @@ import BrokenCompanyCardBankConnection from '@assets/images/product-illustration import BrokenMagnifyingGlass from '@assets/images/product-illustrations/broken-magnifying-glass.svg'; import DjBoothReferenceHands from '@assets/images/product-illustrations/dj-booth-reference-hands.svg'; import EarthWithControls from '@assets/images/product-illustrations/earth-with-controls.svg'; -import EmptyStateSearchExpenses from '@assets/images/product-illustrations/empty-state__expenses.svg'; import EmptyStateExpenses from '@assets/images/product-illustrations/emptystate__expenses.svg'; import HoldExpense from '@assets/images/product-illustrations/emptystate__holdexpense.svg'; import ReceiptFairy from '@assets/images/product-illustrations/emptystate__receiptfairy.svg'; +import EmptyStateSearchExpenses from '@assets/images/product-illustrations/emptystate__searchexpenses.svg'; import EmptyStateTravel from '@assets/images/product-illustrations/emptystate__travel.svg'; import Fireworks from '@assets/images/product-illustrations/fireworks.svg'; import FolderWithPapersAndWatch from '@assets/images/product-illustrations/folder-with-papers-and-watch.svg'; From 336fc3df6130a78244508e72e14cecf6d1752273 Mon Sep 17 00:00:00 2001 From: "Nyoman Jyotisa (via MelvinBot)" Date: Tue, 25 Aug 2026 12:04:13 +0000 Subject: [PATCH 346/466] Type jest.requireActual for TravelBilling mock and drop eslint-disables Co-authored-by: Nyoman Jyotisa --- tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx b/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx index d3ed2e5823b6..aad9fb85e4d6 100644 --- a/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx +++ b/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx @@ -7,6 +7,7 @@ import {ModalActions, ModalProvider} from '@components/Modal/Global/ModalContext import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {updateTravelBillingMonthlyLimit} from '@libs/actions/TravelBilling'; +import type * as TravelBillingActions from '@libs/actions/TravelBilling'; import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import {getTravelBillingCardSettingsKey} from '@libs/TravelBillingUtils'; @@ -39,9 +40,7 @@ jest.mock('@hooks/useDefaultFundID', () => ({ })); jest.mock('@libs/actions/TravelBilling', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actual = jest.requireActual('@libs/actions/TravelBilling'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const actual = jest.requireActual('@libs/actions/TravelBilling'); return { ...actual, updateTravelBillingMonthlyLimit: jest.fn(), From 936e2298b4bad83f49ab56e4fc481bab91bee8ea Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:05:22 +0700 Subject: [PATCH 347/466] update import --- src/pages/iou/DynamicSplitExpenseEditPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index 866352c8172c..dc435d62a23b 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -16,6 +16,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; From 955d1b2c66824ddafc615df4dd01c39e1abedf06 Mon Sep 17 00:00:00 2001 From: "ahmedGaber93 (via MelvinBot)" Date: Tue, 25 Aug 2026 12:39:46 +0000 Subject: [PATCH 348/466] Fix: tear down Onfido SDK on unmount so its modal is removed when RHP closes Co-authored-by: ahmedGaber93 --- src/components/Onfido/BaseOnfidoWeb.tsx | 12 +++++++++--- src/components/Onfido/index.tsx | 10 ---------- src/components/Onfido/types.ts | 3 +-- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/components/Onfido/BaseOnfidoWeb.tsx b/src/components/Onfido/BaseOnfidoWeb.tsx index 4cdb2f3731c3..a806fffbe89a 100644 --- a/src/components/Onfido/BaseOnfidoWeb.tsx +++ b/src/components/Onfido/BaseOnfidoWeb.tsx @@ -29,7 +29,7 @@ type OnfidoEvent = Event & { }; function initializeOnfido({sdkToken, onSuccess, onError, onUserExit, preferredLocale, translate, theme}: InitializeOnfidoProps) { - OnfidoSDK.init({ + return OnfidoSDK.init({ token: sdkToken, containerId: CONST.ONFIDO.CONTAINER_ID, customUI: { @@ -147,7 +147,7 @@ function Onfido({sdkToken, onSuccess, onError, onUserExit, ref}: OnfidoProps) { const theme = useTheme(); useEffect(() => { - initializeOnfido({ + const onfidoOut = initializeOnfido({ sdkToken, onSuccess, onError, @@ -158,7 +158,13 @@ function Onfido({sdkToken, onSuccess, onError, onUserExit, ref}: OnfidoProps) { }); window.addEventListener('userAnalyticsEvent', logOnFidoEvent); - return () => window.removeEventListener('userAnalyticsEvent', logOnFidoEvent); + return () => { + window.removeEventListener('userAnalyticsEvent', logOnFidoEvent); + // Tear down the Onfido SDK so its modal is removed from the DOM when the component unmounts + // (e.g. when the RHP is closed before verification is completed). Otherwise the SDK's modal + // remains orphaned on screen. See https://github.com/Expensify/App/issues/98633 + onfidoOut.tearDown(); + }; // Onfido should be initialized only once on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/Onfido/index.tsx b/src/components/Onfido/index.tsx index 439d9f47fcaa..290f0ca4cfb0 100644 --- a/src/components/Onfido/index.tsx +++ b/src/components/Onfido/index.tsx @@ -8,8 +8,6 @@ function Onfido({sdkToken, onSuccess, onError, onUserExit}: OnfidoProps) { const baseOnfidoRef = useRef(null); useEffect(() => { - const onfidoOut = baseOnfidoRef.current?.onfidoOut; - const observer = new MutationObserver(() => { const fidoRef = baseOnfidoRef.current; /** This condition is needed because we are using external embedded content and they are @@ -31,20 +29,12 @@ function Onfido({sdkToken, onSuccess, onError, onUserExit}: OnfidoProps) { observer.observe(baseOnfidoRef.current, {attributes: false, childList: true, subtree: true}); } - if (!onfidoOut) { - return; - } - - onfidoOut.tearDown(); - // Clean up function to remove the observer when component unmounts return () => { observer.disconnect(); }; }, []); - useEffect(() => {}, []); - return ( ; }; -type OnfidoElement = HTMLDivElement & {onfidoOut?: Handle}; +type OnfidoElement = HTMLDivElement; type OnfidoProps = { /** Token used to initialize the Onfido SDK */ From a0c2e722db33cac9697c917246957532212a5669 Mon Sep 17 00:00:00 2001 From: "Maria D'Costa (via MelvinBot)" Date: Tue, 25 Aug 2026 13:26:33 +0000 Subject: [PATCH 349/466] Fix: order Concierge limit-increase reply after onboarding welcome + tasks Enqueue the onboarding OpenReport before the RequestExpensifyCardLimitIncrease write so Concierge's reply is stamped last, and make navigateToConciergeChat return a promise on every branch so the no-concierge-chat path can sequence the write after creation. Co-authored-by: Maria D'Costa --- src/libs/actions/Report/index.ts | 14 +++-- .../expensifyCard/WorkspaceCardsListLabel.tsx | 53 +++++++++++++++++-- tests/ui/WorkspaceMoreFeaturesPageTest.tsx | 2 +- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index ffecf1fb648c..0b67c798e80b 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -4249,13 +4249,15 @@ function navigateToConciergeChat( reportActionID?: string, // TODO: personalDetails should be a required field in follow-up PRs https://github.com/Expensify/App/issues/73656 personalDetails?: OnyxEntry, -) { +): Promise { // If conciergeReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID. // Otherwise, we would find the concierge chat and navigate to it. + // A resolved promise is returned on every branch so callers can sequence work (e.g. another API.write) to run only + // after the concierge chat has been created/opened and its onboarding OpenReport enqueued. See https://github.com/Expensify/App/issues/99396. if (!conciergeReportID) { // In order to avoid creating concierge repeatedly, // we need to ensure that the server data has been successfully pulled - onServerDataReady().then(() => { + return onServerDataReady().then(() => { // If we don't have a chat with Concierge then create it if (!checkIfCurrentPageActive()) { return; @@ -4273,16 +4275,18 @@ function navigateToConciergeChat( linkToOptions, }); }); - } else if (shouldDismissModal) { + } + if (shouldDismissModal) { const reportParams = {reportID: conciergeReportID, reportActionID}; if (linkToOptions?.afterTransition) { Navigation.dismissModalWithReport(reportParams, undefined, {afterTransition: linkToOptions.afterTransition}); } else { Navigation.dismissModalWithReport(reportParams); } - } else { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID), linkToOptions); + return Promise.resolve(); } + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID), linkToOptions); + return Promise.resolve(); } type BuildNewReportOptimisticDataParams = { diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx index b1055f0bc5d8..b063b6941522 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -20,6 +20,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {getCardSettings} from '@libs/CardUtils'; import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import {isSupportedInviteOnboardingChoice, isSupportedPendingInviteOnboarding} from '@libs/OnboardingUtils'; import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; import Navigation from '@navigation/Navigation'; @@ -29,7 +30,7 @@ import variables from '@styles/variables'; import {queueExpensifyCardForBilling} from '@userActions/Card'; import {requestExpensifyCardLimitIncrease} from '@userActions/Policy/Policy'; -import {navigateToConciergeChat} from '@userActions/Report'; +import {navigateToConciergeChat, openReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -40,7 +41,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {ValueOf} from 'type-fest'; import {useRoute} from '@react-navigation/native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; import {addDays, format} from 'date-fns'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -67,10 +68,23 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr const {translate} = useLocalize(); const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${conciergeReportID}`, {selector: Boolean}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; + const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + + // Mirror ReportFetchHandler's onboarding-pending logic so we can detect when Concierge still has a pending guided + // setup (welcome message + tasks) that OpenReport will create. See https://github.com/Expensify/App/issues/99396. + const isOnboardingCompleted = onboarding?.hasCompletedGuidedSetupFlow ?? false; + const isRegularOnboardingPending = !!introSelected && !introSelected.inviteType && isSupportedInviteOnboardingChoice(introSelected.choice) && !isOnboardingCompleted; + const isPendingInviteOnboarding = isSupportedPendingInviteOnboarding(introSelected); + const isGuidedSetupPending = isRegularOnboardingPending || isPendingInviteOnboarding; const [isVisible, setVisible] = useState(false); const [anchorPosition, setAnchorPosition] = useState({top: 0, left: 0}); const anchorRef = useRef(null); @@ -109,8 +123,39 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr }, [isVisible, windowWidth]); const requestLimitIncrease = () => { - requestExpensifyCardLimitIncrease(settings?.paymentBankAccountID, defaultFundID); setVisible(false); + + // The Concierge onboarding welcome message + tasks are created by OpenReport's guidedSetupData, and the request + // queue is a blocking FIFO, so an OpenReport carrying that onboarding data must be enqueued BEFORE the limit-increase + // write for Concierge's reply to be stamped last. getGuidedSetupData de-dupes, so ReportFetchHandler's later mount + // OpenReport won't duplicate the onboarding. See https://github.com/Expensify/App/issues/99396. + if (isGuidedSetupPending && !conciergeReportID) { + // No Concierge chat exists yet: navigateToConciergeChat creates it and enqueues the onboarding OpenReport on + // its create path. Wait for that promise so the limit-increase write lands after it in the queue. + navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas, false).then(() => { + requestExpensifyCardLimitIncrease(settings?.paymentBankAccountID, defaultFundID); + }); + return; + } + + // Concierge chat already exists but onboarding is still pending. ReportFetchHandler's onboarding OpenReport only + // fires on its mount effect, which runs after this handler, so we enqueue the same OpenReport ourselves first to win + // the ordering. We mirror ReportFetchHandler's isLoadingApp guard because guided-setup tasks need loaded policies to + // resolve their deep links (https://github.com/Expensify/App/issues/71742); when isLoadingApp is true we skip and let + // ReportFetchHandler's deferred OpenReport run instead. Keep this guard as-is. + if (isGuidedSetupPending && !isLoadingApp) { + openReport({ + reportID: conciergeReportID, + introSelected, + conciergeChat, + betas, + hasReportActions, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + }); + } + requestExpensifyCardLimitIncrease(settings?.paymentBankAccountID, defaultFundID); navigateToConciergeChat(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas, false); }; diff --git a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx index 3b60e9bdffb0..281058577a30 100644 --- a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx +++ b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx @@ -124,7 +124,7 @@ const hasAccountingFeatureConnectionMock = jest.mocked(PolicyUtils.hasAccounting const useIsUberConnectedMock = jest.mocked(useIsPolicyConnectedToUberReceiptPartner); const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => undefined); -const navigateToConciergeChatSpy = jest.spyOn(ReportActions, 'navigateToConciergeChat').mockImplementation(() => undefined); +const navigateToConciergeChatSpy = jest.spyOn(ReportActions, 'navigateToConciergeChat').mockImplementation(() => Promise.resolve()); function escapeRegExp(value: string): string { return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); From 59b132344e308dbc2576b0f8e3f487f097e2bafb Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Tue, 25 Aug 2026 13:39:16 +0000 Subject: [PATCH 350/466] Derive payer roles from the WORKFLOWS_PAYMENTS permission instead of a hardcoded role list Co-authored-by: Shridhar Goel --- src/libs/PolicyUtils.ts | 16 +++++++++------- .../members/WorkspaceMemberDetailsPage.tsx | 2 +- .../members/WorkspaceMemberDetailsRolePage.tsx | 2 +- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 12 ++++++------ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index d72582f5fc45..60b00c4871d9 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -698,17 +698,19 @@ function getReimburserEmail(policy: OnyxEntry): string | undefined { } /** - * The roles that are allowed to pay (reimburse) on a workspace. The Authorized Payer (reimburser) must always hold one of - * these, so any role change for a payer is restricted to this set. + * Whether the given role is allowed to pay (reimburse) on a workspace. This is derived from the WORKFLOWS_PAYMENTS feature + * permission rather than a hardcoded role list, so any future role that is granted payment permission (for example upcoming + * customer roles) is automatically treated as a valid payer, matching how the rest of the app determines who can pay. */ -const PAYER_ROLES = [CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN] as const; +function canRolePay(role: string | undefined): boolean { + return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; +} /** - * Whether the given role is allowed to pay (reimburse) on a workspace. + * The roles that are allowed to pay (reimburse) on a workspace, derived from the WORKFLOWS_PAYMENTS permission. The + * Authorized Payer (reimburser) must always hold one of these, so any role change for a payer is restricted to this set. */ -function canRolePay(role: string | undefined): boolean { - return PAYER_ROLES.some((payerRole) => payerRole === role); -} +const PAYER_ROLES = Object.values(CONST.POLICY.ROLE).filter(canRolePay); function isPolicyPayer(policy: OnyxEntry, currentUserLogin: string | undefined): boolean { if (!policy) { diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 64fa9d29db49..caef14be0cae 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -145,7 +145,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - // The Authorized Payer (reimburser) may only hold a role that can pay (Admin or Payments Admin), so they can only be + // The Authorized Payer (reimburser) may only hold a role that can pay (for example Admin or Payments Admin), so they can only be // changed to one of those roles. Keep the Role row interactive for them only when there is another payer role they can // actually move to, for example promoting a non-admin payer to Admin, or switching between Admin and Payments Admin on a // Control workspace. Otherwise, such as an Admin payer on a non-Control workspace where Payments Admin is unavailable, diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index ae14e1f644e1..48c6ebb92d5a 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -40,7 +40,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); - // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to the roles that can pay (Admin or Payments Admin). + // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to the roles that can pay (for example Admin or Payments Admin). const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; const allowedRoles = isReimburser ? [...PAYER_ROLES] : undefined; diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index e9b2445ce71b..c68a129e0f09 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -273,9 +273,9 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); - it('should keep the Role field read-only for an admin Authorized Payer on a non-Control workspace where no other payer role is available', async () => { - // On a Team (non-Control) workspace, Payments Admin is not an assignable role, so an admin payer has no other valid - // payer role to switch to. The Role row must stay read-only instead of opening a page with no actionable option. + it('should keep the Role field interactive for an admin Authorized Payer on a non-Control workspace because Editor also holds the payments permission', async () => { + // On a Team (non-Control) workspace, Payments Admin is not assignable, but Editor also holds the WORKFLOWS_PAYMENTS + // permission, so an admin payer still has another valid payer role to switch to. The Role row must stay interactive. await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { type: CONST.POLICY.TYPE.TEAM, @@ -293,9 +293,9 @@ describe('WorkspaceMemberDetailsPage', () => { const roleItem = await screen.findByTestId('member-role-menu-item'); - // There is no valid role change to make, so the row is disabled and shows no caret. - expect(roleItem).toBeDisabled(); - expect(within(roleItem).queryByTestId('ArrowRight Icon')).not.toBeOnTheScreen(); + // Editor is another payer role the admin payer can switch to, so the row is interactive with no lock hint. + expect(roleItem).not.toBeDisabled(); + expect(screen.queryByText(/Role can/)).not.toBeOnTheScreen(); unmount(); await waitForBatchedUpdatesWithAct(); From d55b2d2d34f57dd2d8865a0604fd0dec907eb8f0 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Tue, 25 Aug 2026 13:45:58 +0000 Subject: [PATCH 351/466] Simplify canRolePay doc comment Co-authored-by: Shridhar Goel --- src/libs/PolicyUtils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 60b00c4871d9..6e8c77156a19 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -698,9 +698,7 @@ function getReimburserEmail(policy: OnyxEntry): string | undefined { } /** - * Whether the given role is allowed to pay (reimburse) on a workspace. This is derived from the WORKFLOWS_PAYMENTS feature - * permission rather than a hardcoded role list, so any future role that is granted payment permission (for example upcoming - * customer roles) is automatically treated as a valid payer, matching how the rest of the app determines who can pay. + * Whether the given role is allowed to pay (reimburse) on a workspace. */ function canRolePay(role: string | undefined): boolean { return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; From 5bd758ab294f1f01bebb44d9f80fb35a1baafb2b Mon Sep 17 00:00:00 2001 From: "Maria D'Costa (via MelvinBot)" Date: Tue, 25 Aug 2026 14:23:21 +0000 Subject: [PATCH 352/466] Drop redundant NVP_ONBOARDING subscription, reuse selector-backed hasCompletedGuidedSetupFlow Co-authored-by: Maria D'Costa --- src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx index b063b6941522..a2a1cd054b41 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -72,7 +72,6 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${conciergeReportID}`, {selector: Boolean}); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [onboarding] = useOnyx(ONYXKEYS.NVP_ONBOARDING); const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; @@ -81,7 +80,7 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr // Mirror ReportFetchHandler's onboarding-pending logic so we can detect when Concierge still has a pending guided // setup (welcome message + tasks) that OpenReport will create. See https://github.com/Expensify/App/issues/99396. - const isOnboardingCompleted = onboarding?.hasCompletedGuidedSetupFlow ?? false; + const isOnboardingCompleted = hasCompletedGuidedSetupFlow ?? false; const isRegularOnboardingPending = !!introSelected && !introSelected.inviteType && isSupportedInviteOnboardingChoice(introSelected.choice) && !isOnboardingCompleted; const isPendingInviteOnboarding = isSupportedPendingInviteOnboarding(introSelected); const isGuidedSetupPending = isRegularOnboardingPending || isPendingInviteOnboarding; From 0de8bc79eb3c712f6d5f0b73772adbf13b18bde2 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Tue, 25 Aug 2026 16:42:25 +0200 Subject: [PATCH 353/466] Rename the patch to the required numbered format PATCHES.md requires ++<3-digit-number>+.patch, which gives future react-native-fs patches an ordering baseline. Also drops the non-template "Fork carrying the fix" bullet from details.md into the Upstream field, and links the now-open E/App issue instead of saying "tracked internally". Co-Authored-By: Claude Opus 5 (1M context) --- patches/react-native-fs/details.md | 7 +++---- ...h => react-native-fs+2.20.0+001+encode-file-uris.patch} | 0 2 files changed, 3 insertions(+), 4 deletions(-) rename patches/react-native-fs/{react-native-fs+2.20.0.patch => react-native-fs+2.20.0+001+encode-file-uris.patch} (100%) diff --git a/patches/react-native-fs/details.md b/patches/react-native-fs/details.md index 0c5632e69ff0..40f0ea158977 100644 --- a/patches/react-native-fs/details.md +++ b/patches/react-native-fs/details.md @@ -1,6 +1,6 @@ # `react-native-fs` patches -### [react-native-fs+2.20.0.patch](react-native-fs+2.20.0.patch) +### [react-native-fs+2.20.0+001+encode-file-uris.patch](react-native-fs+2.20.0+001+encode-file-uris.patch) - Reason: @@ -12,7 +12,6 @@ Delete this patch when react-native-fs goes away in favour of react-native-blob-util. ``` -- Fork carrying the fix: https://github.com/birdofpreyru/react-native-fs/pull/6 (commit [`bbcab23`](https://github.com/birdofpreyru/react-native-fs/commit/bbcab23848195296ab9e200590b6180bba0f3a18), released in `@dr.pogodin/react-native-fs` v2.22.0). This patch backports that line. The fork is a separate npm package, so there is no version of `react-native-fs` to upgrade to. -- Upstream PR/issue: 🛑, `itinance/react-native-fs` is unmaintained. Last pushed 2024-03-18, 630 open issues, and npm `latest` is still the 2.20.0 we depend on. -- E/App issue: 🛑, tracked internally +- Upstream PR/issue: 🛑, `itinance/react-native-fs` is unmaintained. Last pushed 2024-03-18, 630 open issues, and npm `latest` is still the 2.20.0 we depend on. The maintained fork already carries the identical change, https://github.com/birdofpreyru/react-native-fs/pull/6 (commit [`bbcab23`](https://github.com/birdofpreyru/react-native-fs/commit/bbcab23848195296ab9e200590b6180bba0f3a18), released in `@dr.pogodin/react-native-fs` v2.22.0), which this patch backports. That fork is a separate npm package, so there is no version of `react-native-fs` to upgrade to. +- E/App issue: https://github.com/Expensify/App/issues/99350 - PR introducing patch: https://github.com/Expensify/App/pull/99333 diff --git a/patches/react-native-fs/react-native-fs+2.20.0.patch b/patches/react-native-fs/react-native-fs+2.20.0+001+encode-file-uris.patch similarity index 100% rename from patches/react-native-fs/react-native-fs+2.20.0.patch rename to patches/react-native-fs/react-native-fs+2.20.0+001+encode-file-uris.patch From ed200bf834c88c8eecef05589af4ff7980083be2 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Tue, 25 Aug 2026 16:50:45 +0200 Subject: [PATCH 354/466] Trim the patch details to the durable facts The fork's PR link, commit hash, and release version were detail that rots. What a reader needs is that 2.20.0 lacks the fix, maintained forks carry it, and upstream is unmaintained, so there is nothing to upgrade to. Co-Authored-By: Claude Opus 5 (1M context) --- patches/react-native-fs/details.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/patches/react-native-fs/details.md b/patches/react-native-fs/details.md index 40f0ea158977..2e623c0ee25b 100644 --- a/patches/react-native-fs/details.md +++ b/patches/react-native-fs/details.md @@ -5,13 +5,13 @@ - Reason: ``` - `getFileUri` built a file URI by concatenation, `Uri.parse("file://" + filepath)`. `Uri.parse` then reads URI syntax in the result, so a "#" in the path opens a fragment and everything after it is dropped, and a "?" opens a query. `Uri.fromFile` percent-encodes the path before parsing it, so this patch calls that instead. + `getFileUri` built a file URI by concatenation, `Uri.parse("file://" + filepath)`. `Uri.parse` reads URI syntax in the result, so a "#" in the path starts a fragment and drops everything after it, and a "?" starts a query. This patch calls `Uri.fromFile` instead, which percent-encodes the path first. - Anything in RNFS that opens a stream goes through `getFileUri`: readFile, writeFile, appendFile, copyFile, and the cross-filesystem fallback inside moveFile. On Android the receipts folder sits on external storage while picked files land in the app cache, so moveFile's rename fails and that fallback always runs. A receipt whose filename carried a "#" stayed in the app cache instead of reaching Documents/Receipts-Upload. + Every RNFS call that opens a stream goes through `getFileUri`: readFile, writeFile, appendFile, copyFile, and the cross-filesystem fallback inside moveFile. On Android the receipts folder sits on external storage while picked files land in the app cache, so moveFile's rename fails and that fallback always runs. A receipt whose filename carried a "#" stayed in the app cache instead of reaching Documents/Receipts-Upload. - Delete this patch when react-native-fs goes away in favour of react-native-blob-util. + Delete this patch when react-native-blob-util replaces react-native-fs. ``` -- Upstream PR/issue: 🛑, `itinance/react-native-fs` is unmaintained. Last pushed 2024-03-18, 630 open issues, and npm `latest` is still the 2.20.0 we depend on. The maintained fork already carries the identical change, https://github.com/birdofpreyru/react-native-fs/pull/6 (commit [`bbcab23`](https://github.com/birdofpreyru/react-native-fs/commit/bbcab23848195296ab9e200590b6180bba0f3a18), released in `@dr.pogodin/react-native-fs` v2.22.0), which this patch backports. That fork is a separate npm package, so there is no version of `react-native-fs` to upgrade to. +- Upstream PR/issue: 🛑, `itinance/react-native-fs` is unmaintained. Last pushed 2024-03-18, 630 open issues, and npm `latest` is still the 2.20.0 we depend on, which does not carry this fix. Maintained forks do carry it, but they ship under their own package names, so there is no version of `react-native-fs` to upgrade to. - E/App issue: https://github.com/Expensify/App/issues/99350 - PR introducing patch: https://github.com/Expensify/App/pull/99333 From cee335bac4e8450a2642c64440a8ebc05ce8931c Mon Sep 17 00:00:00 2001 From: "Maria D'Costa (via MelvinBot)" Date: Tue, 25 Aug 2026 15:05:47 +0000 Subject: [PATCH 355/466] Split semicolon-spliced comment into two sentences (CONSISTENCY-16) Co-authored-by: Maria D'Costa --- src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx index a2a1cd054b41..93d0830861b4 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -140,7 +140,7 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr // Concierge chat already exists but onboarding is still pending. ReportFetchHandler's onboarding OpenReport only // fires on its mount effect, which runs after this handler, so we enqueue the same OpenReport ourselves first to win // the ordering. We mirror ReportFetchHandler's isLoadingApp guard because guided-setup tasks need loaded policies to - // resolve their deep links (https://github.com/Expensify/App/issues/71742); when isLoadingApp is true we skip and let + // resolve their deep links (https://github.com/Expensify/App/issues/71742). When isLoadingApp is true we skip and let // ReportFetchHandler's deferred OpenReport run instead. Keep this guard as-is. if (isGuidedSetupPending && !isLoadingApp) { openReport({ From 99e57e86a5d62fa7718ae6a993bb8fb3f8d73e72 Mon Sep 17 00:00:00 2001 From: "Maria D'Costa (via MelvinBot)" Date: Tue, 25 Aug 2026 16:00:41 +0000 Subject: [PATCH 356/466] Enqueue onboarding OpenReport on navigateToAndOpenReport existing-chat path when onboarding pending Co-authored-by: Maria D'Costa --- src/libs/actions/Report/index.ts | 18 ++++++++-- tests/actions/ReportTest.ts | 62 ++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 0b67c798e80b..c3384de31052 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -2596,7 +2596,20 @@ function navigateToAndOpenReport({ return; } + // The create path above (createAndOpenNewOptimisticChat) enqueues an onboarding OpenReport carrying guidedSetupData + // whenever guided setup is still pending. When the DM already exists we take an existing-chat path instead, which would + // otherwise skip that OpenReport — breaking callers that rely on it being enqueued (e.g. the Concierge limit-increase + // reply must be stamped after the onboarding welcome message + tasks). So when onboarding is pending, enqueue the same + // onboarding OpenReport here too, so the create-path branch's assumption holds regardless of whether the DM already + // exists. getGuidedSetupDataForOpenReport de-dupes, so this won't duplicate onboarding. See https://github.com/Expensify/App/issues/99396. + const isOnboardingCompleted = hasCompletedGuidedSetupFlow ?? false; + const isRegularOnboardingPending = !!introSelected && !introSelected.inviteType && isSupportedInviteOnboardingChoice(introSelected.choice) && !isOnboardingCompleted; + const isOnboardingPending = isRegularOnboardingPending || isSupportedPendingInviteOnboarding(introSelected); + if (!shouldRevalidateExistingChat) { + if (isOnboardingPending) { + openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, hasReportActions, currentUserAccountID}); + } navigateToReport(chat.reportID, {shouldDismissModal, ...linkToOptions}); return; } @@ -2621,8 +2634,9 @@ function navigateToAndOpenReport({ }, }); - // Re-open existing chats to re-validate server-side access and refresh stale local state. - openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, betas, hasReportActions, currentUserAccountID}); + // Re-open existing chats to re-validate server-side access and refresh stale local state. Pass hasCompletedGuidedSetupFlow + // so a pending onboarding OpenReport is enqueued here too (see the create-path assumption note above). + openReport({reportID: chat.reportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, betas, hasReportActions, currentUserAccountID}); navigateToReport(chat.reportID, {shouldDismissModal, ...linkToOptions}); } diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 4e149dff548b..e7b5f252bb6e 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -8090,25 +8090,81 @@ describe('actions/Report', () => { const testIntroSelected: OnyxTypes.IntroSelected = {choice: CONST.ONBOARDING_CHOICES.ADMIN}; - // When navigateToAndOpenReport is called with the participant that has an existing chat + // When navigateToAndOpenReport is called with the participant that has an existing chat and onboarding is + // already complete (so no onboarding OpenReport needs to be enqueued) Report.navigateToAndOpenReport({ userLogins: [PARTICIPANT_LOGIN], personalDetails: {}, currentUserAccountID: TEST_USER_ACCOUNT_ID, introSelected: testIntroSelected, isSelfTourViewed: false, - hasCompletedGuidedSetupFlow: undefined, + hasCompletedGuidedSetupFlow: true, betas: undefined, }); await waitForBatchedUpdates(); - // Then verify OpenReport API was NOT called since the chat already exists + // Then verify OpenReport API was NOT called since the chat already exists and onboarding is complete TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 0); // Then verify navigation was called to the existing report expect(Navigation.navigate).toHaveBeenCalled(); }); + it('should enqueue an onboarding OpenReport on the existing chat when onboarding is still pending', async () => { + // Given a test user with initial data + const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@user.com'; + const PARTICIPANT_LOGIN = 'participant@test.com'; + const PARTICIPANT_ACCOUNT_ID = 2; + const EXISTING_REPORT_ID = '123'; + + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); + await TestHelper.setPersonalDetails(TEST_USER_LOGIN, TEST_USER_ACCOUNT_ID); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [PARTICIPANT_ACCOUNT_ID]: { + accountID: PARTICIPANT_ACCOUNT_ID, + login: PARTICIPANT_LOGIN, + displayName: 'Participant', + }, + }); + + // When there is an existing chat report with the participant + const existingReport: OnyxTypes.Report = { + reportID: EXISTING_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + participants: { + [TEST_USER_ACCOUNT_ID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, + [PARTICIPANT_ACCOUNT_ID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, + }, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXISTING_REPORT_ID}`, existingReport); + + const testIntroSelected: OnyxTypes.IntroSelected = {choice: CONST.ONBOARDING_CHOICES.ADMIN}; + + // When navigateToAndOpenReport is called with onboarding still pending (guided setup not yet completed) + Report.navigateToAndOpenReport({ + userLogins: [PARTICIPANT_LOGIN], + personalDetails: {}, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + introSelected: testIntroSelected, + isSelfTourViewed: false, + hasCompletedGuidedSetupFlow: false, + betas: undefined, + }); + await waitForBatchedUpdates(); + + // Then OpenReport is enqueued once on the existing chat so the pending onboarding OpenReport lands (the create + // path's assumption holds even though the DM already exists) + TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); + + // Then verify navigation was still called to the existing report + expect(Navigation.navigate).toHaveBeenCalled(); + }); + it('should revalidate existing chat when shouldRevalidateExistingChat is true', async () => { const TEST_USER_ACCOUNT_ID = 1; const TEST_USER_LOGIN = 'test@user.com'; From b2b79354ec7a37646fb449efe3a27e40dc1ea286 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 25 Aug 2026 23:13:49 +0700 Subject: [PATCH 357/466] remove swift redundant checking logic --- src/libs/BankAccountUtils.ts | 49 +++++------------ src/libs/actions/BankAccounts.ts | 12 +--- .../InternationalDepositAccountContent.tsx | 9 +-- .../subPages/Confirmation.tsx | 11 +--- .../InternationalBankAccountDetails.tsx | 11 +--- .../InternationalDepositAccount/utils.ts | 6 +- tests/unit/BankAccountUtilsTest.ts | 55 ++++--------------- .../InternationalDepositAccountUtilsTest.ts | 13 +---- 8 files changed, 36 insertions(+), 130 deletions(-) diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index 94164aae16b7..c079ec8a7899 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -5,7 +5,6 @@ import type {TranslationPaths} from '@src/languages/types'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type * as OnyxTypes from '@src/types/onyx'; import type AccountData from '@src/types/onyx/AccountData'; -import type {CorpayFormField} from '@src/types/onyx/CorpayFields'; import type {ACHData} from '@src/types/onyx/ReimbursementAccount'; import type {OnyxEntry} from 'react-native-onyx'; @@ -295,21 +294,6 @@ function isValidSwiftBic(value?: string): boolean { return CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test((value ?? '').trim()); } -function doesValueMatchCorpayField(value: string | undefined, field?: CorpayFormField): boolean { - const trimmed = (value ?? '').trim(); - if (!trimmed || !field) { - return false; - } - return field.validationRules.every((rule) => new RegExp(rule.regEx).test(trimmed)); -} - -function isSwiftBicCollectedOnAccountDetails(swiftBicCode?: string, corpaySwiftField?: CorpayFormField): boolean { - if (corpaySwiftField) { - return doesValueMatchCorpayField(swiftBicCode, corpaySwiftField); - } - return isValidSwiftBic(swiftBicCode); -} - /** * IBAN/SWIFT/BIC can come from either the dedicated `iban`/`swiftCode` fields filled in on the international bank * account details step, or from `accountNumber`/`swiftBicCode`, which the Corpay bank-details step already collects @@ -321,12 +305,13 @@ function hasValidInternationalBankAccountDetails(iban: string | undefined, swift } /** - * Whether the Corpay account details step already collected a real IBAN and a SWIFT value that matches that country's - * Corpay rules. That is the only case where the dedicated international details step can be omitted. Filled - * `iban`/`swiftCode` must not omit it, or the step disappears from the wizard after the user completes it. + * Whether the Corpay account details step already collected a real IBAN and a SWIFT value. That is the only case + * where the dedicated international details step can be omitted. `accountNumber` must be an IBAN (it is often a local + * number). `swiftBicCode` is always the SWIFT field and is validated on that step, so non-empty is enough. Filled + * `iban`/`swiftCode` must not omit the step, or it disappears from the wizard after the user completes it. */ -function hasValidAccountDetailsInternationalFields(accountNumber?: string, swiftBicCode?: string, corpaySwiftField?: CorpayFormField): boolean { - return isValidIBAN(accountNumber) && isSwiftBicCollectedOnAccountDetails(swiftBicCode, corpaySwiftField); +function hasValidAccountDetailsInternationalFields(accountNumber?: string, swiftBicCode?: string): boolean { + return isValidIBAN(accountNumber) && !!swiftBicCode; } /** @@ -334,23 +319,19 @@ function hasValidAccountDetailsInternationalFields(accountNumber?: string, swift * dedicated `iban`/`swiftCode` fields weren't collected (e.g. the international bank account details step was * skipped because the Corpay bank-details step already gathered equivalent values). */ -function getInternationalBankAccountDetailsValues( - iban: string | undefined, - swiftCode: string | undefined, - accountNumber?: string, - swiftBicCode?: string, - corpaySwiftField?: CorpayFormField, -): {iban: string; swiftCode: string} { +function getInternationalBankAccountDetailsValues(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string): {iban: string; swiftCode: string} { let resolvedIBAN = iban ?? ''; if (!resolvedIBAN && isValidIBAN(accountNumber)) { resolvedIBAN = accountNumber ?? ''; } let resolvedSwiftCode = swiftCode ?? ''; - if (!resolvedSwiftCode && isSwiftBicCollectedOnAccountDetails(swiftBicCode, corpaySwiftField)) { - resolvedSwiftCode = swiftBicCode ?? ''; + if (!resolvedSwiftCode && swiftBicCode) { + resolvedSwiftCode = swiftBicCode; } + // `swiftBicCode` is always the SWIFT field and is already validated on the account details step, so a non-empty + // value can be copied as-is. `accountNumber` is not always an IBAN, so that fallback still uses isValidIBAN. return { iban: resolvedIBAN, swiftCode: resolvedSwiftCode, @@ -361,14 +342,10 @@ function getInternationalBankAccountDetailsValues( * Locks the international-details IBAN or SWIFT/BIC input when the initial bank details page already collected * that value, so the user cannot submit a second conflicting copy. */ -function getDisabledInternationalBankAccountFields( - accountNumber?: string, - swiftBicCode?: string, - corpaySwiftField?: CorpayFormField, -): {isIBANDisabled: boolean; isSwiftCodeDisabled: boolean} { +function getDisabledInternationalBankAccountFields(accountNumber?: string, swiftBicCode?: string): {isIBANDisabled: boolean; isSwiftCodeDisabled: boolean} { return { isIBANDisabled: isValidIBAN(accountNumber), - isSwiftCodeDisabled: isSwiftBicCollectedOnAccountDetails(swiftBicCode, corpaySwiftField), + isSwiftCodeDisabled: !!swiftBicCode, }; } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 576ecdd6bd7e..2aabddb4b404 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -41,7 +41,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {InternationalBankAccountForm, PersonalBankAccountForm} from '@src/types/form'; import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, ReimbursementAccountForm, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; -import type {BankAccountList, CorpayFormField, LastPaymentMethod, LastPaymentMethodType, PersonalBankAccount} from '@src/types/onyx'; +import type {BankAccountList, LastPaymentMethod, LastPaymentMethodType, PersonalBankAccount} from '@src/types/onyx'; import type {BankAccountAdditionalData} from '@src/types/onyx/BankAccount'; import type PlaidBankAccount from '@src/types/onyx/PlaidBankAccount'; import type {BankAccountStep, ReimbursementAccountStep, ReimbursementAccountSubStep} from '@src/types/onyx/ReimbursementAccount'; @@ -1591,16 +1591,10 @@ function unshareBankAccount(bankAccountID: number, ownerEmail: string) { API.write(WRITE_COMMANDS.UNSHARE_BANK_ACCOUNT, parameters, onyxData); } -function createCorpayBankAccountForWalletFlow( - data: InternationalBankAccountForm, - classification: string, - destinationCountry: string, - preferredMethod: string, - corpaySwiftField?: CorpayFormField, -) { +function createCorpayBankAccountForWalletFlow(data: InternationalBankAccountForm, classification: string, destinationCountry: string, preferredMethod: string) { // The international details step is skipped when the Corpay bank-details step already collected equivalent values, // so resolve them here to guarantee the IBAN/SWIFT are always sent when we have them, whichever step captured them. - const {iban, swiftCode} = getInternationalBankAccountDetailsValues(data.iban, data.swiftCode, data.accountNumber, data.swiftBicCode, corpaySwiftField); + const {iban, swiftCode} = getInternationalBankAccountDetailsValues(data.iban, data.swiftCode, data.accountNumber, data.swiftBicCode); const inputData = { ...data, iban, diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx index 8c7a16c7efcd..532a7ea3d503 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -42,7 +42,7 @@ import Confirmation from './subPages/Confirmation'; import CountrySelection from './subPages/CountrySelection'; import InternationalBankAccountDetails from './subPages/InternationalBankAccountDetails'; import Success from './subPages/Success'; -import {getCorpayFieldByLabelKeyword, getFieldsMap, getInitialPersonalDetailsValues, getInitialSubstep, getSubstepValues, testValidation} from './utils'; +import {getFieldsMap, getInitialPersonalDetailsValues, getInitialSubstep, getSubstepValues, testValidation} from './utils'; type InternationalDepositAccountContentProps = { privatePersonalDetails: OnyxEntry; @@ -103,11 +103,8 @@ function InternationalDepositAccountContent({ const skipAccountHolderInformationStep = testValidation(initialAccountHolderDetailsValues, fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS]); // Skip only when collection isn't needed, or when the account details step already captured a real IBAN and a - // SWIFT value that matches Corpay's country rules. Completing `iban`/`swiftCode` on the dedicated step must not - // omit it from the wizard. - const corpaySwiftField = getCorpayFieldByLabelKeyword(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS], CONST.CORPAY_FIELDS.SWIFT_LABEL_KEYWORD); - const skipInternationalBankAccountDetailsStep = - !shouldCollectInternationalDepositDetails || hasValidAccountDetailsInternationalFields(values.accountNumber, values.swiftBicCode, corpaySwiftField); + // SWIFT value. Completing `iban`/`swiftCode` on the dedicated step must not omit it from the wizard. + const skipInternationalBankAccountDetailsStep = !shouldCollectInternationalDepositDetails || hasValidAccountDetailsInternationalFields(values.accountNumber, values.swiftBicCode); const startFrom = getInitialSubstep(values, fieldsMap, skipInternationalBankAccountDetailsStep); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx index c4abd87ec5ec..be15e75f81ba 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/Confirmation.tsx @@ -19,7 +19,7 @@ import {shouldShowInternationalDetailOnConfirmation} from '@libs/BankAccountUtil import {getLatestErrorMessage} from '@libs/ErrorUtils'; import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; -import {getAccountDetailsFieldsMap, getCorpayFieldByLabelKeyword} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; +import {getAccountDetailsFieldsMap} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; import {clearReimbursementAccountBankCreation, createCorpayBankAccountForWalletFlow, hideBankAccountErrors} from '@userActions/BankAccounts'; @@ -70,14 +70,7 @@ function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubPageProp }; const getDataAndGoToNextStep = (values: FormOnyxValues) => { - const corpaySwiftField = getCorpayFieldByLabelKeyword(accountDetailsFields, CONST.CORPAY_FIELDS.SWIFT_LABEL_KEYWORD); - createCorpayBankAccountForWalletFlow( - {...formValues, ...values}, - corpayFields?.classification ?? '', - corpayFields?.destinationCountry ?? '', - corpayFields?.preferredMethod ?? '', - corpaySwiftField, - ); + createCorpayBankAccountForWalletFlow({...formValues, ...values}, corpayFields?.classification ?? '', corpayFields?.destinationCountry ?? '', corpayFields?.preferredMethod ?? ''); }; useEffect(() => { diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx index 619309988dcd..0dca9937f7e7 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/subPages/InternationalBankAccountDetails.tsx @@ -11,16 +11,14 @@ import {getDisabledInternationalBankAccountFields, getInternationalBankAccountDe import {getFieldRequiredErrors} from '@libs/ValidationUtils'; import type CustomSubPageProps from '@pages/settings/Wallet/InternationalDepositAccount/types'; -import {getCorpayFieldByLabelKeyword} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; const STEP_FIELDS = [IBAN_INPUT_ID, SWIFT_CODE_INPUT_ID]; -function InternationalBankAccountDetails({isEditing, onNext, formValues, fieldsMap}: CustomSubPageProps) { +function InternationalBankAccountDetails({isEditing, onNext, formValues}: CustomSubPageProps) { const {translate} = useLocalize(); const handleSubmit = useInternationalBankAccountFormSubmit({ @@ -29,17 +27,14 @@ function InternationalBankAccountDetails({isEditing, onNext, formValues, fieldsM shouldSaveDraft: true, }); - // Pre-fill from what the account details step already captured, until the user edits this step directly. SWIFT - // is treated as collected when it matches Corpay's account-details rules, not our shared BIC format. - const corpaySwiftField = getCorpayFieldByLabelKeyword(fieldsMap[CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_DETAILS], CONST.CORPAY_FIELDS.SWIFT_LABEL_KEYWORD); + // Pre-fill from what the account details step already captured, until the user edits this step directly. const {iban: ibanDefaultValue, swiftCode: swiftCodeDefaultValue} = getInternationalBankAccountDetailsValues( formValues[IBAN_INPUT_ID], formValues[SWIFT_CODE_INPUT_ID], formValues.accountNumber, formValues.swiftBicCode, - corpaySwiftField, ); - const {isIBANDisabled, isSwiftCodeDisabled} = getDisabledInternationalBankAccountFields(formValues.accountNumber, formValues.swiftBicCode, corpaySwiftField); + const {isIBANDisabled, isSwiftCodeDisabled} = getDisabledInternationalBankAccountFields(formValues.accountNumber, formValues.swiftBicCode); const validate = (values: FormOnyxValues): FormInputErrors => ({ ...getFieldRequiredErrors(values, STEP_FIELDS, translate), diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts index f1c8715816bc..979e8e887813 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts +++ b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts @@ -165,10 +165,6 @@ function isInternationalBankAccountFieldLabel(label: string): boolean { return [CONST.CORPAY_FIELDS.IBAN_LABEL_KEYWORD, CONST.CORPAY_FIELDS.SWIFT_LABEL_KEYWORD].some((keyword) => normalizedLabel.includes(keyword)); } -function getCorpayFieldByLabelKeyword(fields: CorpayFieldsMap | undefined, keyword: string) { - return Object.values(fields ?? {}).find((field) => field.label.toLowerCase().includes(keyword)); -} - function getAccountDetailsFieldsMap(accountDetailsFields: CorpayFieldsMap | undefined, shouldCollectInternationalDepositDetails: boolean): CorpayFieldsMap { const fields = accountDetailsFields ?? {}; if (!shouldCollectInternationalDepositDetails) { @@ -187,4 +183,4 @@ function getAccountDetailsFieldsMap(accountDetailsFields: CorpayFieldsMap | unde return nextFields; } -export {getFieldsMap, getSubstepValues, getInitialPersonalDetailsValues, getInitialSubstep, testValidation, getValidationErrors, getAccountDetailsFieldsMap, getCorpayFieldByLabelKeyword}; +export {getFieldsMap, getSubstepValues, getInitialPersonalDetailsValues, getInitialSubstep, testValidation, getValidationErrors, getAccountDetailsFieldsMap}; diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts index 15967ee0194b..b985dd5032da 100644 --- a/tests/unit/BankAccountUtilsTest.ts +++ b/tests/unit/BankAccountUtilsTest.ts @@ -25,7 +25,6 @@ import CONST from '@src/CONST'; import INPUT_IDS from '@src/types/form/ReimbursementAccountForm'; import type {Account, BankAccountList, Session} from '@src/types/onyx'; import type AccountData from '@src/types/onyx/AccountData'; -import type {CorpayFormField} from '@src/types/onyx/CorpayFields'; import createMock from '../utils/createMock'; @@ -763,27 +762,14 @@ describe('BankAccountUtils', () => { describe('hasValidAccountDetailsInternationalFields', () => { const iban = 'AT483200000012345864'; const swiftBicCode = 'XXXXATXX'; - const corpaySwiftField = { - id: 'swiftBicCode', - isRequired: false, - isRequiredInValueSet: false, - label: 'Swift Code', - errorMessage: '', - regEx: '', - validationRules: [{regEx: '^.{0,12}$', errorMessage: 'Swift must be less than 12 characters'}], - } as CorpayFormField; - - it('is true only when accountNumber is an IBAN and swiftBicCode is a SWIFT/BIC', () => { + + it('is true only when accountNumber is an IBAN and swiftBicCode is set', () => { expect(hasValidAccountDetailsInternationalFields(iban, swiftBicCode)).toBe(true); + expect(hasValidAccountDetailsInternationalFields(iban, 'XXXX')).toBe(true); expect(hasValidAccountDetailsInternationalFields('123456789', swiftBicCode)).toBe(false); - expect(hasValidAccountDetailsInternationalFields(iban, 'XXXX')).toBe(false); + expect(hasValidAccountDetailsInternationalFields(iban, undefined)).toBe(false); expect(hasValidAccountDetailsInternationalFields(undefined, undefined)).toBe(false); }); - - it('accepts a SWIFT value that matches Corpay account-details rules', () => { - expect(hasValidAccountDetailsInternationalFields(iban, 'XXXX', corpaySwiftField)).toBe(true); - expect(hasValidAccountDetailsInternationalFields(iban, '1234567890123', corpaySwiftField)).toBe(false); - }); }); describe('hasValidInternationalBankAccountDetails', () => { @@ -806,15 +792,6 @@ describe('BankAccountUtils', () => { describe('getDisabledInternationalBankAccountFields', () => { const iban = 'AT483200000012345864'; const swiftBicCode = 'XXXXATXX'; - const corpaySwiftField = { - id: 'swiftBicCode', - isRequired: false, - isRequiredInValueSet: false, - label: 'Swift Code', - errorMessage: '', - regEx: '', - validationRules: [{regEx: '^.{0,12}$', errorMessage: 'Swift must be less than 12 characters'}], - } as CorpayFormField; it('disables IBAN when the account number is already a valid IBAN', () => { expect(getDisabledInternationalBankAccountFields(iban, undefined).isIBANDisabled).toBe(true); @@ -824,34 +801,22 @@ describe('BankAccountUtils', () => { expect(getDisabledInternationalBankAccountFields('123456789', undefined).isIBANDisabled).toBe(false); }); - it('disables SWIFT/BIC when swiftBicCode is already a valid SWIFT/BIC', () => { + it('disables SWIFT/BIC when swiftBicCode is already set', () => { expect(getDisabledInternationalBankAccountFields(undefined, swiftBicCode).isSwiftCodeDisabled).toBe(true); + expect(getDisabledInternationalBankAccountFields(undefined, 'XXXX').isSwiftCodeDisabled).toBe(true); }); it('does not disable SWIFT/BIC when swiftBicCode is empty', () => { expect(getDisabledInternationalBankAccountFields(iban, undefined).isSwiftCodeDisabled).toBe(false); }); - - it('disables SWIFT when the value matches Corpay account-details rules', () => { - expect(getDisabledInternationalBankAccountFields(undefined, 'XXXX', corpaySwiftField).isSwiftCodeDisabled).toBe(true); - expect(getDisabledInternationalBankAccountFields(undefined, '1234567890123', corpaySwiftField).isSwiftCodeDisabled).toBe(false); - }); }); describe('getInternationalBankAccountDetailsValues', () => { const iban = 'AT483200000012345864'; - const corpaySwiftField = { - id: 'swiftBicCode', - isRequired: false, - isRequiredInValueSet: false, - label: 'Swift Code', - errorMessage: '', - regEx: '', - validationRules: [{regEx: '^.{0,12}$', errorMessage: 'Swift must be less than 12 characters'}], - } as CorpayFormField; - - it('prefills SWIFT from swiftBicCode when it matches Corpay account-details rules', () => { - expect(getInternationalBankAccountDetailsValues(undefined, undefined, iban, 'XXXX', corpaySwiftField)).toEqual({iban, swiftCode: 'XXXX'}); + + it('prefills SWIFT from a non-empty swiftBicCode and IBAN only when accountNumber is an IBAN', () => { + expect(getInternationalBankAccountDetailsValues(undefined, undefined, iban, 'XXXX')).toEqual({iban, swiftCode: 'XXXX'}); + expect(getInternationalBankAccountDetailsValues(undefined, undefined, '123456789', 'XXXX')).toEqual({iban: '', swiftCode: 'XXXX'}); }); }); diff --git a/tests/unit/InternationalDepositAccountUtilsTest.ts b/tests/unit/InternationalDepositAccountUtilsTest.ts index 3093cbcc5eb5..fba2bec2b4ef 100644 --- a/tests/unit/InternationalDepositAccountUtilsTest.ts +++ b/tests/unit/InternationalDepositAccountUtilsTest.ts @@ -1,8 +1,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import {getAccountDetailsFieldsMap, getCorpayFieldByLabelKeyword, getValidationErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; +import {getAccountDetailsFieldsMap, getValidationErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; -import CONST from '@src/CONST'; import type {CorpayFormField} from '@src/types/onyx/CorpayFields'; const translate: LocaleContextProps['translate'] = (path, ...parameters) => { @@ -65,16 +64,6 @@ describe('getAccountDetailsFieldsMap', () => { }); }); -describe('getCorpayFieldByLabelKeyword', () => { - it('finds the SWIFT field by label text from Corpay', () => { - const swiftBicCode = createCorpayField('swiftBicCode', false, 'Swift Code'); - const accountNumber = createCorpayField('accountNumber', true, 'Account Number'); - - expect(getCorpayFieldByLabelKeyword({swiftBicCode, accountNumber}, CONST.CORPAY_FIELDS.SWIFT_LABEL_KEYWORD)).toBe(swiftBicCode); - expect(getCorpayFieldByLabelKeyword({accountNumber}, CONST.CORPAY_FIELDS.SWIFT_LABEL_KEYWORD)).toBeUndefined(); - }); -}); - describe('getValidationErrors', () => { it('uses Corpay validation rules for a required SWIFT field', () => { const swiftBicCode = createCorpayField('swiftBicCode', true, 'Swift Code', [{regEx: '^.{0,12}$', errorMessage: 'Swift must be less than 12 characters'}]); From d9ff9f97e5b06657a082aa800f31619fc57b332b Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:16:03 +0200 Subject: [PATCH 358/466] fix action --- src/libs/actions/Workflow.ts | 8 +++++- tests/actions/WorkflowTest.ts | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 1ad6322528cc..91125b9267e8 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -326,7 +326,13 @@ function setApprovalWorkflowRules({policyID, rulesDiff, previousRules}: SetAppro optimisticData.push({ onyxMethod: Onyx.METHOD.SET, key: ruleKey, - value: {...rule, scope: CONST.RULES.SCOPE.POLICY, scopeID: policyID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: null}, + value: { + ...rule, + scope: CONST.RULES.SCOPE.POLICY, + scopeID: policyID, + pendingAction: previousRule ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: null, + }, }); successData.push({onyxMethod: Onyx.METHOD.MERGE, key: ruleKey, value: {pendingAction: null}}); failureData.push(restore); diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index cb8d62887712..0890fe2b2a19 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -1101,6 +1101,54 @@ describe('actions/Workflow', () => { await mockFetch.resume(); await waitForBatchedUpdates(); }); + + it('marks rewritten rules as pending update rather than pending add', async () => { + mockFetch.pause(); + + const policyID = '123456789'; + const policy: Policy = { + ...createRandomPolicy(1), + id: policyID, + owner: ownerEmail, + rules: {}, + }; + + // Seed the existing [employee1] → [owner] workflow as rules in the collection. + await createForwardApproveRules(policyID, [employee1Email], ownerEmail); + + const initialApprovalWorkflow = { + members: [{email: employee1Email, displayName: employee1Email}], + approvers: [{email: ownerEmail, displayName: ownerEmail, isCircularReference: false}], + availableMembers: [], + usedApproverEmails: [], + isDefault: false, + action: 'update', + originalApprovers: [], + }; + + // Adding a member keeps the same approver chain, so the seeded rules are rewritten under their own IDs. + const approvalWorkflow = { + ...initialApprovalWorkflow, + members: [ + {email: employee1Email, displayName: employee1Email}, + {email: employee2Email, displayName: employee2Email}, + ], + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, policy); + await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); + await waitForBatchedUpdates(); + + updateApprovalWorkflowRules({approvalWorkflow, initialApprovalWorkflow, policy, rules: await getRulesCollection()}); + await waitForBatchedUpdates(); + + const collection = await getRulesCollection(); + expect(collection?.[`${ONYXKEYS.COLLECTION.RULE}rule1`]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + expect(collection?.[`${ONYXKEYS.COLLECTION.RULE}rule2`]?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + + await mockFetch.resume(); + await waitForBatchedUpdates(); + }); }); describe('removeApprovalWorkflowRules', () => { From 0b480dc290ec566f265b2da041d6c15752098f8b Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:19:23 +0200 Subject: [PATCH 359/466] cleanup --- src/libs/WorkflowUtils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 0b429976aa54..f5770b355846 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1003,9 +1003,7 @@ function replaceSubmitterEmails(rule: ApprovalWorkflowRule, newEmails: string[]) /** Merge `emailsToAdd` into `existingEmails`, preserving the order of `existingEmails` and dropping duplicates. */ function mergeEmails(existingEmails: string[], emailsToAdd: string[]): string[] { - const seen = new Set(existingEmails); - - return [...existingEmails, ...emailsToAdd.filter((email) => !seen.has(email) && !!seen.add(email))]; + return [...new Set([...existingEmails, ...emailsToAdd])]; } /** Remove everything in `emailsToRemove` from `existingEmails`, preserving the order of `existingEmails`. */ From 09f7dccfde45baaa9f6f65bedb60ba5d81ca1748 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:21:39 +0200 Subject: [PATCH 360/466] simplify tests --- tests/unit/WorkflowUtilsTest.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 1159679c7cf1..d4654850aa7b 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -1904,18 +1904,12 @@ describe('WorkflowUtils', () => { }); describe('convertApprovalWorkflowRulesToWorkflows', () => { - const createPolicy = (employees: PolicyEmployeeList, defaultApprover: string) => - ({ - id: 'test-policy', - name: 'Test Policy', - role: 'admin' as const, - type: 'team' as const, - owner: 'owner@example.com', - outputCurrency: 'USD', - isPolicyExpenseChatEnabled: true, - employeeList: employees, - approver: defaultApprover, - }) as Policy; + const createPolicy = (employees: PolicyEmployeeList, defaultApprover: string): Policy => ({ + ...createRandomPolicy(1), + owner: 'owner@example.com', + employeeList: employees, + approver: defaultApprover, + }); it('Should reconstruct a multi-approver chain from rules', () => { const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); From 8158218d2fb75905c7fc20ec548b6825f4e57101 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:25:34 +0200 Subject: [PATCH 361/466] simplify --- src/libs/actions/Policy/Policy.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 8fe6d48bc35e..7cb59f0bf0a0 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1088,9 +1088,11 @@ function setWorkspaceApprovalMode( if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { continue; } - const ruleOnyxKey: `${typeof ONYXKEYS.COLLECTION.RULE}${string}` = `${ONYXKEYS.COLLECTION.RULE}${ruleKey.slice(ONYXKEYS.COLLECTION.RULE.length)}`; - optimisticData.push({onyxMethod: Onyx.METHOD.SET, key: ruleOnyxKey, value: null}); - failureData.push({onyxMethod: Onyx.METHOD.SET, key: ruleOnyxKey, value: rule}); + // `Object.entries` widens the collection keys to `string`, so rebuild the key from the rule ID to get + // the `rules_${string}` type that an Onyx update needs. + const ruleID = ruleKey.slice(ONYXKEYS.COLLECTION.RULE.length); + optimisticData.push({onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}`, value: null}); + failureData.push({onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}`, value: rule}); } } const successData: Array> = [ From f5ac580297c22d12381fff9a60bca9d7691da7e4 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:27:48 +0200 Subject: [PATCH 362/466] cleanup comment --- src/libs/WorkflowUtils.ts | 10 +++++----- src/libs/actions/Policy/Policy.ts | 2 -- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index f5770b355846..fc95abef29a6 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -965,16 +965,16 @@ function canonicalize(value: unknown): unknown { return value; } -/** - * Return a structural fingerprint of a rule with every `from` leaf's `right` (the submitter list) - * stripped and keys canonicalized. Two rules with the same fingerprint differ only in their submitters, - * which is what we look for when deciding whether to merge two workflows into a shared rule. - */ /** A string that is equal for two values that mean the same thing, regardless of the order their keys were built in. */ function canonicalFingerprint(value: unknown): string { return JSON.stringify(canonicalize(value)); } +/** + * Return a structural fingerprint of a rule with every `from` leaf's `right` (the submitter list) + * stripped and keys canonicalized. Two rules with the same fingerprint differ only in their submitters, + * which is what we look for when deciding whether to merge two workflows into a shared rule. + */ function structuralFingerprint(rule: ApprovalWorkflowRule): string { const stripFromValues = (node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): unknown => { if (!node) { diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 7cb59f0bf0a0..d57ecbec3d0f 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -1088,8 +1088,6 @@ function setWorkspaceApprovalMode( if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { continue; } - // `Object.entries` widens the collection keys to `string`, so rebuild the key from the rule ID to get - // the `rules_${string}` type that an Onyx update needs. const ruleID = ruleKey.slice(ONYXKEYS.COLLECTION.RULE.length); optimisticData.push({onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}`, value: null}); failureData.push({onyxMethod: Onyx.METHOD.SET, key: `${ONYXKEYS.COLLECTION.RULE}${ruleID}`, value: rule}); From eda201ba423aa2803ab82970ed7af29ff1cf3224 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:31:51 +0200 Subject: [PATCH 363/466] left can't be an array --- src/libs/WorkflowUtils.ts | 8 ++------ src/types/onyx/ApprovalWorkflowRules.ts | 15 ++++++--------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index fc95abef29a6..006ae77b4b02 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -894,14 +894,10 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva * joins two other nodes. * * Both look the same (`{operator, left, right}`), so the giveaway is `left`: a comparison points at a field - * name, an `AND` points at another node. Arrays count as comparisons too. + * name, an `AND` points at another node. */ function isComparisonLeaf(node: ApprovalWorkflowFilter | ApprovalWorkflowFilterComparison | undefined): node is ApprovalWorkflowFilterComparison { - if (!node) { - return false; - } - const nodeLeft = node.left; - return typeof nodeLeft !== 'object' || nodeLeft === null || Array.isArray(nodeLeft); + return !!node && typeof node.left === 'string'; } /** True when a comparison node targets the `from` field with an equality operator. */ diff --git a/src/types/onyx/ApprovalWorkflowRules.ts b/src/types/onyx/ApprovalWorkflowRules.ts index 06d5d308d702..0072b5628887 100644 --- a/src/types/onyx/ApprovalWorkflowRules.ts +++ b/src/types/onyx/ApprovalWorkflowRules.ts @@ -35,13 +35,10 @@ type ApprovalWorkflowAction = { type ApprovalWorkflowActions = Record; /** - * Operand of a comparison in an approval-workflow filter. - * - * - The left operand is a field identifier — one of the search-syntax filter keys (`from`, `to`, `amount`). - * - The right operand is the literal value the field is compared against. For email-typed fields like - * `from`, the value may be a list of emails to match against. + * The value a comparison's field is compared against. For email-typed fields like `from`, this is a list + * of emails to match against. */ -type ApprovalWorkflowFilterOperand = string | number | string[]; +type ApprovalWorkflowFilterValue = string | number | string[]; /** * A single comparison node: ` `. Both `left` and `right` are always present. @@ -50,11 +47,11 @@ type ApprovalWorkflowFilterComparison = { /** The comparison operator. */ operator: ValueOf; - /** The field identifier being compared. */ - left: ApprovalWorkflowFilterOperand; + /** The field identifier being compared — one of the search-syntax filter keys (`from`, `to`, `amount`). */ + left: string; /** The literal value being compared against. */ - right: ApprovalWorkflowFilterOperand; + right: ApprovalWorkflowFilterValue; }; /** From 2beb6fc3a541c0ecb658366e15e31290bba674a3 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:38:38 +0200 Subject: [PATCH 364/466] rename function --- src/libs/WorkflowUtils.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 006ae77b4b02..0152f742cdd6 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -880,7 +880,7 @@ function buildApprovalWorkflowRules(approvalWorkflow: ApprovalWorkflow): Approva // Different approvers may share an `overLimitForwardsTo`, which would emit identical terminal rules. const seen = new Set(); return rules.filter((rule) => { - const fingerprint = canonicalFingerprint(rule); + const fingerprint = JSON.stringify(sortObjectKeysDeep(rule)); if (seen.has(fingerprint)) { return false; } @@ -944,9 +944,9 @@ function extractSubmitterEmails(rule: ApprovalWorkflowRule): string[] { * Only key order needs fixing: a rule that came back from the server can list the same keys in a different * order than one we just built, and those two would otherwise produce different strings and never match. */ -function canonicalize(value: unknown): unknown { +function sortObjectKeysDeep(value: unknown): unknown { if (Array.isArray(value)) { - return value.map(canonicalize); + return value.map(sortObjectKeysDeep); } if (value !== null && typeof value === 'object') { // Byte-order sort (not locale-aware): this is a structural fingerprint, not user-facing text. @@ -956,19 +956,14 @@ function canonicalize(value: unknown): unknown { } return a < b ? -1 : 1; }); - return Object.fromEntries(sortedEntries.map(([key, val]) => [key, canonicalize(val)])); + return Object.fromEntries(sortedEntries.map(([key, val]) => [key, sortObjectKeysDeep(val)])); } return value; } -/** A string that is equal for two values that mean the same thing, regardless of the order their keys were built in. */ -function canonicalFingerprint(value: unknown): string { - return JSON.stringify(canonicalize(value)); -} - /** * Return a structural fingerprint of a rule with every `from` leaf's `right` (the submitter list) - * stripped and keys canonicalized. Two rules with the same fingerprint differ only in their submitters, + * stripped and object keys sorted. Two rules with the same fingerprint differ only in their submitters, * which is what we look for when deciding whether to merge two workflows into a shared rule. */ function structuralFingerprint(rule: ApprovalWorkflowRule): string { @@ -985,11 +980,13 @@ function structuralFingerprint(rule: ApprovalWorkflowRule): string { return {operator: node.operator, left: stripFromValues(node.left), right: stripFromValues(node.right)}; }; - return canonicalFingerprint({ - triggers: rule.triggers, - filters: stripFromValues(rule.filters), - actions: rule.actions, - }); + return JSON.stringify( + sortObjectKeysDeep({ + triggers: rule.triggers, + filters: stripFromValues(rule.filters), + actions: rule.actions, + }), + ); } /** Replace the `right` value on every `from` leaf with `newEmails`. */ From 99875d0a49b6f7b1f6c84cea589e1aa21e46a672 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:40:42 +0200 Subject: [PATCH 365/466] reuse filterRulesForPolicy --- src/libs/WorkflowUtils.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 0152f742cdd6..9b4f0c549c0f 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1404,15 +1404,9 @@ function filterRulesForPolicy(rulesCollection: OnyxCollection, policyID: s */ function getApprovalWorkflowRulesForPolicy(rulesCollection: OnyxCollection | undefined, policyID: string | undefined): Record { const result: Record = {}; - if (!rulesCollection || !policyID) { - return result; - } - for (const [onyxKey, rule] of Object.entries(rulesCollection)) { - if (!rule || rule.scope !== CONST.RULES.SCOPE.POLICY || rule.scopeID !== policyID) { - continue; - } - if (rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + for (const [onyxKey, rule] of Object.entries(filterRulesForPolicy(rulesCollection, policyID))) { + if (!rule || rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { continue; } const ruleID = onyxKey.slice(ONYXKEYS.COLLECTION.RULE.length); From 24b135b1a7fa89e974000cb4d6bb8bd389552a6b Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:42:55 +0200 Subject: [PATCH 366/466] moved to module level --- src/libs/WorkflowUtils.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 9b4f0c549c0f..8b6747b2746a 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1464,6 +1464,14 @@ function getRulesSubmitterToWorkflowKey(rules: Record(); const usedApproverEmails = new Set(); const availableMembers: Member[] = []; From c85ce361829820d60823f52d243a6eda94f27ace Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 19:56:08 +0200 Subject: [PATCH 367/466] Match Mobile-Expensify submodule to main --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 7e8bfd0bb953..79674077a3a1 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 7e8bfd0bb95320d73b15d54d20875cf2d86d9891 +Subproject commit 79674077a3a172153b707d139393bffd8bb36bc1 From b00e11bc2ae3750d2a713ef53a44f2e08eac320a Mon Sep 17 00:00:00 2001 From: "Manan (via MelvinBot)" Date: Tue, 25 Aug 2026 18:06:32 +0000 Subject: [PATCH 368/466] Fix: reserve bottom safe-area padding on Reports page ScrollView so Add field button is not cut off Co-authored-by: Manan --- src/pages/workspace/reports/WorkspaceReportsPage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/reports/WorkspaceReportsPage.tsx b/src/pages/workspace/reports/WorkspaceReportsPage.tsx index 0d5e805bf45a..8573886cdbf8 100644 --- a/src/pages/workspace/reports/WorkspaceReportsPage.tsx +++ b/src/pages/workspace/reports/WorkspaceReportsPage.tsx @@ -234,7 +234,10 @@ function WorkspaceReportFieldsPage({ /> )} {!isLoading && ( - +
Date: Tue, 25 Aug 2026 22:53:30 +0200 Subject: [PATCH 369/466] fix equal merge --- src/libs/MergeTransactionUtils.ts | 4 +--- tests/unit/MergeTransactionUtilsTest.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index c6c43305b6a3..1fcd3c6fed00 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -806,10 +806,8 @@ function getMergeFieldUpdatedValues({ if (isDistanceRequest(transaction)) { const previousCustomUnit = mergeTransaction?.customUnit; const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate(undefined, previousCustomUnit, destinationPolicy); - // An empty update means the expense has no exclusion to apply or clear, and writing it would wipe the - // custom unit the merchant selection stored if (Object.keys(commuterExclusionUpdate).length > 0) { - updatedValues.customUnit = commuterExclusionUpdate; + updatedValues.customUnit = {...previousCustomUnit, ...commuterExclusionUpdate}; } if (typeof mergeTransaction?.amount === 'number') { updatedValues.amount = getAmountForBilledDistance( diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index c43c1b9709d1..ff2401bcc348 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -364,7 +364,7 @@ describe('MergeTransactionUtils', () => { }); }); - it('should keep the commuter exclusion when two identical distance expenses are merged without conflicts', () => { + it('should keep the distance, rate and commuter exclusion when two identical distance expenses are merged without conflicts', () => { // Given two identical distance expenses on a report of a workspace that excludes 1 commuter mile, so that // nothing conflicts and every field is merged automatically const excludingWorkspace: Policy = { @@ -380,6 +380,7 @@ describe('MergeTransactionUtils', () => { comment: { customUnit: { name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'rate123', quantity: 10.2, distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, commuterExclusion: 1, @@ -399,11 +400,19 @@ describe('MergeTransactionUtils', () => { excludingWorkspace, ); - // Then the workspace's exclusion survives, and the amount still pays for the reimbursable distance only - expect(result.conflictFields).not.toContain('merchant'); + // Then the distance and rate survive alongside the workspace's exclusion, and the amount still pays for + // the reimbursable distance only + expect(result.conflictFields).toHaveLength(0); expect(result.mergeableData).toMatchObject({ amount: 920, - customUnit: expect.objectContaining({commuterExclusion: 1, reimbursableDistance: 9.2}), + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'rate123', + quantity: 10.2, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + commuterExclusion: 1, + reimbursableDistance: 9.2, + }, }); }); From bb17afec8c8d05b528e68d0ce83286ad058358c9 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 23:06:48 +0200 Subject: [PATCH 370/466] handle manual merges properly --- src/libs/DistanceRequestUtils.ts | 14 +++- src/libs/MergeTransactionUtils.ts | 21 ++++- tests/unit/MergeTransactionUtilsTest.ts | 105 ++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index d495e3e088a2..b7b06304a6e4 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -2,6 +2,7 @@ import type {CurrencyListActionsContextType} from '@components/CurrencyListConte import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; +import type {IOURequestType} from '@src/CONST'; import type {LastSelectedDistanceRates, OnyxInputOrEntry, Transaction} from '@src/types/onyx'; import type DefaultP2PMileageRate from '@src/types/onyx/DefaultP2PMileageRate'; import type {Unit} from '@src/types/onyx/Policy'; @@ -336,6 +337,16 @@ function getCommuterExclusionDisplayData(customUnit: TransactionCustomUnit | und }; } +/** + * Whether a workspace's commuter exclusion applies to a distance expense of this request type. + * + * Only a distance the app itself measured describes a route the workspace can recognise a commute in, so a manually + * entered or odometer distance is reimbursed in full. + */ +function isCommuterExclusionApplicableToRequestType(iouRequestType: IOURequestType | undefined): boolean { + return iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL && iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER; +} + /** * Returns the distance a workspace excludes from a distance of `distance` units, expressed in that same unit. * @@ -374,7 +385,7 @@ function getTransactionCommuterExclusionData({ getCurrencySymbol?: CurrencyListActionsContextType['getCurrencySymbol']; personalPolicyOutputCurrency?: string; }): (Pick & {modifiedAmount: number; customUnit: TransactionCustomUnit}) | undefined { - if (transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL || transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { + if (!isCommuterExclusionApplicableToRequestType(transaction?.iouRequestType)) { return; } @@ -874,6 +885,7 @@ export default { getDistanceRequestAmount, getCommuterExclusionDisplayData, getPolicyCommuterExclusionForDistance, + isCommuterExclusionApplicableToRequestType, getTransactionCommuterExclusionData, getDistanceDisplayDetailsWithCommuter, getFormattedRateValue, diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 1fcd3c6fed00..fbad088f740a 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -2,6 +2,7 @@ import type {CurrencyListActionsContextType} from '@components/CurrencyListConte import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; +import type {IOURequestType} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MergeTransaction, Policy, Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; @@ -716,17 +717,24 @@ type GetMergeFieldUpdatedValuesParams = { * that is selected. Selections are stored with Onyx.merge, which deep merges the custom unit, so both the exclusion of * a workspace that is no longer the destination and the reimbursable distance of a previously selected merchant would * otherwise survive. The distance field then shows the reimbursable distance in place of the full distance. + * + * `iouRequestType` is the type the merged expense ends up with, so that merging reaches the same exclusion that + * creating the expense on the destination workspace would. */ function getCommuterExclusionCustomUnitUpdate( selectedCustomUnit: TransactionCustomUnit | undefined, previousCustomUnit: TransactionCustomUnit | undefined, destinationPolicy: OnyxEntry, + iouRequestType: IOURequestType | undefined, ): CommuterExclusionCustomUnitUpdate { const quantity = selectedCustomUnit?.quantity ?? previousCustomUnit?.quantity; const distanceUnit = selectedCustomUnit?.distanceUnit ?? previousCustomUnit?.distanceUnit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; // The destination workspace's setting decides the exclusion, so it applies to an expense that arrives from a // workspace excluding nothing, and stops applying to one that leaves a workspace that excludes a distance - const commuterExclusion = typeof quantity === 'number' ? DistanceRequestUtils.getPolicyCommuterExclusionForDistance(destinationPolicy, quantity, distanceUnit) : 0; + const commuterExclusion = + typeof quantity === 'number' && DistanceRequestUtils.isCommuterExclusionApplicableToRequestType(iouRequestType) + ? DistanceRequestUtils.getPolicyCommuterExclusionForDistance(destinationPolicy, quantity, distanceUnit) + : 0; if (commuterExclusion > 0 && typeof quantity === 'number') { return { @@ -805,7 +813,14 @@ function getMergeFieldUpdatedValues({ // and the amount has to follow the distance that is left to reimburse if (isDistanceRequest(transaction)) { const previousCustomUnit = mergeTransaction?.customUnit; - const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate(undefined, previousCustomUnit, destinationPolicy); + // The merchant selection is what sets the merged expense's type, so it is preferred over the type of the + // expense whose report was selected here + const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate( + undefined, + previousCustomUnit, + destinationPolicy, + mergeTransaction?.iouRequestType ?? transaction?.iouRequestType, + ); if (Object.keys(commuterExclusionUpdate).length > 0) { updatedValues.customUnit = {...previousCustomUnit, ...commuterExclusionUpdate}; } @@ -823,7 +838,7 @@ function getMergeFieldUpdatedValues({ const transactionDetails = getTransactionDetails(transaction); updatedValues.currency = getCurrency(transaction); const selectedCustomUnit = transaction?.comment?.customUnit; - const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate(selectedCustomUnit, mergeTransaction?.customUnit, destinationPolicy); + const commuterExclusionUpdate = getCommuterExclusionCustomUnitUpdate(selectedCustomUnit, mergeTransaction?.customUnit, destinationPolicy, transaction?.iouRequestType); updatedValues.customUnit = { ...selectedCustomUnit, ...commuterExclusionUpdate, diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index ff2401bcc348..d16e1dfdc493 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -416,6 +416,111 @@ describe('MergeTransactionUtils', () => { }); }); + it.each([CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER])( + 'should not apply the commuter exclusion when merging a %s expense onto a workspace that excludes commuter distance', + (iouRequestType) => { + // Given two identical distance expenses whose distance the app did not measure, on a report of a + // workspace that excludes 1 commuter mile + const excludingWorkspace: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, fixedDistance: 1, fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }; + const distanceExpense = { + ...createRandomDistanceRequestTransaction(0), + iouRequestType, + reportID: '9999', + merchant: '10.20 mi @ $1.00 / mi', + modifiedMerchant: '10.20 mi @ $1.00 / mi', + amount: -1020, + comment: { + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'rate123', + quantity: 10.2, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }; + + // When they are merged + const result = getMergeableDataAndConflictFields( + distanceExpense, + {...distanceExpense, transactionID: 'secondTransaction'}, + mockLocaleCompare, + mockGetCurrencyDecimals, + [], + excludingWorkspace, + excludingWorkspace, + ); + + // Then the whole distance is reimbursed, the same as creating the expense on that workspace would do + expect(result.conflictFields).toHaveLength(0); + expect(result.mergeableData).toMatchObject({ + amount: 1020, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'rate123', + quantity: 10.2, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }); + expect(result.mergeableData.customUnit).not.toHaveProperty('commuterExclusion'); + expect(result.mergeableData.customUnit).not.toHaveProperty('reimbursableDistance'); + }, + ); + + it('should clear an exclusion a manual distance expense still carries when it is merged', () => { + // Given two identical manual distance expenses that carry an exclusion from an earlier workspace, so that + // their amount pays for 9.2 of the 10.2 miles + const excludingWorkspace: Policy = { + ...createRandomPolicy(0, CONST.POLICY.TYPE.TEAM), + commuterExclusions: {method: CONST.POLICY.COMMUTER_EXCLUSION_METHOD.FIXED_DISTANCE, fixedDistance: 1, fixedDistanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }; + const distanceExpense = { + ...createRandomDistanceRequestTransaction(0), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL, + reportID: '9999', + merchant: '10.20 mi @ $1.00 / mi', + modifiedMerchant: '10.20 mi @ $1.00 / mi', + amount: -920, + comment: { + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'rate123', + quantity: 10.2, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + commuterExclusion: 1, + reimbursableDistance: 9.2, + }, + }, + }; + + // When they are merged + const result = getMergeableDataAndConflictFields( + distanceExpense, + {...distanceExpense, transactionID: 'secondTransaction'}, + mockLocaleCompare, + mockGetCurrencyDecimals, + [], + excludingWorkspace, + excludingWorkspace, + ); + + // Then the exclusion keys are nulled so Onyx removes them, and the amount pays for the whole distance again + expect(result.conflictFields).toHaveLength(0); + expect(result.mergeableData).toMatchObject({ + amount: 1020, + customUnit: { + name: CONST.CUSTOM_UNITS.NAME_DISTANCE, + customUnitRateID: 'rate123', + quantity: 10.2, + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + commuterExclusion: null, + reimbursableDistance: null, + }, + }); + }); + it('should merge amount field correctly when they are same', () => { const targetTransaction = { ...createRandomTransaction(1), From 3ab299ee2fa6e29e5037baa4ecf07160f6313e91 Mon Sep 17 00:00:00 2001 From: alberto Date: Tue, 25 Aug 2026 23:16:59 +0200 Subject: [PATCH 371/466] better tests --- src/libs/DistanceRequestUtils.ts | 2 +- .../ui/MergeTransactionDetailsReviewTest.tsx | 113 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index b7b06304a6e4..182382b77192 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -340,7 +340,7 @@ function getCommuterExclusionDisplayData(customUnit: TransactionCustomUnit | und /** * Whether a workspace's commuter exclusion applies to a distance expense of this request type. * - * Only a distance the app itself measured describes a route the workspace can recognise a commute in, so a manually + * Only a distance the app itself measured describes a route the workspace can recognize a commute in, so a manually * entered or odometer distance is reimbursed in full. */ function isCommuterExclusionApplicableToRequestType(iouRequestType: IOURequestType | undefined): boolean { diff --git a/tests/ui/MergeTransactionDetailsReviewTest.tsx b/tests/ui/MergeTransactionDetailsReviewTest.tsx index 7d0c6499dda3..6cb6ffdf72f2 100644 --- a/tests/ui/MergeTransactionDetailsReviewTest.tsx +++ b/tests/ui/MergeTransactionDetailsReviewTest.tsx @@ -4,6 +4,7 @@ import ComposeProviders from '@components/ComposeProviders'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import navigationRef from '@libs/Navigation/navigationRef'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; @@ -11,6 +12,7 @@ import DynamicConfirmationPage from '@pages/TransactionMerge/DynamicConfirmation import DynamicDetailsReviewPage from '@pages/TransactionMerge/DynamicDetailsReviewPage'; import CONST from '@src/CONST'; +import type {IOURequestType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {Policy, Report, Transaction} from '@src/types/onyx'; @@ -28,6 +30,15 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => '')); +// The auto-merge entry point navigates itself, which the test drives by rendering the destination page directly +jest.mock('@libs/Navigation/Navigation', () => { + const actualNavigation = jest.requireActual<{default: Record}>('@libs/Navigation/Navigation'); + return { + __esModule: true, + default: {...actualNavigation.default, navigate: jest.fn()}, + }; +}); + const Stack = createPlatformStackNavigator>(); // Expose each field row's description and title so the rendered distance can be read back @@ -43,6 +54,7 @@ jest.mock('@components/MenuItemWithTopDescription', () => { TestHelper.setupGlobalFetchMock(); const MERGE_TRANSACTION_ID = 'mergeDistanceTransaction'; +const DISTANCE_RATE_ID = 'distanceRateOfTheExcludingWorkspace'; const EXCLUDING_POLICY_ID = 'policyThatExcludesCommuterDistance'; const PLAIN_POLICY_ID = 'policyThatExcludesNothing'; const EXCLUDING_REPORT_ID = '4444'; @@ -192,3 +204,104 @@ describe('Merging distance expenses across workspaces', () => { expect(mergeTransaction?.amount).toBe(449); }); }); + +describe('Merging identical distance expenses without conflicts', () => { + // Identical expenses on the same report leave nothing to resolve, so the merge skips the details review page and + // builds the whole merge transaction in one pass + const buildIdenticalExpense = (iouRequestType: IOURequestType): Transaction => { + const expense = buildDistanceExpense('firstTransaction', EXCLUDING_REPORT_ID, '10.20 mi @ $1.00 / mi', 10.2); + return { + ...expense, + iouRequestType, + comment: {...expense.comment, customUnit: {...expense.comment?.customUnit, customUnitRateID: DISTANCE_RATE_ID}}, + }; + }; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + const setUpAndMerge = async (iouRequestType: IOURequestType) => { + // The second expense is a copy so that every field matches and the merge has nothing to resolve + const firstExpense = buildIdenticalExpense(iouRequestType); + const secondExpense = {...firstExpense, transactionID: 'secondTransaction'}; + const report = buildReport(EXCLUDING_REPORT_ID, EXCLUDING_POLICY_ID, 'Report that deducts'); + + await act(async () => { + await Onyx.clear(); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${EXCLUDING_POLICY_ID}`, excludingPolicy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${EXCLUDING_REPORT_ID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${firstExpense.transactionID}`, firstExpense); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${secondExpense.transactionID}`, secondExpense); + }); + await waitForBatchedUpdatesWithAct(); + + await act(async () => { + setupMergeTransactionDataAndNavigate( + MERGE_TRANSACTION_ID, + [firstExpense, secondExpense], + (a: string, b: string) => a.localeCompare(b), + () => 2, + [report], + false, + false, + [excludingPolicy, excludingPolicy], + ); + }); + await waitForBatchedUpdatesWithAct(); + + return getOnyxValue(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${MERGE_TRANSACTION_ID}`); + }; + + const renderConfirmationPage = async () => { + render( + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + }; + + it('carries the distance and rate through to the confirmation page alongside the exclusion', async () => { + // Given two identical map distance expenses on a report of a workspace that excludes 1 commuter mile + // When they are merged with nothing to resolve + const mergeTransaction = await setUpAndMerge(CONST.IOU.REQUEST_TYPE.DISTANCE_MAP); + + // Then the distance and rate reach the merge transaction rather than being replaced by the exclusion alone + expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); + expect(mergeTransaction?.customUnit?.customUnitRateID).toBe(DISTANCE_RATE_ID); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBe(1); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBe(9.2); + expect(mergeTransaction?.amount).toBe(920); + + // And the confirmation page renders the reimbursable distance against the distance it was deducted from + await renderConfirmationPage(); + // Matched loosely because the unit label reads as either "mi" or "miles", depending on the field's short form flag + expect(screen.getByTestId('field-Distance • Original: 10.20 mi')).toHaveTextContent(/^9\.20 (mi|miles)$/); + }); + + it('deducts nothing from a manually entered distance, which the workspace cannot recognize a commute in', async () => { + // Given two identical manual distance expenses on a report of a workspace that excludes 1 commuter mile + // When they are merged with nothing to resolve + const mergeTransaction = await setUpAndMerge(CONST.IOU.REQUEST_TYPE.DISTANCE_MANUAL); + + // Then the whole distance is reimbursed, the same as creating the expense on that workspace would do + expect(mergeTransaction?.customUnit?.quantity).toBe(10.2); + expect(mergeTransaction?.customUnit?.customUnitRateID).toBe(DISTANCE_RATE_ID); + expect(mergeTransaction?.customUnit?.commuterExclusion).toBeUndefined(); + expect(mergeTransaction?.customUnit?.reimbursableDistance).toBeUndefined(); + expect(mergeTransaction?.amount).toBe(1020); + + // And the confirmation page renders the whole distance, with no distance deducted from it + await renderConfirmationPage(); + expect(screen.getByTestId('field-Distance')).toHaveTextContent(/^10\.20 (mi|miles)$/); + }); +}); From 5efcf505eaa67fe1ae44986c9b9cfa574a17627f Mon Sep 17 00:00:00 2001 From: "Shawn Borton (via MelvinBot)" Date: Tue, 25 Aug 2026 21:43:29 +0000 Subject: [PATCH 372/466] Docs: document Submit to my employer option on tracked expense More menu Co-authored-by: Shawn Borton --- .../new-expensify/reports-and-expenses/Track-Expenses.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md index 3596a29e8288..ac3a04b64bb9 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Track-Expenses.md @@ -78,6 +78,10 @@ When you create a tracked expense, Concierge will show an actionable message wit - **Submit it to someone** – Send it for approval or reimbursement. - **Nothing for now** – Leave it stored for later. +You can also open the tracked expense and select **More** to submit it: + - **Send to someone** – Send the expense to another person to request payment. + - **Submit to my employer** – Submit the expense to a workspace for approval and reimbursement. If you do not have a workspace yet, one is created for you automatically. + If you belong to a workspace, you can also click into the expense to: - **Categorize** – Assign to a workspace category. - **Share with your accountant** – Give access to financial contacts. From b873a80c7a2c11ceb82ca180197c4769f4d57c5f Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Tue, 25 Aug 2026 21:49:29 +0000 Subject: [PATCH 373/466] Shorten containerStyle comment per review feedback Co-authored-by: Kevin Brian Bader --- src/pages/ReportSubmitToContent.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index 048242465b31..0049ac8d9ddc 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -458,10 +458,9 @@ function ReportSubmitToContent({ [handleSubmit, translate, keyboardActiveHeight, isInLandscapeMode, shouldUseNarrowLayout], ); - // The wrapper `View` in `useReportSubmitToPopover` owns the popover height, and this content fills it via `flex1`. - // Dropping the old `getMinimumHeight` floor lets the recipient list scroll inside a constant-size popover instead - // of resizing it on the empty / "No results found" state. No bottom padding is added here because `FixedFooter` - // already pads 20px below the Confirm button. + // `flex1` fills the popover height owned by the wrapper `View` in `useReportSubmitToPopover`, so the recipient list + // scrolls inside a constant-size popover rather than resizing it on the empty state. No bottom padding here because + // `FixedFooter` already pads below the Confirm button. const containerStyle = [styles.w100, styles.flex1, styles.pt3]; if (shouldShowNotFoundView) { From cd37f67fc7724e4e3a5f56946a71b890580f525d Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:36:36 +0700 Subject: [PATCH 374/466] update comments --- src/hooks/useDefaultExpensePolicy.tsx | 2 +- src/hooks/useOutstandingReports.ts | 2 +- src/libs/ReportUtils.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useDefaultExpensePolicy.tsx b/src/hooks/useDefaultExpensePolicy.tsx index 4c7c9ca2796f..2cf0ea1d88d5 100644 --- a/src/hooks/useDefaultExpensePolicy.tsx +++ b/src/hooks/useDefaultExpensePolicy.tsx @@ -49,7 +49,7 @@ export default function useDefaultExpensePolicy() { // Per-key lookup for the single group policy (only fires when that specific policy changes) const [singleGroupPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${singleGroupPolicyID}`); - // Teachers Unite doesn't support reimbursement, so it can never be the default destination for a new expense. + // Teachers Unite only supports expenses via split expense , so it can never be the default destination for a new expense. if (isRestrictedToPreferredPolicy && isGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login) && !isTeachersUnitePolicyID(preferredPolicy?.id)) { return preferredPolicy; } diff --git a/src/hooks/useOutstandingReports.ts b/src/hooks/useOutstandingReports.ts index 10b7febbbd07..67e9011aee53 100644 --- a/src/hooks/useOutstandingReports.ts +++ b/src/hooks/useOutstandingReports.ts @@ -29,7 +29,7 @@ export default function useOutstandingReports(selectedReportID: string | undefin if (shouldUseAllPolicies) { const result = []; for (const policyID of Object.values(allPoliciesID ?? {})) { - // Teachers Unite doesn't support reimbursement, so its reports can never be a move-expense destination. + // Teachers Unite only supports expenses via split expense, so its reports can never be a move-expense destination. if (!policyID || policyID === personalPolicyID || isTeachersUnitePolicyID(policyID)) { continue; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4a83bde65de1..4ab42beba22c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3241,7 +3241,7 @@ function getAddExpenseDropdownOptions({ const isReportTeachersUnite = isTeachersUnitePolicyID(getReportOrDraftReport(iouReportID)?.policyID ?? policy?.id); return [ - // Teachers Unite doesn't support reimbursement, so "Create expense" and "Track distance" are hidden for those reports. + // Teachers Unite only supports expenses via split expense ...(isReportTeachersUnite ? [] : [ From e83fa5e2e98995fbf6e7a24cc32ac40060a8260b Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:55:53 -1000 Subject: [PATCH 375/466] Update Set-distance-rates.md --- .../workspaces/Set-distance-rates.md | 75 +++++++++++-------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index e5e53be8fad6..bf16fdf172f4 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -1,12 +1,20 @@ --- title: Set Distance Rates -description: Set and manage distance rates for mileage reimbursement in your Expensify workspace. +description: Set and manage distance rates for distance reimbursement in your Expensify workspace. keywords: [New Expensify, distance rates, mileage reimbursement, enable distance rates, workspace settings, bulk edit rates] --- # Set Distance Rates -Distance rates allow your workspace to reimburse mileage-based travel. Admins can create and manage one or more rates that members can select when submitting distance expenses. +Distance rates determine how much members are reimbursed for distance-based travel. Workspace Admins can create multiple rates, control when each rate applies, configure taxes, and automatically update rates when supported government reimbursement rates change. + +--- + +## Who can use Distance rates + +Workspace Admins can configure Distance rates for a workspace. + +Automatic updates are available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. --- @@ -14,7 +22,7 @@ Distance rates allow your workspace to reimburse mileage-based travel. Admins ca To activate Distance rates for a workspace: -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace name]**. +1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [workspace name]**. 2. Click **More Features**. 3. Toggle **Distance rates** to enable the setting. @@ -24,7 +32,7 @@ Once enabled, **Distance Rates** will appear in the left menu. ## How to add Distance rates -1. Go to **Workspaces > [Workspace Name] > Distance Rates**. +1. Go to **Workspaces > [workspace Name] > Distance Rates**. 2. Click **Add Rate** in the top-right corner. 3. Enter the rate value (e.g., $0.585 per mile or km). 4. (Optional) Set a **Start date** and **End date** to define the period the rate applies to. Leave these blank if the rate has no time limit. @@ -34,9 +42,9 @@ When a rate has a **Start date** or **End date**, Expensify uses it to apply the --- -## How to edit Distance rate settings +## How to change a Distance rate, effective dates, or tax settings -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace name]**. +1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [workspace name]**. 2. Click **Distance rates**. 3. Click on the rate you want to edit. 4. Update any of the available settings: @@ -48,7 +56,7 @@ When a rate has a **Start date** or **End date**, Expensify uses it to apply the - Tax reclaimable amount 5. Click **Save**. -**Note:** If **Taxes** is enabled on the Workspace, each Distance rate can be associated with a tax rate and a tax reclaimable amount. The selected tax rate is automatically applied when the Distance rate is used on an expense. To track **Taxes** on a Workspace, [learn how to track Taxes](/articles/new-expensify/workspaces/Track-Taxes). +**Note:** If **Taxes** is enabled on the Workspace, each Distance rate can be associated with a tax rate and a tax reclaimable amount. The selected tax rate is automatically applied when the Distance rate is used on an expense. To enable tax tracking on a Workspace, [learn how to track Taxes](/articles/new-expensify/workspaces/Track-Taxes). --- @@ -59,7 +67,7 @@ To prevent members from using a Distance rate, you can either disable it or dele - Disabled rates remain in the Workspace but cannot be selected on new Distance expenses. - Deleted rates are permanently removed. -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace name]**. +1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [workspace name]**. 2. Click **Distance rates**. 3. Select each rate you want to disable or delete. 4. Click **Selected** @@ -67,28 +75,41 @@ To prevent members from using a Distance rate, you can either disable it or dele --- -## How to enable Auto-update government rate +## How to automatically update Distance rates when government rates change -Control workspaces can have Expensify keep the government mileage rate current automatically. When enabled, Expensify adds a new effective-dated rate whenever the government publishes an updated rate, so you don't have to update it manually each year. +Expensify can automatically update Distance rates in your Workspace when the standard government reimbursement rate changes, such as the IRS standard mileage rate in the United States or equivalent rates in supported countries. -Auto-update is available for workspaces using USD, CAD, GBP, AUD, or NZD. It is turned on by default for new workspaces and turned off by default for existing workspaces. +When **Auto-update government rate** is enabled, Expensify adds a new effective-dated Distance rate when a supported government publishes a new rate. This means admins don't need to manually update the standard rate each time it changes. -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [Workspace name]**. -2. Click **Distance rates**. -3. Turn on **Auto-update government rate**. +Automatic updates are available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. + +To enable automatic updates: + +1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [workspace name]**. +2. Select **Distance rates**. +3. Select **Settings**. +4. Enable **Auto-update government rate**. --- -## What happens when a government rate updates +## What happens when Expensify automatically updates a Distance rate -When Auto-update government rate is enabled and a new government rate is published: +When Auto-update government rate is enabled and a new supported government rate takes effect: - Expensify adds a new rate with a **Start date** matching the date the new rate takes effect. -- Previous rates are preserved so historical expenses keep the rate that applied on their expense date. -- If the previous government rate had no **End date**, Expensify sets its **End date** to the day before the new rate starts. -- Admins are notified of the change in the workspace **#admins** room and in **Home > Announcements**. +- Previous rates remain available so historical expenses can retain the rate that applied on their expense date. +- If the previous government rate does not have an End date, Expensify sets its End date to the day before the new rate takes effect. +- Workspace Admins are notified of the update in the workspace **#admins** room and in **Home > Announcements**. + +Workspace Admins can still edit or disable an automatically added rate. -You keep full control of your rates — you can edit or disable any auto-added rate at any time. +## How Expensify selects a Distance rate based on the expense date + +When a workspace has more than one Distance rate, Expensify uses effective dates to determine which rate applies to an expense. This allows a workspace to keep multiple rates active, such as a previous year's rate and a new rate. + + - When a member creates or edits a Distance expense, Expensify selects the rate whose **Start date** and **End date** include the expense date. + - If a member manually selects a rate that isn't valid for the expense date, the expense shows a violation indicating that the rate doesn't match the selected date. + - Setting effective dates is optional. Rates without a **Start date** or **End date** continue to apply without an effective-date restriction. --- @@ -124,16 +145,4 @@ Yes. When **Distance Rates** is enabled, the Workspace must always have at least ## Why isn't Auto-update government rate turned on for my workspace? -Auto-update government rate is turned on by default only for new workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for workspaces using USD, CAD, GBP, AUD, or NZD. - ---- - -## How Expensify Selects a Rate When Multiple Rates Exist - -When a Workspace has more than one distance rate, Expensify automatically applies the rate that matches the expense date. This lets you keep multiple rates active at once — for example, last year's mileage rate and this year's updated rate. - -- When a member creates or edits a Distance expense, Expensify selects the rate whose **Start date** and **End date** range includes the expense date. -- If a member manually selects a rate that isn't valid for the expense date, the expense shows a violation indicating the rate doesn't match the selected date. This is informational and does not block submission. - -**Note:** Setting effective dates is optional. If your rates don't have **Start date** or **End date** values, Expensify continues to apply rates as before. - +Auto-update government rate is turned on by default only for new workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. From 2b69aa8b7a565da2b8d1e079bbd9ceebbf235a8c Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:00:57 -1000 Subject: [PATCH 376/466] Add files via upload --- .../images/Distance_rates_automatic_update.png | Bin 0 -> 502768 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/assets/images/Distance_rates_automatic_update.png diff --git a/docs/assets/images/Distance_rates_automatic_update.png b/docs/assets/images/Distance_rates_automatic_update.png new file mode 100644 index 0000000000000000000000000000000000000000..e4e07b6f767cf7cde6a615548d8d2fa19802416a GIT binary patch literal 502768 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 z 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

vfwTeDHi)8 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 z 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

?tx2f0ag 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

W

&N+7 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 O%!rsh_1F~MrvCxsdj_Zg literal 0 HcmV?d00001 From 3331dee4caec4ed76423990900a2e593f948f7a3 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:02:11 -1000 Subject: [PATCH 377/466] Update Set-distance-rates.md with image and clarification Added an image to illustrate automatically created government rates and clarified how Expensify selects a Distance rate based on the expense date. --- docs/articles/new-expensify/workspaces/Set-distance-rates.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index bf16fdf172f4..e7c66ec87867 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -103,6 +103,10 @@ When Auto-update government rate is enabled and a new supported government rate Workspace Admins can still edit or disable an automatically added rate. +![Distance rates showing automatically created government rates]({{site.url}}/assets/images/Distance_rates_automatic_update.png){:width="100%"} + +--- + ## How Expensify selects a Distance rate based on the expense date When a workspace has more than one Distance rate, Expensify uses effective dates to determine which rate applies to an expense. This allows a workspace to keep multiple rates active, such as a previous year's rate and a new rate. From 8c7846cdad3ffd99b8efd8c9d41cb5467088dc4e Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:13:22 -1000 Subject: [PATCH 378/466] Update Set-distance-rates.md --- .../new-expensify/workspaces/Set-distance-rates.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index e7c66ec87867..0bdbe6ac1768 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -1,7 +1,8 @@ --- title: Set Distance Rates description: Set and manage distance rates for distance reimbursement in your Expensify workspace. -keywords: [New Expensify, distance rates, mileage reimbursement, enable distance rates, workspace settings, bulk edit rates] +keywords: [New Expensify, distance rates, mileage reimbursement, enable distance rates, workspace settings, bulk edit rates, auto-update government rate, IRS standard rate, mileage rates, distance bands] +internalScope: Audience is Workspace Admins. Covers configuring and auto-updating Distance rates, does not cover creating distance expenses. --- # Set Distance Rates @@ -14,7 +15,7 @@ Distance rates determine how much members are reimbursed for distance-based trav Workspace Admins can configure Distance rates for a workspace. -Automatic updates are available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. +Automatic updates are available for Collect workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. --- @@ -38,7 +39,7 @@ Once enabled, **Distance Rates** will appear in the left menu. 4. (Optional) Set a **Start date** and **End date** to define the period the rate applies to. Leave these blank if the rate has no time limit. 5. Click **Save**. -When a rate has a **Start date** or **End date**, Expensify uses it to apply the correct rate based on the expense date. See [How Expensify selects a rate when multiple rates exist](#how-expensify-selects-a-rate-when-multiple-rates-exist) below. +When a rate has a **Start date** or **End date**, Expensify uses it to apply the correct rate based on the expense date. See [How Expensify selects a rate when multiple rates exist](#How-Expensify-selects-a-Distance-rate-based-on-the-expense-date) below. --- From 78a4e05356f8b32664c585967e7c9fc8da9fac50 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:16:37 -1000 Subject: [PATCH 379/466] Update Set-distance-rates.md --- docs/articles/new-expensify/workspaces/Set-distance-rates.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index 0bdbe6ac1768..47c2290dbae3 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -11,7 +11,7 @@ Distance rates determine how much members are reimbursed for distance-based trav --- -## Who can use Distance rates +## Who can set Distance rates Workspace Admins can configure Distance rates for a workspace. @@ -19,7 +19,7 @@ Automatic updates are available for Collect workspaces with a default currency o --- -## How to enable Distance rates +## How to enable Distance rates for a workspace To activate Distance rates for a workspace: From 13dd90e2358a69895fbf5420d0513ccfbdd47c67 Mon Sep 17 00:00:00 2001 From: "Nyoman Jyotisa (via MelvinBot)" Date: Wed, 26 Aug 2026 07:05:07 +0000 Subject: [PATCH 380/466] Remove em dash from test comment per CONSISTENCY-16 Co-authored-by: Nyoman Jyotisa --- tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx b/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx index aad9fb85e4d6..9335055097ba 100644 --- a/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx +++ b/tests/ui/WorkspaceTravelBillingMonthlyLimitPageTest.tsx @@ -31,7 +31,7 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' const POLICY_ID = 'testPolicy123'; const FUND_ID = 999888; // Must match the literal returned by the useDefaultFundID mock below. const CURRENT_LIMIT_CENTS = 10000; // $100.00 current monthly limit. -const REDUCED_LIMIT_CENTS = 5000; // $50.00 — a lower value that triggers the "Reduce limit" confirmation. +const REDUCED_LIMIT_CENTS = 5000; // $50.00, a lower value that triggers the "Reduce limit" confirmation. // jest.mock() factories are hoisted, so they can only reference literal values (not the constants above). jest.mock('@hooks/useDefaultFundID', () => ({ From 1219ffba14ae67d634e43f8a830934447eb2039e Mon Sep 17 00:00:00 2001 From: alberto Date: Wed, 26 Aug 2026 09:41:32 +0200 Subject: [PATCH 381/466] parrot --- src/languages/de.ts | 22 +++++++++++----------- src/languages/el.ts | 18 +++++++++--------- src/languages/es.ts | 21 ++++++++++----------- src/languages/fr.ts | 15 +++++++-------- src/languages/it.ts | 12 ++++++------ src/languages/ja.ts | 18 +++++++++--------- src/languages/nl.ts | 18 +++++++++--------- src/languages/pl.ts | 22 +++++++++++----------- src/languages/pt-BR.ts | 12 ++++++------ src/languages/zh-hans.ts | 22 +++++++++++----------- 10 files changed, 89 insertions(+), 91 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index b5ec8f7e7581..5456988f5223 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -979,7 +979,7 @@ const translations: TranslationDeepObject = { subtitle: 'Wallet', }, validateAccount: {title: 'Bestätigen Sie Ihr Konto', subtitle: 'Konto', cta: 'Bestätigen'}, - addHomeAddress: {title: 'Fügen Sie Ihre Privatadresse für die Entfernungserfassung hinzu', subtitle: 'Konto', cta: 'Adresse hinzufügen'}, + addHomeAddress: {title: 'Fügen Sie Ihre Privatadresse zur Entfernungserfassung hinzu', subtitle: 'Konto', cta: 'Adresse hinzufügen'}, fixFailedBilling: {title: 'Wir konnten Ihre hinterlegte Karte nicht belasten', subtitle: 'Abonnement'}, unlockBankAccount: { workspaceTitle: 'Ihr Geschäftskonto wurde gesperrt', @@ -1294,12 +1294,12 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Die Wohnadresse ist erforderlich', + title: 'Privatadresse ist erforderlich', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Fahrtkostenermäßigungen.` - : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. Dieser Workspace verwendet diese Adresse für Pendlerabzüge.', - cta: 'Privatadresse hinzufügen', + ? `Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse zu Ihrem privaten Profil hinzufügen. ${workspaceName} verwendet diese Adresse für Pendlerabzüge.` + : 'Bevor Sie Entfernungen erfassen, müssen Sie Ihre Privatadresse in Ihrem privaten Profil hinzufügen. Dieser Arbeitsbereich verwendet diese Adresse für Pendlerabzüge.', + cta: 'Heimatadresse hinzufügen', }, amount: 'Betrag', percent: 'Prozent', @@ -7269,16 +7269,16 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, summaryDisabled: 'Kein Ausschluss von Pendelstrecken', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `${distance} ${unit} pro Abrechnung ausschließen`, optionDisabledTitle: 'Pendelfahrten nicht ausschließen', - optionDisabledHelp: 'Kein Arbeitsweg wird aus Abrechnungen entfernt.', + optionDisabledHelp: 'Keine Pendelfahrt wird von Anträgen abgezogen.', optionFixedDistanceTitle: 'Eine feste Entfernung pro Antrag ausschließen', optionFixedDistanceHelp: 'Ziehen Sie die gleiche Pendelstrecke von jeder Abrechnung ab. Am besten für Mitglieder geeignet, die eine Abrechnung pro Arbeitstag einreichen.', distanceLabel: 'Entfernung', - summaryHomeAndOffice: 'Standortangaben für Zuhause und Büro verwenden', - optionHomeAndOfficeTitle: 'Nach Zuhause und Büro berechnen', - optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuordnung des Mitglieds, um Pendelausschlüsse zu berechnen.', + summaryHomeAndOffice: 'Standort für Zuhause und Büro verwenden', + optionHomeAndOfficeTitle: 'Nach Wohnsitz und Büro berechnen', + optionHomeAndOfficeHelp: 'Verwenden Sie die Privatadresse, die Arbeitsregelung und die Bürozuweisung der betreffenden Person, um Pendlerausschlüsse zu berechnen.', workspaceAddressRequired: { - title: 'Nur nicht so schnell …', - promptStart: 'Sie können die Einstellung „Berechnung nach Zuhause und Büro“ nicht aktivieren, bevor Sie zuerst einen Bürostandort hinzugefügt haben in', + title: 'Nicht so schnell …', + promptStart: 'Sie können die Einstellung „Nach Zuhause und Büro berechnen“ erst aktivieren, nachdem Sie zunächst einen Bürostandort hinzugefügt haben in', linkText: 'Übersicht', promptEnd: ' hinzugefügt haben.', cta: 'Verstanden', diff --git a/src/languages/el.ts b/src/languages/el.ts index 960c1ba4863b..157a6153f375 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1020,7 +1020,7 @@ const translations: TranslationDeepObject = { subtitle: 'Λογαριασμός', cta: 'Επικυρώστε', }, - addHomeAddress: {title: 'Προσθέστε τη διεύθυνση κατοικίας σας για παρακολούθηση αποστάσεων', subtitle: 'Λογαριασμός', cta: 'Προσθέστε διεύθυνση'}, + addHomeAddress: {title: 'Προσθέστε τη διεύθυνση κατοικίας σας για παρακολούθηση αποστάσεων', subtitle: 'Λογαριασμός', cta: 'Προσθήκη διεύθυνσης'}, fixFailedBilling: { title: 'Δεν μπορέσαμε να χρεώσουμε την αποθηκευμένη κάρτα σας', subtitle: 'Συνδρομή', @@ -1342,12 +1342,12 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Η διεύθυνση κατοικίας είναι υποχρεωτική', + title: 'Απαιτείται η διεύθυνση κατοικίας σας', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName ? `Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Το ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για εκπτώσεις μετακίνησης.` - : 'Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για εκπτώσεις μετακίνησης προς και από την εργασία.', - cta: 'Προσθήκη διεύθυνσης κατοικίας', + : 'Πριν καταγράψετε αποστάσεις, πρέπει να προσθέσετε τη διεύθυνση κατοικίας σας στο ιδιωτικό προφίλ σας. Αυτός ο χώρος εργασίας χρησιμοποιεί αυτή τη διεύθυνση για εκπτώσεις μετακίνησης.', + cta: 'Προσθήκη οικιακής διεύθυνσης', }, amount: 'Ποσό', percent: 'Ποσοστό', @@ -3688,7 +3688,7 @@ ${amount} για ${merchant} - ${date}`, legalFirstName: 'Επίσημο μικρό όνομα', legalLastName: 'Επώνυμο (όπως αναγράφεται στα επίσημα έγγραφα)', address: 'Οικιακή διεύθυνση', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `Το ${workspaceName} χρησιμοποιεί αυτήν τη διεύθυνση για εξαιρέσεις μετακίνησης.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `Το ${workspaceName} χρησιμοποιεί αυτή τη διεύθυνση για εξαιρέσεις μετακίνησης.`, error: { dateShouldBeBefore: (dateString: string) => `Η ημερομηνία πρέπει να είναι πριν από ${dateString}`, dateShouldBeAfter: (dateString: string) => `Η ημερομηνία πρέπει να είναι μετά από ${dateString}`, @@ -7513,18 +7513,18 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Εξαίρεση ${distance} ${unit} ανά αίτημα`, summaryHomeAndOffice: 'Χρησιμοποιήστε τις τοποθεσίες σπιτιού και γραφείου', optionDisabledTitle: 'Να μην εξαιρούνται οι μετακινήσεις από και προς την εργασία', - optionDisabledHelp: 'Δεν έχει αφαιρεθεί καμία μετακίνηση από τις αιτήσεις.', + optionDisabledHelp: 'Καμία μετακίνηση από και προς την εργασία δεν αφαιρείται από τις αιτήσεις.', optionFixedDistanceTitle: 'Εξαίρεση σταθερής απόστασης ανά αίτημα', optionFixedDistanceHelp: 'Αφαιρέστε την ίδια απόσταση μετακίνησης από κάθε αίτημα. Ιδανικό για μέλη που υποβάλλουν ένα αίτημα ανά εργάσιμη ημέρα.', optionHomeAndOfficeTitle: 'Υπολογισμός ανά σπίτι και γραφείο', - optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας του μέλους, τη ρύθμιση εργασίας και την ανάθεση γραφείου για τον υπολογισμό των εξαιρέσεων μετακίνησης.', + optionHomeAndOfficeHelp: 'Χρησιμοποιήστε τη διεύθυνση κατοικίας, τη ρύθμιση εργασίας και την ανάθεση γραφείου του μέλους για να υπολογίσετε τις εξαιρέσεις μετακίνησης.', distanceLabel: 'Απόσταση', workspaceAddressRequired: { title: 'Όχι τόσο γρήγορα...', - promptStart: 'Δεν μπορείτε να ενεργοποιήσετε τη ρύθμιση υπολογισμού με βάση το σπίτι και το γραφείο μέχρι πρώτα να προσθέσετε τοποθεσία γραφείου στο', + promptStart: 'Δεν μπορείτε να ενεργοποιήσετε τη ρύθμιση υπολογισμού ανά σπίτι και γραφείο μέχρι να προσθέσετε πρώτα μια τοποθεσία γραφείου στο', linkText: 'Επισκόπηση', promptEnd: '.', - cta: 'Το κατάλαβα', + cta: 'Εντάξει', }, errors: { distanceMustBePositive: 'Η απόσταση πρέπει να είναι ένας θετικός ακέραιος αριθμός.', diff --git a/src/languages/es.ts b/src/languages/es.ts index c246d68823ca..4885cba60acb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9,7 +9,6 @@ * - Improve the prompts in prompts/translation, or * - Improve context annotations in src/languages/en.ts */ - import CONST from '@src/CONST'; import type {OriginalMessageReportPreview, OriginalMessageSettlementAccountLocked, PersonalRulesModifiedFields, PolicyRulesModifiedFields} from '@src/types/onyx/OriginalMessage'; @@ -975,7 +974,7 @@ const translations: TranslationDeepObject = { subtitle: 'Cuenta', cta: 'Validar', }, - addHomeAddress: {title: 'Añade tu dirección de casa para el seguimiento de distancias', subtitle: 'Cuenta', cta: 'Añadir dirección'}, + addHomeAddress: {title: 'Añade tu dirección de casa para el seguimiento de distancia', subtitle: 'Cuenta', cta: 'Añadir dirección'}, fixFailedBilling: { title: 'No pudimos cobrar a la tarjeta registrada.', subtitle: 'Suscripción', @@ -1291,11 +1290,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'La dirección particular es obligatoria', + title: 'La dirección de domicilio es obligatoria', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. ${workspaceName} usa esta dirección para las deducciones por desplazamiento.` - : 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo usa esa dirección para las deducciones por desplazamientos al trabajo.', + ? `Antes de organizar distancias, tienes que añadir tu dirección de casa a tu perfil privado. ${workspaceName} usa esta dirección para las deducciones por desplazamientos al trabajo.` + : 'Antes de registrar distancias, tienes que añadir tu dirección de casa a tu perfil privado. Este espacio de trabajo utiliza esta dirección para las deducciones por desplazamientos al trabajo.', cta: 'Añadir dirección de domicilio', }, amount: 'Importe', @@ -3552,8 +3551,8 @@ ${amount} para ${merchant} - ${date}`, legalName: 'Nombre completo', legalFirstName: 'Nombre legal', legalLastName: 'Apellidos legales', - address: 'Dirección de casa', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utiliza esta dirección para las exclusiones de desplazamientos.`, + address: 'Dirección particular', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa esta dirección para las exclusiones de desplazamientos al trabajo.`, error: { dateShouldBeBefore: (dateString) => `La fecha debe ser anterior a ${dateString}`, dateShouldBeAfter: (dateString) => `La fecha debe ser posterior a ${dateString}`, @@ -7242,16 +7241,16 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, title: 'Excluir desplazamientos al trabajo', summaryDisabled: 'Sin exclusión por desplazamiento al trabajo', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Excluir ${distance} ${unit} por reclamación`, - summaryHomeAndOffice: 'Usar ubicaciones de casa y oficina', + summaryHomeAndOffice: 'Usa las ubicaciones de casa y oficina', optionDisabledTitle: 'No excluir los desplazamientos al trabajo', - optionDisabledHelp: 'Ningún desplazamiento al trabajo se elimina de las reclamaciones.', + optionDisabledHelp: 'No se ha eliminado ningún trayecto al trabajo de las reclamaciones.', optionFixedDistanceTitle: 'Excluir una distancia fija por reclamación', optionFixedDistanceHelp: 'Resta la misma distancia de desplazamiento de cada solicitud. Ideal para personas que envían una solicitud por día laborable.', optionHomeAndOfficeTitle: 'Calcular por casa y oficina', - optionHomeAndOfficeHelp: 'Usa la dirección de casa del miembro, su modalidad de trabajo y su asignación de oficina para calcular las exclusiones de desplazamiento.', + optionHomeAndOfficeHelp: 'Utiliza la dirección de casa del miembro, su modalidad de trabajo y su asignación de oficina para calcular las exclusiones de desplazamiento.', distanceLabel: 'Distancia', workspaceAddressRequired: { - title: 'No tan deprisa...', + title: 'No tan rápido...', promptStart: 'No puedes habilitar la configuración de cálculo por casa y oficina hasta que primero añadas una ubicación de oficina en', linkText: 'Resumen', promptEnd: '.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a28deb2cefc5..af28e0f8b6f5 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1298,10 +1298,10 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'L’adresse domicile est obligatoire', + title: 'L’adresse du domicile est obligatoire', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions liées aux trajets domicile-travail.` + ? `Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. ${workspaceName} utilise cette adresse pour les déductions de navette.` : 'Avant de suivre une distance, vous devez ajouter votre adresse personnelle à votre profil privé. Cet espace de travail utilise cette adresse pour les déductions liées aux trajets domicile-travail.', cta: 'Ajouter une adresse domicile', }, @@ -3648,8 +3648,8 @@ ${amount} pour ${merchant} - ${date}`, legalName: 'Nom légal', legalFirstName: 'Prénom légal', legalLastName: 'Nom de famille légal', - address: 'Adresse personnelle', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilise cette adresse pour les exclusions liées aux trajets domicile-travail.`, + address: 'Adresse du domicile', + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilise cette adresse pour les exclusions de déplacements domicile-travail.`, error: { dateShouldBeBefore: (dateString: string) => `La date doit être antérieure au ${dateString}`, dateShouldBeAfter: (dateString: string) => `La date doit être postérieure au ${dateString}`, @@ -7296,13 +7296,12 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, optionFixedDistanceTitle: 'Exclure une distance fixe par demande', optionFixedDistanceHelp: 'Soustraire la même distance de trajet domicile-travail de chaque demande. Idéal pour les membres qui soumettent une demande par jour de travail.', distanceLabel: 'Distance', - summaryHomeAndOffice: 'Utiliser les adresses domicile et bureau', + summaryHomeAndOffice: 'Utiliser les emplacements domicile et bureau', optionHomeAndOfficeTitle: 'Calculer par domicile et bureau', - optionHomeAndOfficeHelp: - 'Utiliser l’adresse personnelle du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet domicile-travail.', + optionHomeAndOfficeHelp: 'Utiliser l’adresse personnelle du membre, son mode de travail et son affectation de bureau pour calculer les exclusions de trajet.', workspaceAddressRequired: { title: 'Pas si vite...', - promptStart: 'Vous ne pouvez pas activer le paramètre de calcul par domicile et bureau tant que vous n’avez pas d’abord ajouté une adresse de bureau dans', + promptStart: 'Vous ne pouvez pas activer le paramètre de calcul par domicile et bureau avant d’ajouter d’abord un lieu de travail dans', linkText: 'Aperçu', promptEnd: '.', cta: 'Compris', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6b2131a97f68..0ee3193ba68a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -980,7 +980,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portafoglio', }, validateAccount: {title: 'Conferma il tuo account', subtitle: 'Account', cta: 'Conferma'}, - addHomeAddress: {title: 'Aggiungi il tuo indirizzo di casa per il monitoraggio delle distanze', subtitle: 'Account', cta: 'Aggiungi indirizzo'}, + addHomeAddress: {title: 'Aggiungi il tuo indirizzo di casa per il tracciamento delle distanze', subtitle: 'Account', cta: 'Aggiungi indirizzo'}, fixFailedBilling: {title: 'Non abbiamo potuto addebitare la carta salvata nel profilo', subtitle: 'Abbonamento'}, unlockBankAccount: { workspaceTitle: 'Il conto bancario della tua azienda è stato bloccato', @@ -1296,8 +1296,8 @@ const translations: TranslationDeepObject = { title: "L'indirizzo di casa è obbligatorio", prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Prima di registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le detrazioni per i pendolari.` - : 'Prima di poter registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro usa questo indirizzo per le detrazioni per pendolarismo.', + ? `Prima di registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. ${workspaceName} usa questo indirizzo per le detrazioni per pendolarismo.` + : 'Prima di poter registrare la distanza, devi aggiungere il tuo indirizzo di casa al tuo profilo privato. Questo spazio di lavoro utilizza questo indirizzo per le detrazioni per i pendolari.', cta: 'Aggiungi indirizzo di casa', }, amount: 'Importo', @@ -3629,7 +3629,7 @@ ${amount} per ${merchant} - ${date}`, legalFirstName: 'Nome legale di battesimo', legalLastName: 'Cognome legale', address: 'Indirizzo di casa', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa questo indirizzo per le esclusioni per pendolari.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} utilizza questo indirizzo per le esclusioni pendolari.`, error: { dateShouldBeBefore: (dateString: string) => `La data deve essere precedente a ${dateString}`, dateShouldBeAfter: (dateString: string) => `La data deve essere successiva a ${dateString}`, @@ -7244,13 +7244,13 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, distanceLabel: 'Distanza', summaryHomeAndOffice: 'Usa gli indirizzi di casa e ufficio', optionHomeAndOfficeTitle: 'Calcola per casa e ufficio', - optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, la modalità di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni per il tragitto casa-lavoro.', + optionHomeAndOfficeHelp: 'Usa l’indirizzo di casa del membro, la modalità di lavoro e l’assegnazione all’ufficio per calcolare le esclusioni sul tragitto casa‑lavoro.', workspaceAddressRequired: { title: 'Non così in fretta...', promptStart: 'Non puoi abilitare l’impostazione di calcolo per casa e ufficio finché non aggiungi prima una sede dell’ufficio in', linkText: 'Panoramica', promptEnd: '.', - cta: 'Capito', + cta: 'Ricevuto', }, errors: { distanceMustBePositive: 'La distanza deve essere un numero intero positivo.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 60a339334168..0b15a6342152 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -967,7 +967,7 @@ const translations: TranslationDeepObject = { }, fixPersonalCardConnection: {title: ({cardName}: {cardName?: string}) => (cardName ? `${cardName}個人カードの接続を修正` : '個人カードの連携を修正'), subtitle: 'ウォレット'}, validateAccount: {title: 'アカウントを認証してください', subtitle: 'アカウント', cta: '検証する'}, - addHomeAddress: {title: '距離の追跡のために自宅住所を追加してください', subtitle: 'アカウント', cta: '住所を追加'}, + addHomeAddress: {title: '距離の追跡用に自宅住所を追加してください', subtitle: 'アカウント', cta: '住所を追加'}, fixFailedBilling: {title: '登録されているカードから請求できませんでした', subtitle: 'サブスクリプション'}, unlockBankAccount: { workspaceTitle: 'ビジネス用銀行口座がロックされました', @@ -1278,8 +1278,8 @@ const translations: TranslationDeepObject = { title: '自宅住所は必須です', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `距離を記録する前に、まず非公開プロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除のためにこの住所を使用します。` - : '距離を記録する前に、まず自分のホームアドレスを非公開プロフィールに追加する必要があります。このワークスペースでは、その住所を通勤控除に使用します。', + ? `距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。${workspaceName} は通勤控除のためにこの住所を使用します。` + : '距離を記録する前に、プライベートプロフィールに自宅住所を追加する必要があります。このワークスペースでは、その住所を通勤控除に使用します。', cta: '自宅住所を追加', }, amount: '金額', @@ -3593,7 +3593,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' legalFirstName: '法的な名', legalLastName: '法的な姓', address: '自宅住所', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} は通勤手当の除外のためにこの住所を使用します。`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} は通勤手当の除外にこの住所を使用します。`, error: { dateShouldBeBefore: (dateString: string) => `日付は${dateString}より前である必要があります`, dateShouldBeAfter: (dateString: string) => `日付は${dateString}より後の日付にしてください`, @@ -7147,19 +7147,19 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から summaryDisabled: '通勤除外なし', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `申請ごとに ${distance} ${unit} を除外します`, optionDisabledTitle: '通勤を除外しない', - optionDisabledHelp: '通勤分は一切申請から除外されていません。', + optionDisabledHelp: '通勤分は精算申請から除外されていません。', optionFixedDistanceTitle: '申請ごとに一定距離を除外します', optionFixedDistanceHelp: '各申請から同じ通勤距離を差し引きます。1勤務日につき1件の申請を行うメンバーに最適です。', distanceLabel: '距離', summaryHomeAndOffice: '自宅と勤務先の所在地を使用する', - optionHomeAndOfficeTitle: '自宅とオフィスで計算', - optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィスの割り当て情報を使用して、通勤に関する除外額を計算します。', + optionHomeAndOfficeTitle: '自宅とオフィスで按分計算します', + optionHomeAndOfficeHelp: 'メンバーの自宅住所、勤務形態、オフィス配属先を使用して、通勤の除外額を計算します。', workspaceAddressRequired: { - title: 'ちょっと待ってください…', + title: 'ちょっとお待ちください…', promptStart: '自宅とオフィスで計算する設定は、先にオフィスの所在地を追加しないと有効にできません', linkText: '概要', promptEnd: 'でオフィスの住所を追加してください。', - cta: '了解しました', + cta: '了解です', }, errors: {distanceMustBePositive: '距離は正の整数で入力してください。', invalidAddress: '有効な住所を入力してください', distanceTooLarge: '距離が大きすぎます。'}, }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 83c1bbe1c7c9..b69f6772de52 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1292,11 +1292,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Huisadres is verplicht', + title: 'Woonadres is verplicht', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Voordat je de afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werkaftrek.` - : 'Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. Deze workspace gebruikt dit adres voor woon-werk aftrekposten.', + ? `Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. ${workspaceName} gebruikt dit adres voor woon-werk aftrek.` + : 'Voordat je afstand kunt bijhouden, moet je je huisadres toevoegen aan je privéprofiel. Deze werkruimte gebruikt dit adres voor woon-werkverkeeraftrek.', cta: 'Thuisadres toevoegen', }, amount: 'Bedrag', @@ -3628,7 +3628,7 @@ ${amount} voor ${merchant} - ${date}`, legalName: 'Wettelijke naam', legalFirstName: 'Juridische voornaam', legalLastName: 'Wettelijke achternaam', - address: 'Huisadres', + address: 'Adres thuis', commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} gebruikt dit adres voor woon-werkuitzonderingen.`, error: { dateShouldBeBefore: (dateString: string) => `Datum moet vóór ${dateString} zijn`, @@ -7222,16 +7222,16 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, summaryDisabled: 'Geen woon-werkuitzondering', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Sluit ${distance} ${unit} per declaratie uit`, optionDisabledTitle: 'Woon-werkverkeer niet uitsluiten', - optionDisabledHelp: 'Er wordt geen woon-werkverkeer uit declaraties verwijderd.', + optionDisabledHelp: 'Er is geen woon-werkverkeer uit declaraties verwijderd.', optionFixedDistanceTitle: 'Een vaste afstand per declaratie uitsluiten', optionFixedDistanceHelp: 'Trek dezelfde woon-werkafstand van elke declaratie af. Het meest geschikt voor leden die één declaratie per werkdag indienen.', distanceLabel: 'Afstand', - summaryHomeAndOffice: 'Thuis- en kantoorlocaties gebruiken', - optionHomeAndOfficeTitle: 'Bereken per thuis en kantoor', - optionHomeAndOfficeHelp: 'Gebruik het woonadres van het lid, de werkafspraak en de kantoorindeling om woon-werkuitsluitingen te berekenen.', + summaryHomeAndOffice: 'Gebruik thuis- en kantoorlocaties', + optionHomeAndOfficeTitle: 'Berekenen per thuis en kantoor', + optionHomeAndOfficeHelp: 'Gebruik het thuisadres van het lid, de werkregeling en de kantoorindeling om woon-werkuitzonderingen te berekenen.', workspaceAddressRequired: { title: 'Niet zo snel...', - promptStart: 'Je kunt de instelling ‘berekenen op basis van thuis en kantoor’ pas inschakelen als je eerst een kantoorlocatie hebt toegevoegd in', + promptStart: 'Je kunt de instelling ‘berekenen op basis van thuis en kantoor’ pas inschakelen nadat je eerst een kantoorlocatie hebt toegevoegd in', linkText: 'Overzicht', promptEnd: '.', cta: 'Begrepen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 98ea5a08d0cf..839253e2e087 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -986,7 +986,7 @@ const translations: TranslationDeepObject = { subtitle: 'Portfel', }, validateAccount: {title: 'Zweryfikuj swoje konto', subtitle: 'Konto', cta: 'Zatwierdź'}, - addHomeAddress: {title: 'Dodaj swój adres domowy do śledzenia dystansu', subtitle: 'Konto', cta: 'Dodaj adres'}, + addHomeAddress: {title: 'Dodaj swój adres domowy do śledzenia odległości', subtitle: 'Konto', cta: 'Dodaj adres'}, fixFailedBilling: {title: 'Nie mogliśmy obciążyć zapisanej karty', subtitle: 'Subskrypcja'}, unlockBankAccount: { workspaceTitle: 'Twoje firmowe konto bankowe zostało zablokowane', @@ -1321,11 +1321,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: 'Adres domowy jest wymagany', + title: 'Adres zamieszkania jest wymagany', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy do pracy.` - : 'Zanim zaczniesz śledzić dystans, dodaj swój adres domowy do prywatnego profilu. To miejsce pracy użyje tego adresu do odliczeń za dojazdy.', + ? `Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. ${workspaceName} używa tego adresu do odliczeń za dojazdy.` + : 'Zanim zaczniesz śledzić dystans, musisz dodać swój adres domowy do prywatnego profilu. To miejsce pracy używa tego adresu do odliczeń dojazdów.', cta: 'Dodaj adres domowy', }, amount: 'Kwota', @@ -3657,7 +3657,7 @@ ${amount} dla ${merchant} - ${date}`, legalFirstName: 'Imię (zgodnie z dokumentem tożsamości)', legalLastName: 'Nazwisko zgodne z dokumentami', address: 'Adres domowy', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} używa tego adresu do wykluczeń dojazdów do pracy.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} używa tego adresu do wyłączeń dla dojeżdżających do pracy.`, error: { dateShouldBeBefore: (dateString: string) => `Data powinna być wcześniejsza niż ${dateString}`, dateShouldBeAfter: (dateString: string) => `Data powinna być po ${dateString}`, @@ -7243,19 +7243,19 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, summaryDisabled: 'Bez wykluczenia dojazdów', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `Wyklucz ${distance} ${unit} na zgłoszenie`, optionDisabledTitle: 'Nie wykluczaj dojazdów', - optionDisabledHelp: 'Z żadnego rozliczenia nie usunięto dojazdu do pracy.', + optionDisabledHelp: 'Żaden dojazd do pracy nie jest usuwany z rozliczeń.', optionFixedDistanceTitle: 'Wyklucz stały dystans na każde rozliczenie', optionFixedDistanceHelp: 'Odejmij tę samą odległość dojazdu od każdego rozliczenia. Najlepsze dla osób, które składają jedno rozliczenie na każdy dzień pracy.', distanceLabel: 'Dystans', summaryHomeAndOffice: 'Użyj lokalizacji domu i biura', - optionHomeAndOfficeTitle: 'Oblicz według domu i biura', - optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy oraz przypisanego biura, aby obliczyć wyłączenia z dojazdów.', + optionHomeAndOfficeTitle: 'Obliczaj według domu i biura', + optionHomeAndOfficeHelp: 'Użyj adresu domowego członka, jego modelu pracy i przypisania do biura, żeby obliczyć wyłączenia z dojazdów.', workspaceAddressRequired: { - title: 'Nie tak prędko…', - promptStart: 'Nie możesz włączyć ustawienia „obliczaj według domu i biura”, dopóki najpierw nie dodasz lokalizacji biura w', + title: 'Nie tak szybko…', + promptStart: 'Nie możesz włączyć ustawienia obliczania według domu i biura, dopóki najpierw nie dodasz lokalizacji biura w', linkText: 'Przegląd', promptEnd: '.', - cta: 'Rozumiem', + cta: 'Jasne', }, errors: {distanceMustBePositive: 'Dystans musi być dodatnią liczbą całkowitą.', invalidAddress: 'Wpisz prawidłowy adres', distanceTooLarge: 'Odległość jest zbyt duża.'}, }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e5e7b71adb67..e10b5bd0a6c5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1295,8 +1295,8 @@ const translations: TranslationDeepObject = { title: 'Endereço residencial é obrigatório', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `Antes de registrar distâncias, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` - : 'Antes de registrar distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.', + ? `Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. ${workspaceName} usa esse endereço para deduções de deslocamento.` + : 'Antes de registrar a distância, você precisa adicionar seu endereço residencial ao seu perfil privado. Este workspace usa esse endereço para deduções de deslocamento.', cta: 'Adicionar endereço residencial', }, amount: 'Valor', @@ -3619,7 +3619,7 @@ ${amount} para ${merchant} - ${date}`, legalFirstName: 'Primeiro nome legal', legalLastName: 'Sobrenome legal', address: 'Endereço residencial', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa este endereço para exclusões de deslocamento.`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} usa este endereço para exclusões de transporte diário.`, error: { dateShouldBeBefore: (dateString: string) => `A data deve ser anterior a ${dateString}`, dateShouldBeAfter: (dateString: string) => `A data deve ser posterior a ${dateString}`, @@ -7227,12 +7227,12 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, optionFixedDistanceTitle: 'Excluir uma distância fixa por solicitação', optionFixedDistanceHelp: 'Remova a mesma distância de deslocamento de cada reembolso. Ideal para membros que enviam um reembolso por dia de trabalho.', distanceLabel: 'Distância', - summaryHomeAndOffice: 'Usar endereços de casa e trabalho', + summaryHomeAndOffice: 'Usar endereços de casa e escritório', optionHomeAndOfficeTitle: 'Calcular por casa e escritório', - optionHomeAndOfficeHelp: 'Use o endereço residencial da pessoa associada, o regime de trabalho e a designação do escritório para calcular as exclusões de deslocamento.', + optionHomeAndOfficeHelp: 'Use o endereço residencial, o regime de trabalho e a designação de escritório da pessoa colaboradora para calcular as exclusões de deslocamento.', workspaceAddressRequired: { title: 'Calma lá...', - promptStart: 'Você não pode ativar a configuração de calcular por casa e escritório até primeiro adicionar um endereço de escritório em', + promptStart: 'Você não pode ativar a configuração de cálculo por casa e escritório até primeiro adicionar um endereço de escritório em', linkText: 'Visão geral', promptEnd: '.', cta: 'Entendi', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 89e4f318d433..fce77f65c6ad 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -942,7 +942,7 @@ const translations: TranslationDeepObject = { subtitle: ({policyName}: {policyName: string}) => `${policyName} > 会计`, }, validateAccount: {title: '验证您的账户', subtitle: '账户', cta: '验证'}, - addHomeAddress: {title: '添加您的家庭住址以进行距离追踪', subtitle: '账户', cta: '添加地址'}, + addHomeAddress: {title: '添加您的家庭住址以进行距离跟踪', subtitle: '账户', cta: '添加地址'}, fixFailedBilling: {title: '我们无法向您档案中的银行卡收费', subtitle: '订阅'}, unlockBankAccount: { workspaceTitle: '您的企业银行账户已被锁定', @@ -1238,11 +1238,11 @@ const translations: TranslationDeepObject = { }, iou: { homeAddressRequired: { - title: '家庭住址为必填项', + title: '需要填写家庭地址', prompt: ({workspaceName}: {workspaceName: string}) => workspaceName - ? `在开始记录出行距离之前,您需要在个人资料中添加家庭住址。${workspaceName} 会使用此地址计算通勤减免。` - : '在开始记录行程前,您需要在个人资料中添加家庭住址。此工作区会使用该地址计算通勤抵扣。', + ? `在开始记录出行距离之前,您需要在个人资料中添加家庭住址。${workspaceName} 会使用此地址用于通勤抵扣。` + : '在开始记录里程之前,您需要在个人资料中添加您的家庭住址。此工作区会使用该地址计算通勤扣除。', cta: '添加家庭住址', }, amount: '金额', @@ -3504,7 +3504,7 @@ ${amount},商户:${merchant} - 日期:${date}`, legalFirstName: '法定名(名)', legalLastName: '法定姓氏', address: '家庭住址', - commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} 使用此地址来设置通勤免税项目。`, + commuterExclusionsHint: ({workspaceName}: {workspaceName: string}) => `${workspaceName} 使用此地址进行通勤排除。`, error: { dateShouldBeBefore: (dateString: string) => `日期应早于 ${dateString}`, dateShouldBeAfter: (dateString: string) => `日期应晚于 ${dateString}`, @@ -6967,16 +6967,16 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM summaryDisabled: '不排除通勤', summaryFixedDistance: ({distance, unit}: {distance: number; unit: string}) => `每次报销排除 ${distance} ${unit}`, optionDisabledTitle: '不要排除通勤', - optionDisabledHelp: '未从报销中排除任何通勤路程。', + optionDisabledHelp: '未从报销中移除任何通勤费用。', optionFixedDistanceTitle: '为每笔报销排除固定距离', optionFixedDistanceHelp: '从每笔报销中扣除相同的通勤距离。最适合每个工作日提交一笔报销的成员使用。', distanceLabel: '距离', - summaryHomeAndOffice: '使用家庭和办公室地址', - optionHomeAndOfficeTitle: '按家庭与办公室计算', - optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公地点分配来计算通勤排除项。', + summaryHomeAndOffice: '使用家庭和办公地点', + optionHomeAndOfficeTitle: '按家庭和办公室计算', + optionHomeAndOfficeHelp: '使用成员的家庭住址、工作安排和办公室分配来计算通勤免税额。', workspaceAddressRequired: { - title: '先别急…', - promptStart: '在先在 中添加办公地点之前,您无法启用“按家庭和办公室计算”设置', + title: '别急……', + promptStart: '在您先添加一个办公地点之前,无法启用按家庭和办公室计算的设置,添加位置于', linkText: '概览', promptEnd: '中添加办公地点,才能启用“按家庭和办公室计算”设置。', cta: '知道了', From 71bf35b7be52db4c3c5eee01546fa1e332ee5698 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Wed, 26 Aug 2026 15:00:57 +0700 Subject: [PATCH 382/466] remove isValidSwiftBic check --- src/libs/BankAccountUtils.ts | 9 ++++----- tests/unit/BankAccountUtilsTest.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index c079ec8a7899..2c496ee487f2 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -295,13 +295,12 @@ function isValidSwiftBic(value?: string): boolean { } /** - * IBAN/SWIFT/BIC can come from either the dedicated `iban`/`swiftCode` fields filled in on the international bank - * account details step, or from `accountNumber`/`swiftBicCode`, which the Corpay bank-details step already collects - * for some countries. Either pairing satisfies the requirement, so both are checked. The Corpay fields are matched - * against the same formats because their own validation rules vary by country and don't guarantee an IBAN or a BIC. + * IBAN/SWIFT can come from the dedicated `iban`/`swiftCode` fields or from `accountNumber`/`swiftBicCode` on the + * Corpay bank-details step. IBAN must match the IBAN format in either place. Dedicated `swiftCode` must be a BIC; + * first-page `swiftBicCode` is already Corpay-validated, so non-empty is enough (it may not be a BIC). */ function hasValidInternationalBankAccountDetails(iban: string | undefined, swiftCode: string | undefined, accountNumber?: string, swiftBicCode?: string) { - return (isValidIBAN(iban) || isValidIBAN(accountNumber)) && (isValidSwiftBic(swiftCode) || isValidSwiftBic(swiftBicCode)); + return (isValidIBAN(iban) || isValidIBAN(accountNumber)) && (isValidSwiftBic(swiftCode) || !!swiftBicCode); } /** diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts index b985dd5032da..ba7c454bb400 100644 --- a/tests/unit/BankAccountUtilsTest.ts +++ b/tests/unit/BankAccountUtilsTest.ts @@ -780,12 +780,17 @@ describe('BankAccountUtils', () => { expect(hasValidInternationalBankAccountDetails(iban, swiftBicCode, '123456789', 'XXXX')).toBe(true); }); - it('returns true when accountNumber and swiftBicCode are valid even if dedicated fields are empty', () => { + it('returns true when accountNumber is an IBAN and swiftBicCode is set even if dedicated fields are empty', () => { expect(hasValidInternationalBankAccountDetails(undefined, undefined, iban, swiftBicCode)).toBe(true); }); - it('returns false when neither source has both a valid IBAN and SWIFT/BIC', () => { - expect(hasValidInternationalBankAccountDetails(iban, undefined, iban, 'XXXX')).toBe(false); + it('returns true when dedicated IBAN is set and first-page SWIFT is a Corpay value that is not a BIC', () => { + expect(hasValidInternationalBankAccountDetails(iban, 'XXXX', '123456789', 'XXXX')).toBe(true); + }); + + it('returns false when there is no IBAN or no SWIFT from either source', () => { + expect(hasValidInternationalBankAccountDetails(undefined, swiftBicCode, '123456789', 'XXXX')).toBe(false); + expect(hasValidInternationalBankAccountDetails(iban, undefined, iban, undefined)).toBe(false); }); }); From f7daaaea26a4b2e3e94c4f7c275b086aad4a81ec Mon Sep 17 00:00:00 2001 From: Cong Pham Date: Wed, 26 Aug 2026 15:11:42 +0700 Subject: [PATCH 383/466] update runtime config with dynamic route --- src/CONST/runtimeConfigured.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CONST/runtimeConfigured.ts b/src/CONST/runtimeConfigured.ts index 9d28e4e51bcb..070d61bdf70b 100644 --- a/src/CONST/runtimeConfigured.ts +++ b/src/CONST/runtimeConfigured.ts @@ -73,7 +73,7 @@ const CONST_RUNTIME: ConstRuntime = { SCREENS.MIGRATED_USER_WELCOME_MODAL.DYNAMIC_ROOT, SCREENS.SUBMIT_PLAN_WELCOME_MODAL.DYNAMIC_ROOT, SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT, - SCREENS.MONEY_REQUEST.STEP_SCAN, + SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP, ...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION), ], From 06f19b211b94e4e6cb92fa6bed04a67c133d8f6f Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 26 Aug 2026 10:28:52 +0200 Subject: [PATCH 384/466] Fix Concierge prompt box requiring two taps with keyboard open --- src/pages/home/HomePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 8449cb7f9c44..8ed189ae7519 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -64,6 +64,7 @@ function HomePage() { {!shouldUseNarrowLayout && ( From f77a880adcc45c01dd442651426ae907399a9d1c Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 08:32:06 +0000 Subject: [PATCH 385/466] Auto-tighten eslint-seatbelt baseline --- config/eslint/eslint.seatbelt.tsv | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index f5e3c70aa1c2..3e6374057afd 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -57,7 +57,7 @@ "../../src/Expensify.tsx" "react-hooks/set-state-in-effect" 1 "../../src/GlobalModals.tsx" "no-restricted-syntax" 2 "../../src/ONYXKEYS.ts" "no-restricted-syntax" 2 -"../../src/ROUTES.ts" "@typescript-eslint/no-deprecated/getUrlWithBackToParam" 59 +"../../src/ROUTES.ts" "@typescript-eslint/no-deprecated/getUrlWithBackToParam" 54 "../../src/ROUTES.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/TIMEZONES.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AccountingConnectionConfirmationModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 @@ -969,8 +969,6 @@ "../../src/pages/TransactionMerge/MergeTransactionsListContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/Travel/DynamicTravelUpgrade.tsx" "no-restricted-imports" 1 "../../src/pages/Travel/DynamicWorkspaceConfirmationForTravelPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/Travel/DynamicTravelUpgrade.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/TripChatNameEditPage.tsx" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 "../../src/pages/UnreportedExpenseListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/ValidateLoginPage/index.web.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/domain/Groups/PreferredWorkspaceToggle.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 @@ -1031,7 +1029,6 @@ "../../src/pages/iou/DynamicSplitExpensePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/DynamicSplitExpensePage.tsx" "react-hooks/set-state-in-effect" 3 "../../src/pages/iou/MoneyRequestAmountForm.tsx" "react-hooks/set-state-in-effect" 3 -"../../src/pages/iou/DynamicSplitExpenseEditPage.tsx" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 "../../src/pages/iou/SplitList/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/ParticipantSearchResults.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/pages/iou/request/step/DistanceManualTabContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1042,6 +1039,7 @@ "../../src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/iou/request/step/IOURequestEditReportCommon.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepAmount.tsx" "react-hooks/set-state-in-effect" 1 @@ -1059,7 +1057,6 @@ "../../src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepScan/utils/getFileSource.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/iou/request/step/DynamicIOURequestStepWaypoint.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/StepScreenWrapper.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/withFullTransactionOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/withWritableReportOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 From 36b5bea9d6c8e432f1ec78af8f555429e8120249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jasikowski?= Date: Wed, 26 Aug 2026 10:39:22 +0200 Subject: [PATCH 386/466] Fix split expense dynamic route typecheck --- src/ROUTES.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f99af397d6e2..def683ed1104 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -533,7 +533,7 @@ const DYNAMIC_ROUTES = { SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT, + SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT, SCREENS.SPLIT_DETAILS.DYNAMIC_ROOT, ], getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined) => { From 120d71137c21caeaa11da597c26d756e8684230d Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Wed, 26 Aug 2026 10:51:07 +0200 Subject: [PATCH 387/466] Skip preexisting report replacement when it is the parent report --- ...replaceOptimisticReportWithActualReport.ts | 5 ++ ...aceOptimisticReportWithActualReportTest.ts | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/libs/actions/replaceOptimisticReportWithActualReport.ts b/src/libs/actions/replaceOptimisticReportWithActualReport.ts index 3d3aba5b3b63..4f94629c2236 100644 --- a/src/libs/actions/replaceOptimisticReportWithActualReport.ts +++ b/src/libs/actions/replaceOptimisticReportWithActualReport.ts @@ -73,6 +73,11 @@ function replaceOptimisticReportWithActualReport(report: Report, draftReportComm return; } + // API sometimes returns the parent as the preexisting report, which would parent it to itself + if (preexistingReportID === parentReportID) { + return; + } + // Handle cleanup of stale optimistic IOU report and its report preview separately if (isMoneyRequestReport(report) && parentReportID && parentReportActionID) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { diff --git a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts index f1533b7391f7..affe85ca7461 100644 --- a/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts +++ b/tests/actions/ReplaceOptimisticReportWithActualReportTest.ts @@ -1412,4 +1412,94 @@ describe('replaceOptimisticReportWithActualReport', () => { // And it should be updated to point to the preexisting report expect(parentActions?.[reportActionID]?.childReportID).toBe(preexistingReportID); }); + + const setUpParentReturnedAsPreexistingReport = async () => { + const parentReportID = '9999'; + const optimisticReportID = '1234'; + const reportActionID = 'action123'; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, { + reportID: parentReportID, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + reportName: 'Self DM', + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: 'trans123'}, + childReportID: optimisticReportID, + }, + }); + + const optimisticReport = { + reportID: optimisticReportID, + type: CONST.REPORT.TYPE.CHAT, + parentReportID, + parentReportActionID: reportActionID, + preexistingReportID: parentReportID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`, optimisticReport); + await waitForBatchedUpdates(); + + return {parentReportID, optimisticReportID, reportActionID, optimisticReport}; + }; + + it('should not overwrite the parent report when the preexisting report is the parent report', async () => { + // Given the API returned the parent report itself as the preexisting report + const {parentReportID, optimisticReport} = await setUpParentReturnedAsPreexistingReport(); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined, 1); + await waitForBatchedUpdates(); + + // Then the parent report keeps its own data and is not left parented to itself + const parentReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); + expect(parentReport?.parentReportID).toBeUndefined(); + expect(parentReport?.chatType).toBe(CONST.REPORT.CHAT_TYPE.SELF_DM); + expect(parentReport?.reportName).toBe('Self DM'); + }); + + it('should keep the optimistic report when the preexisting report is the parent report', async () => { + // Given the API returned the parent report itself as the preexisting report + const {parentReportID, optimisticReportID, reportActionID, optimisticReport} = await setUpParentReturnedAsPreexistingReport(); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined, 1); + await waitForBatchedUpdates(); + + // Then the optimistic report is not cleared, so the parent action's childReportID does not dangle + const keptReport = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${optimisticReportID}`); + expect(keptReport).toBeDefined(); + + const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); + expect(parentActions?.[reportActionID]?.childReportID).toBe(optimisticReportID); + }); + + it('should not navigate away when the preexisting report is the parent report and the user is viewing the optimistic report', async () => { + // Given the user is viewing the optimistic report + mockIsReady.mockReturnValue(true); + mockGetCurrentRoute.mockReturnValue({name: SCREENS.REPORT, params: {}}); + + // And the API returned the parent report itself as the preexisting report + const {optimisticReportID, optimisticReport} = await setUpParentReturnedAsPreexistingReport(); + mockGetActiveRoute.mockReturnValue(`/r/${optimisticReportID}`); + + let capturedEventData: SwitchReportEventData | undefined; + const subscription = DeviceEventEmitter.addListener(`switchToPreExistingReport_${optimisticReportID}`, (data: SwitchReportEventData) => { + capturedEventData = data; + }); + + // When replaceOptimisticReportWithActualReport is called + replaceOptimisticReportWithActualReport(optimisticReport, undefined, 1); + await waitForBatchedUpdates(); + + // Then the user is not switched to the parent report + expect(capturedEventData).toBeUndefined(); + expect(mockSetParams).not.toHaveBeenCalled(); + + subscription.remove(); + }); }); From cf1f597b6fb150e08e6e1a1e939be0a9553d2645 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 10:54:34 +0200 Subject: [PATCH 388/466] Fix iOS - Home - Concierge prompt box remains expanded after sending attachment --- src/pages/home/ForYouSection/ConciergePromptBox.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 223b3232697b..dd6809aba19c 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -1,5 +1,6 @@ import AttachmentPicker from '@components/AttachmentPicker'; import Composer from '@components/Composer'; +import type {ComposerRef} from '@components/Composer/types'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; import {PressableWithoutFeedback} from '@components/Pressable'; @@ -18,6 +19,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isSafari} from '@libs/Browser'; +import {forceClearInput} from '@libs/ComponentUtils'; import {canSkipTriggerHotkeys} from '@libs/ComposerUtils'; import DateUtils from '@libs/DateUtils'; import getButtonState from '@libs/getButtonState'; @@ -32,10 +34,12 @@ import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {TextInputKeyPressEvent} from 'react-native'; +import type {NativeMethods, TextInputKeyPressEvent} from 'react-native'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +import {useAnimatedRef} from 'react-native-reanimated'; +import {scheduleOnUI} from 'react-native-worklets'; import useConciergeAttachmentPicker from './useConciergeAttachmentPicker'; @@ -75,10 +79,16 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const [longPlaceholderHeight, setLongPlaceholderHeight] = useState(null); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); const actionButtonRef = useRef(null); + const animatedRef = useAnimatedRef(); + + const setComposerRef = (element: ComposerRef) => { + animatedRef(element); + }; const clearInput = () => { setValue(''); setSelection({start: 0, end: 0}); + scheduleOnUI(forceClearInput, animatedRef); }; const sendAttachment = (attachments: FileObject | FileObject[]) => { @@ -216,6 +226,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo Date: Wed, 26 Aug 2026 11:32:18 +0200 Subject: [PATCH 389/466] Fix Home - Task is not created in Concierge after sending [] task from Concierge prompt box --- .../Search/SearchRouter/useAskConcierge.tsx | 11 ++- src/libs/actions/Task.ts | 87 +++++++++++++++++++ .../ReportActionCompose/useComposerSubmit.ts | 66 +------------- tests/ui/ReportActionComposeTest.tsx | 28 +++--- 4 files changed, 111 insertions(+), 81 deletions(-) diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index bf04a6447238..68195d3ed9d2 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -7,6 +7,7 @@ import useSidePanelReportID from '@hooks/useSidePanelReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {addAttachmentWithComment, addComment} from '@userActions/Report'; +import {createTaskFromMarkdown} from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -25,7 +26,9 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = const {openConciergeAnywhere, isInSidePanel} = useOpenConciergeAnywhere(); const targetReportID = !forceConcierge && isInSidePanel && sidePanelReportID ? sidePanelReportID : conciergeReportID; const [targetReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetReportID)}`); - const {timezone, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {timezone, accountID: currentUserAccountID} = currentUserPersonalDetails; + const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE); const delegateAccountID = useDelegateAccountID(); const shouldShowAskConcierge = !!targetReportID && !!targetReport; @@ -35,6 +38,12 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = return; } openConciergeAnywhere({forceConcierge}); + + // The `[] task` shorthand has to be handled here too, otherwise it would be sent to Concierge as a plain + // message instead of creating a task, unlike the same text typed into the report composer. + if (createTaskFromMarkdown({text: trimmedQuery, parentReport: targetReport, currentUserPersonalDetails, quickAction})) { + return; + } addComment({ report: targetReport, notifyReportID: targetReportID, diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 4e420b45ad3c..3ba048610ec0 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -7,18 +7,22 @@ import type {CancelTaskParams, CompleteTaskParams, CreateTaskParams, EditTaskAss import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {isEmailPublicDomain} from '@libs/LoginUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getDBTimeWithSkew} from '@libs/NetworkState'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {addDomainToShortMention} from '@libs/ParsingUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import {deprecatedGetReportName} from '@libs/ReportNameUtils'; import * as ReportUtils from '@libs/ReportUtils'; import {buildOptimisticSnapshotData} from '@libs/SearchQueryUtils'; +import {getAllPersonalDetailLogins} from '@libs/ShortMentionLogins'; import playSound, {SOUNDS} from '@libs/Sound'; import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {generateAccountID} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -27,6 +31,7 @@ import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type PersonalDetails from '@src/types/onyx/PersonalDetails'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {ReportActions} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -35,6 +40,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; import {getMostRecentReportID, navigateToConciergeChatAndDeleteReport, notifyNewAction, optimisticReportLastData} from './Report'; @@ -87,6 +93,15 @@ type CreateTaskAndNavigateParams = { taskCreatorAndAssigneeDetails: OnyxEntry; }; +type CreateTaskFromMarkdownParams = { + /** The already trimmed text the user is sending */ + text: string; + parentReport: OnyxEntry; + currentUserPersonalDetails: CurrentUserPersonalDetails; + quickAction: OnyxEntry; + ancestors?: ReportUtils.Ancestor[]; +}; + type DeleteTaskOptions = { ancestors?: ReportUtils.Ancestor[]; shouldNavigateBack?: boolean; @@ -410,6 +425,77 @@ function createTaskAndNavigate(params: CreateTaskAndNavigateParams) { notifyNewAction(parentReportID, optimisticAddCommentReport.reportAction, true); } +/** + * Creates a task from the `[] title` markdown shorthand (with an optional `@mention` assignee). + * + * @returns true when a task was created, so the caller can skip sending the text as a plain comment. + */ +function createTaskFromMarkdown({text, parentReport, currentUserPersonalDetails, quickAction, ancestors = []}: CreateTaskFromMarkdownParams): boolean { + // A task cannot be created without a parent report, so let the caller fall back to sending the text as a comment. + if (!parentReport?.reportID) { + return false; + } + + const taskMatch = text.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); + if (!taskMatch) { + return false; + } + + let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; + if (!taskTitle) { + return false; + } + + const currentUserEmail = currentUserPersonalDetails.email ?? ''; + const mention = taskMatch[1] ? taskMatch[1].trim() : ''; + const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); + const mentionWithDomain = addDomainToShortMention(mention, getAllPersonalDetailLogins(), currentUserPrivateDomain) ?? mention; + const isValidMention = Str.isValidEmail(mentionWithDomain); + + let assignee: OnyxEntry; + let assigneeChatReport; + if (mentionWithDomain) { + if (isValidMention) { + assignee = PersonalDetailsUtils.getPersonalDetailByEmail(mentionWithDomain); + if (!assignee) { + const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { + accountID: generateAccountID(mentionWithDomain), + login: mentionWithDomain, + }); + assignee = optimisticDataForNewAssignee.assignee; + assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; + } + } else { + taskTitle = `@${mentionWithDomain} ${taskTitle}`; + } + } + + const taskCreatorAndAssigneeDetails = {[currentUserPersonalDetails.accountID]: currentUserPersonalDetails}; + if (assignee) { + taskCreatorAndAssigneeDetails[assignee.accountID] = assignee; + } + + createTaskAndNavigate({ + parentReport, + title: taskTitle, + description: '', + assigneeEmail: assignee?.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + currentUserEmail, + currentUserDisplayName: currentUserPersonalDetails.displayName, + currentUserAvatar: currentUserPersonalDetails.avatar, + assigneeAccountID: assignee?.accountID, + assigneeChatReport, + policyID: parentReport?.policyID, + isCreatedUsingMarkdown: true, + quickAction, + ancestors, + taskCreatorAndAssigneeDetails, + }); + + return true; +} + function buildTaskData( taskReport: OnyxEntry, taskReportID: string, @@ -1554,6 +1640,7 @@ function completeTestDriveTask( export { createTaskAndNavigate, + createTaskFromMarkdown, editTask, editTaskAssignee, setTitleValue, diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index b3c2c6533a5d..7fcedf1af829 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -7,18 +7,13 @@ import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {addAttachmentWithComment, addComment, clearAgentZeroProcessingIndicator} from '@libs/actions/Report'; -import {createTaskAndNavigate, setNewOptimisticAssignee} from '@libs/actions/Task'; -import {isEmailPublicDomain} from '@libs/LoginUtils'; +import {createTaskFromMarkdown} from '@libs/actions/Task'; import {rand64} from '@libs/NumberUtils'; -import {addDomainToShortMention} from '@libs/ParsingUtils'; -import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getAllReportActions} from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction, generateReportID, isConciergeChatReport} from '@libs/ReportUtils'; -import {getAllPersonalDetailLogins} from '@libs/ShortMentionLogins'; import {startSpan} from '@libs/telemetry/activeSpans'; import getSendMessageListWeight from '@libs/telemetry/getSendMessageListWeight'; import getSendMessageSource from '@libs/telemetry/getSendMessageSource'; -import {generateAccountID} from '@libs/UserUtils'; import {useActionListContext} from '@pages/inbox/ActionListContext'; @@ -26,12 +21,8 @@ import {setIsComposerFullSize} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type * as OnyxTypes from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import {Str} from 'expensify-common'; import {useComposerActions, useComposerEditActions, useComposerEditState, useComposerMeta, useComposerSendState} from './ComposerContext'; import useComposerReportData from './useComposerReportData'; @@ -62,8 +53,6 @@ function useComposerSubmit(reportID: string) { const reportAncestors = useAncestors(report); const targetReportAncestors = useAncestors(targetReport); - const currentUserEmail = currentUserPersonalDetails.email ?? ''; - /** * Add or edit a comment in the composer */ @@ -107,57 +96,8 @@ function useComposerSubmit(reportID: string) { return; } - const taskMatch = draftMessageTrimmed.match(CONST.REGEX.TASK_TITLE_WITH_OPTIONAL_SHORT_MENTION); - if (taskMatch) { - let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; - if (taskTitle) { - const mention = taskMatch[1] ? taskMatch[1].trim() : ''; - const currentUserPrivateDomain = isEmailPublicDomain(currentUserEmail) ? '' : Str.extractEmailDomain(currentUserEmail); - const mentionWithDomain = addDomainToShortMention(mention, getAllPersonalDetailLogins(), currentUserPrivateDomain) ?? mention; - const isValidMention = Str.isValidEmail(mentionWithDomain); - - let assignee: OnyxEntry; - let assigneeChatReport; - if (mentionWithDomain) { - if (isValidMention) { - assignee = getPersonalDetailByEmail(mentionWithDomain); - if (!assignee) { - const optimisticDataForNewAssignee = setNewOptimisticAssignee(currentUserPersonalDetails.accountID, { - accountID: generateAccountID(mentionWithDomain), - login: mentionWithDomain, - }); - assignee = optimisticDataForNewAssignee.assignee; - assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; - } - } else { - taskTitle = `@${mentionWithDomain} ${taskTitle}`; - } - } - - const taskCreatorAndAssigneeDetails = {[currentUserPersonalDetails.accountID]: currentUserPersonalDetails}; - if (assignee) { - taskCreatorAndAssigneeDetails[assignee.accountID] = assignee; - } - - createTaskAndNavigate({ - parentReport: report, - title: taskTitle, - description: '', - assigneeEmail: assignee?.login ?? '', - currentUserAccountID: currentUserPersonalDetails.accountID, - currentUserEmail, - currentUserDisplayName: currentUserPersonalDetails.displayName, - currentUserAvatar: currentUserPersonalDetails.avatar, - assigneeAccountID: assignee?.accountID, - assigneeChatReport, - policyID: report?.policyID, - isCreatedUsingMarkdown: true, - quickAction, - ancestors: reportAncestors, - taskCreatorAndAssigneeDetails, - }); - return; - } + if (createTaskFromMarkdown({text: draftMessageTrimmed, parentReport: report, currentUserPersonalDetails, quickAction, ancestors: reportAncestors})) { + return; } const optimisticReportActionID = rand64(); diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index 465424818175..97a7c64be668 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -7,7 +7,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {KeyboardStateProvider} from '@components/withKeyboardState'; import type * as TaskActions from '@libs/actions/Task'; -import {createTaskAndNavigate} from '@libs/actions/Task'; +import {createTaskFromMarkdown} from '@libs/actions/Task'; import type {ReportActionComposeProps} from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; import ReportActionCompose from '@pages/inbox/report/ReportActionCompose/ReportActionCompose'; @@ -33,7 +33,7 @@ jest.mock('@libs/ComponentUtils', () => ({ jest.mock('@libs/actions/Task', () => ({ ...jest.requireActual('@libs/actions/Task'), - createTaskAndNavigate: jest.fn(), + createTaskFromMarkdown: jest.fn(() => true), })); jest.mock('@hooks/useLocalize', () => @@ -520,33 +520,27 @@ describe('ReportActionCompose Integration Tests', () => { }); }); - describe('Task creation with a short mention', () => { - it('assigns the task to the user resolved from a same-private-domain short mention', async () => { - // Given a current user on a private domain and a coworker on the same domain - const coworkerAccountID = 2; + describe('Task creation', () => { + // The `[] title` parsing itself (short mentions included) is covered in tests/actions/TaskTest.ts; what matters + // here is that the composer routes the draft through the shared detection instead of sending it as a comment. + it('hands a `[] task` draft to the shared markdown task detection', async () => { await TestHelper.signInWithTestUser(1, 'user@domain.com'); - await act(async () => { - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { - [coworkerAccountID]: TestHelper.buildPersonalDetails('mat@domain.com', coworkerAccountID, 'Mat'), - }); - }); const {unmount} = renderReportActionCompose(); await waitForBatchedUpdatesWithAct(); - // When a task with a short mention of the coworker is typed and submitted + // When a task with a short mention of a coworker is typed and submitted // (the composer submits by clearing the input, which hands the draft to validateAndSubmitDraft) const composer = screen.getByTestId('composer'); fireEvent.changeText(composer, '[] @mat Buy milk'); fireEvent(composer, 'clear', {nativeEvent: {text: '[] @mat Buy milk'}}); - // Then the task is created with the mention resolved to the coworker's full login + // Then the draft is passed to the task detection along with the report it should be created in await waitFor(() => { - expect(createTaskAndNavigate).toHaveBeenCalledWith( + expect(createTaskFromMarkdown).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Buy milk', - assigneeEmail: 'mat@domain.com', - assigneeAccountID: coworkerAccountID, + text: '[] @mat Buy milk', + parentReport: expect.objectContaining({reportID: REPORT_ID}), }), ); }); From 2bb48e203d0972992ad474b5b92088e242f46d17 Mon Sep 17 00:00:00 2001 From: "Linh Vo (via MelvinBot)" Date: Wed, 26 Aug 2026 09:39:05 +0000 Subject: [PATCH 390/466] Fix broken Android upload thumbnail by adding file:// scheme to cached local source Co-authored-by: Linh Vo --- src/libs/actions/Attachment/index.native.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Attachment/index.native.ts b/src/libs/actions/Attachment/index.native.ts index e3fd4c0ac055..83ef8ffb2751 100644 --- a/src/libs/actions/Attachment/index.native.ts +++ b/src/libs/actions/Attachment/index.native.ts @@ -88,7 +88,11 @@ async function getCachedAttachment({attachmentID, attachment, currentSource}: Ge // exists. If it was purged, fall back to the current source and re-cache it. const localFileExists = await RNFS.exists(localSource); if (localFileExists) { - return localSource; + // The path is stored schemeless so RNFS file operations (exists/unlink) accept it, but + // React Native's on Android only loads a local file when it carries a `file://` + // scheme (a bare path renders as a broken thumbnail), so add the scheme before the path + // reaches the image renderer. + return localSource.startsWith('file://') ? localSource : `file://${localSource}`; } cacheAttachment({attachmentID, uri: currentSource}); } From f24584cb898d9bdc94e12032d7b0cc79e710c500 Mon Sep 17 00:00:00 2001 From: "Linh Vo (via MelvinBot)" Date: Wed, 26 Aug 2026 09:41:34 +0000 Subject: [PATCH 391/466] Add test for file:// scheme on cached attachment source Co-authored-by: Linh Vo --- tests/actions/AttachmentTest.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/actions/AttachmentTest.ts b/tests/actions/AttachmentTest.ts index ebb59e6b350d..2f4cc3b0a45e 100644 --- a/tests/actions/AttachmentTest.ts +++ b/tests/actions/AttachmentTest.ts @@ -124,6 +124,20 @@ describe('AttachmentStorage', () => { // Then the dead local path is not returned expect(resolvedSource).toBe(sourceURL); }); + it('should return the cached local source with a file:// scheme so the native Image can load it', async () => { + // Given a cached attachment whose schemeless file path still exists on disk + const attachmentID = 'cached-attachment'; + const sourceURL = 'https://images.unsplash.com/photo-1726066012751-2adfb5485977?w=500'; + const localSource = `/mock/caches/attachments/${attachmentID}.jpg`; + const attachment = {attachmentID, source: localSource, remoteSource: sourceURL}; + mockRNFS.exists.mockResolvedValueOnce(true); + + // When reading it from the cache + const resolvedSource = await getCachedAttachment({attachmentID, attachment, currentSource: sourceURL}); + + // Then the path is returned prefixed with file:// (Android's can't load a schemeless local path) + expect(resolvedSource).toBe(`file://${localSource}`); + }); it('should cache markdown attachment', async () => { // Given the attachment data consisting of sourceURL and markdown comment text const sourceURL = 'https://images.unsplash.com/photo-1726066012751-2adfb5485977?w=500'; From da925b81cab466135d690d155588f0481a14c87a Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 11:42:42 +0200 Subject: [PATCH 392/466] Fix and add tests --- tests/actions/TaskTest.ts | 117 ++++++++++++++++++++++ tests/ui/ReportActionComposeTest.tsx | 2 +- tests/unit/hooks/useAskConcierge.test.tsx | 39 +++++++- 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index 1e78844dbbf9..ba007a82bc2e 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -13,6 +13,7 @@ import { completeTask, completeTestDriveTask, createTaskAndNavigate, + createTaskFromMarkdown, deleteTask, editTask, editTaskAssignee, @@ -22,6 +23,7 @@ import { getShareDestination, } from '@libs/actions/Task'; import * as API from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getReportName} from '@libs/ReportNameUtils'; @@ -33,6 +35,7 @@ import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Policy, Report, ReportAction} from '@src/types/onyx'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {OnyxData} from '@src/types/onyx/Request'; import type {OnyxEntry, OnyxKey, OnyxUpdate} from 'react-native-onyx'; @@ -998,6 +1001,120 @@ describe('actions/Task', () => { }); }); + describe('createTaskFromMarkdown', () => { + const parentReportID = 'markdown_parent_report'; + const currentUserAccountID = 1; + const coworkerAccountID = 2; + const currentUserPersonalDetails = { + accountID: currentUserAccountID, + login: 'user@domain.com', + email: 'user@domain.com', + displayName: 'User', + avatar: 'https://example.com/avatar.png', + } as CurrentUserPersonalDetails; + const parentReport = {reportID: parentReportID, type: CONST.REPORT.TYPE.CHAT} as Report; + + beforeEach(async () => { + jest.clearAllMocks(); + writeSpy.mockClear(); + global.fetch = getGlobalFetchMock(); + + mockBuildOptimisticTaskReport.mockImplementation((...args: unknown[]) => ({ + reportID: 'markdown_task_report', + reportName: args.at(3), + type: CONST.REPORT.TYPE.TASK, + parentReportID, + })); + mockBuildOptimisticCreatedReportAction.mockReturnValue({ + reportActionID: 'created_action_1', + reportAction: {reportActionID: 'created_action_1', actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: DateUtils.getDBTime()}, + }); + mockBuildOptimisticTaskCommentReportAction.mockReturnValue({ + reportAction: {reportActionID: 'comment_action_1', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: DateUtils.getDBTime()}, + }); + mockGetTaskAssigneeChatOnyxData.mockReturnValue({optimisticData: [], successData: [], failureData: []}); + mockIsHiddenForCurrentUser.mockReturnValue(false); + mockFormatReportLastMessageText.mockReturnValue('Last message text'); + + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.SESSION, {email: currentUserPersonalDetails.login, accountID: currentUserAccountID}); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [currentUserAccountID]: {accountID: currentUserAccountID, login: 'user@domain.com', displayName: 'User'}, + [coworkerAccountID]: {accountID: coworkerAccountID, login: 'mat@domain.com', displayName: 'Mat'}, + } as PersonalDetailsList); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, parentReport); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('does not create a task from text that is not the markdown shorthand', () => { + expect(createTaskFromMarkdown({text: 'Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('does not create a task when the shorthand has no title', () => { + expect(createTaskFromMarkdown({text: '[]', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(createTaskFromMarkdown({text: '[] ', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('does not create a task when there is no parent report', () => { + expect(createTaskFromMarkdown({text: '[] Buy milk', parentReport: undefined, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('creates an unassigned task from the shorthand', () => { + // When the shorthand is sent without a mention + expect(createTaskFromMarkdown({text: '[] Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(true); + + // Then the task is created with the remaining text as its title and nobody assigned + expect(mockBuildOptimisticTaskReport).toHaveBeenCalledWith(currentUserAccountID, parentReportID, 0, 'Buy milk', '', CONST.POLICY.OWNER_EMAIL_FAKE, expect.anything(), undefined); + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_TASK, expect.objectContaining({assignee: '', assigneeAccountID: 0}), expect.anything()); + }); + + it('assigns the task to the user resolved from a same-private-domain short mention', () => { + // When the shorthand mentions a coworker by their short login + expect(createTaskFromMarkdown({text: '[] @mat Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(true); + + // Then the mention is resolved to the full login and dropped from the title + expect(mockBuildOptimisticTaskReport).toHaveBeenCalledWith( + currentUserAccountID, + parentReportID, + coworkerAccountID, + 'Buy milk', + '', + CONST.POLICY.OWNER_EMAIL_FAKE, + expect.anything(), + undefined, + ); + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_TASK, expect.objectContaining({assignee: 'mat@domain.com', assigneeAccountID: coworkerAccountID}), expect.anything()); + }); + + it('keeps an unresolvable mention in the title instead of assigning it', () => { + // When the mention cannot be resolved to a known login + expect(createTaskFromMarkdown({text: '[] @nobody Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(true); + + // Then it stays part of the title and the task is left unassigned + expect(mockBuildOptimisticTaskReport).toHaveBeenCalledWith( + currentUserAccountID, + parentReportID, + 0, + '@nobody Buy milk', + '', + CONST.POLICY.OWNER_EMAIL_FAKE, + expect.anything(), + undefined, + ); + }); + + it('does not navigate, so it is safe to call from outside a modal flow', () => { + createTaskFromMarkdown({text: '[] Buy milk', parentReport, currentUserPersonalDetails, quickAction: undefined}); + + expect(Navigation.dismissModalWithReport).not.toHaveBeenCalled(); + }); + }); + describe('completeTask', () => { const mockTaskReportID = 'task_report_456'; const mockParentReportID = 'parent_report_789'; diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index 97a7c64be668..e216cb8abf77 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -540,7 +540,7 @@ describe('ReportActionCompose Integration Tests', () => { expect(createTaskFromMarkdown).toHaveBeenCalledWith( expect.objectContaining({ text: '[] @mat Buy milk', - parentReport: expect.objectContaining({reportID: REPORT_ID}), + parentReport: expect.objectContaining({reportID: defaultReport.reportID}), }), ); }); diff --git a/tests/unit/hooks/useAskConcierge.test.tsx b/tests/unit/hooks/useAskConcierge.test.tsx index 068144db50d1..2ee603cf82f5 100644 --- a/tests/unit/hooks/useAskConcierge.test.tsx +++ b/tests/unit/hooks/useAskConcierge.test.tsx @@ -3,6 +3,7 @@ import {renderHook} from '@testing-library/react-native'; import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; import {addAttachmentWithComment, addComment} from '@userActions/Report'; +import {createTaskFromMarkdown} from '@userActions/Task'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; @@ -32,7 +33,7 @@ jest.mock('@hooks/useOpenConciergeAnywhere', () => ({ jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ __esModule: true, - default: () => ({accountID: 1, timezone: {selected: 'Europe/Warsaw'}}), + default: () => ({accountID: 1, timezone: {selected: 'Europe/Warsaw'}, login: 'user@domain.com', email: 'user@domain.com', displayName: 'User', avatar: undefined}), })); jest.mock('@hooks/useDelegateAccountID', () => ({ @@ -45,8 +46,13 @@ jest.mock('@userActions/Report', () => ({ addAttachmentWithComment: jest.fn(), })); +jest.mock('@userActions/Task', () => ({ + createTaskFromMarkdown: jest.fn(() => false), +})); + const mockAddComment = jest.mocked(addComment); const mockAddAttachmentWithComment = jest.mocked(addAttachmentWithComment); +const mockCreateTaskFromMarkdown = jest.mocked(createTaskFromMarkdown); const CONCIERGE_REPORT = {reportID: CONCIERGE_REPORT_ID, reportName: 'Concierge'} as Report; const ADMINS_ROOM_REPORT = {reportID: ADMINS_ROOM_REPORT_ID, reportName: '#admins'} as Report; @@ -138,6 +144,37 @@ describe('useAskConcierge', () => { ); }); + it('creates a task instead of a comment when the message uses the `[] task` shorthand', async () => { + // Given a loaded Concierge report and text that the markdown task detection claims + await seedReports(); + mockCreateTaskFromMarkdown.mockReturnValueOnce(true); + const {result} = renderAskConcierge({forceConcierge: true}); + expect(result.current.shouldShowAskConcierge).toBe(true); + + // When the shorthand is sent + result.current.askConcierge(' [] Buy milk '); + + // Then Concierge is still opened, the detection gets the trimmed text and the target report, + // and the text is not also posted as a plain comment + expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true}); + expect(mockCreateTaskFromMarkdown).toHaveBeenCalledWith(expect.objectContaining({text: '[] Buy milk', parentReport: CONCIERGE_REPORT})); + expect(mockAddComment).not.toHaveBeenCalled(); + }); + + it('falls back to a comment when the text is not the task shorthand', async () => { + // Given a loaded Concierge report and text the markdown task detection does not claim + await seedReports(); + const {result} = renderAskConcierge({forceConcierge: true}); + expect(result.current.shouldShowAskConcierge).toBe(true); + + // When a plain message is sent + result.current.askConcierge('Where is my expense?'); + + // Then it is sent as a comment + expect(mockCreateTaskFromMarkdown).toHaveBeenCalled(); + expect(mockAddComment).toHaveBeenCalledWith(expect.objectContaining({text: 'Where is my expense?'})); + }); + it('does nothing for a whitespace-only message', async () => { // Given a loaded Concierge report await seedReports(); From afdb57eb33e0881e28d1d6c009f874cdfffe75a3 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 11:44:32 +0200 Subject: [PATCH 393/466] Comments --- src/components/Search/SearchRouter/useAskConcierge.tsx | 2 -- src/libs/actions/Task.ts | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchRouter/useAskConcierge.tsx b/src/components/Search/SearchRouter/useAskConcierge.tsx index 68195d3ed9d2..a5396918f64f 100644 --- a/src/components/Search/SearchRouter/useAskConcierge.tsx +++ b/src/components/Search/SearchRouter/useAskConcierge.tsx @@ -39,8 +39,6 @@ function useAskConcierge({forceConcierge = false}: {forceConcierge?: boolean} = } openConciergeAnywhere({forceConcierge}); - // The `[] task` shorthand has to be handled here too, otherwise it would be sent to Concierge as a plain - // message instead of creating a task, unlike the same text typed into the report composer. if (createTaskFromMarkdown({text: trimmedQuery, parentReport: targetReport, currentUserPersonalDetails, quickAction})) { return; } diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 3ba048610ec0..9bbcd83869d2 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -96,9 +96,13 @@ type CreateTaskAndNavigateParams = { type CreateTaskFromMarkdownParams = { /** The already trimmed text the user is sending */ text: string; + /** The parent report to which the task belongs */ parentReport: OnyxEntry; + /** The current user's personal details */ currentUserPersonalDetails: CurrentUserPersonalDetails; + /** The quick action associated with the task */ quickAction: OnyxEntry; + /** The ancestors of the task */ ancestors?: ReportUtils.Ancestor[]; }; From 7830e1024f774cdb010fca66ba8c85bc68eb7647 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 11:46:48 +0200 Subject: [PATCH 394/466] Fix knip check --- src/libs/actions/Task.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 9bbcd83869d2..2107619d7c21 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -1665,7 +1665,6 @@ export { getTaskAssigneeAccountID, clearTaskErrors, canModifyTask, - setNewOptimisticAssignee, getNavigationUrlOnTaskDelete, canActionTask, getFinishOnboardingTaskOnyxData, From d8b3841cab1045f98d54e9834dc66a6da19f943f Mon Sep 17 00:00:00 2001 From: "Linh Vo (via MelvinBot)" Date: Wed, 26 Aug 2026 09:47:50 +0000 Subject: [PATCH 395/466] Reword comments to fix spellcheck (avoid non-dictionary word 'schemeless') Co-authored-by: Linh Vo --- src/libs/actions/Attachment/index.native.ts | 2 +- tests/actions/AttachmentTest.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/actions/Attachment/index.native.ts b/src/libs/actions/Attachment/index.native.ts index 83ef8ffb2751..624bec0ef605 100644 --- a/src/libs/actions/Attachment/index.native.ts +++ b/src/libs/actions/Attachment/index.native.ts @@ -88,7 +88,7 @@ async function getCachedAttachment({attachmentID, attachment, currentSource}: Ge // exists. If it was purged, fall back to the current source and re-cache it. const localFileExists = await RNFS.exists(localSource); if (localFileExists) { - // The path is stored schemeless so RNFS file operations (exists/unlink) accept it, but + // The path is stored without a scheme so RNFS file operations (exists/unlink) accept it, but // React Native's on Android only loads a local file when it carries a `file://` // scheme (a bare path renders as a broken thumbnail), so add the scheme before the path // reaches the image renderer. diff --git a/tests/actions/AttachmentTest.ts b/tests/actions/AttachmentTest.ts index 2f4cc3b0a45e..2077407f5c06 100644 --- a/tests/actions/AttachmentTest.ts +++ b/tests/actions/AttachmentTest.ts @@ -125,7 +125,7 @@ describe('AttachmentStorage', () => { expect(resolvedSource).toBe(sourceURL); }); it('should return the cached local source with a file:// scheme so the native Image can load it', async () => { - // Given a cached attachment whose schemeless file path still exists on disk + // Given a cached attachment whose stored file path (no scheme) still exists on disk const attachmentID = 'cached-attachment'; const sourceURL = 'https://images.unsplash.com/photo-1726066012751-2adfb5485977?w=500'; const localSource = `/mock/caches/attachments/${attachmentID}.jpg`; @@ -135,7 +135,7 @@ describe('AttachmentStorage', () => { // When reading it from the cache const resolvedSource = await getCachedAttachment({attachmentID, attachment, currentSource: sourceURL}); - // Then the path is returned prefixed with file:// (Android's can't load a schemeless local path) + // Then the path is returned prefixed with file:// (Android's can't load a local path without a scheme) expect(resolvedSource).toBe(`file://${localSource}`); }); it('should cache markdown attachment', async () => { From 080f9c81137e77e74101b8df006ed38079b9faa7 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 12:56:36 +0200 Subject: [PATCH 396/466] Fix comment max length check --- src/libs/actions/Task.ts | 2 +- .../home/ForYouSection/ConciergePromptBox.tsx | 249 ++++++++++-------- tests/actions/TaskTest.ts | 18 ++ 3 files changed, 157 insertions(+), 112 deletions(-) diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 2107619d7c21..1b457553c64c 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -446,7 +446,7 @@ function createTaskFromMarkdown({text, parentReport, currentUserPersonalDetails, } let taskTitle = taskMatch[3] ? taskMatch[3].trim().replaceAll('\n', ' ') : undefined; - if (!taskTitle) { + if (!taskTitle || taskTitle.length > CONST.TITLE_CHARACTER_LIMIT) { return false; } diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 223b3232697b..d71603f8f49b 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -1,5 +1,6 @@ import AttachmentPicker from '@components/AttachmentPicker'; import Composer from '@components/Composer'; +import ExceededCommentLength from '@components/ExceededCommentLength'; import Icon from '@components/Icon'; import PopoverMenu from '@components/PopoverMenu'; import {PressableWithoutFeedback} from '@components/Pressable'; @@ -23,6 +24,7 @@ import DateUtils from '@libs/DateUtils'; import getButtonState from '@libs/getButtonState'; import SubmitDraftButton from '@pages/inbox/report/ReportActionCompose/SubmitDraftButton'; +import useDebouncedCommentMaxLengthValidation from '@pages/inbox/report/ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import variables from '@styles/variables'; @@ -68,6 +70,8 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const {calculatePopoverPosition} = usePopoverPosition(); const [value, setValue] = useState(''); + const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({reportID: conciergeTargetReportID}); + // Composer is a controlled input: the caret position must be tracked and fed back in (with // shouldCalculateCaretPosition), otherwise every value update re-renders it with the caret at the start. const [selection, setSelection] = useState({start: 0, end: 0}); @@ -79,6 +83,9 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const clearInput = () => { setValue(''); setSelection({start: 0, end: 0}); + + debouncedCommentMaxLengthValidation(''); + debouncedCommentMaxLengthValidation.flush(); }; const sendAttachment = (attachments: FileObject | FileObject[]) => { @@ -111,10 +118,12 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo // Default to the short copy until measured, so it never flashes a wrapped long placeholder that then collapses. const longPlaceholderFitsOneLine = longPlaceholderHeight !== null && longPlaceholderHeight <= SINGLE_LINE_PLACEHOLDER_MAX_HEIGHT; const placeholder = shouldUseNarrowLayout || !longPlaceholderFitsOneLine ? shortPlaceholder : longPlaceholder; - const canSubmit = shouldShowAskConcierge && value.trim().length > 0; + const canSubmit = shouldShowAskConcierge && value.trim().length > 0 && !isExceedingMaxLength; + + const canAddAttachment = shouldShowAskConcierge && !isExceedingMaxLength; const submit = () => { - if (!canSubmit) { + if (!canSubmit || debouncedCommentMaxLengthValidation.flush() === false) { return; } askConcierge(value); @@ -140,123 +149,141 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo {dateLabel} {greeting} - - - - - - {({openPicker}) => { - const triggerAttachmentPicker = () => openPicker({onPicked: pickAttachments}); - return ( - <> - - { - e?.preventDefault(); - actionButtonRef.current?.blur(); - setIsMenuVisible((prev) => !prev); + + + + + + + {({openPicker}) => { + const triggerAttachmentPicker = () => openPicker({onPicked: pickAttachments}); + return ( + <> + + { + e?.preventDefault(); + actionButtonRef.current?.blur(); + setIsMenuVisible((prev) => !prev); + }} + style={({hovered, pressed}) => [ + styles.composerSizeButton, + StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered && canAddAttachment, pressed && canAddAttachment)), + ]} + > + {({hovered, pressed}) => ( + + )} + + + setIsMenuVisible(false)} + onItemSelected={() => { + setIsMenuVisible(false); + + // On Safari the file picker must be opened from within the user-initiated + // event handler, so it can't wait for the popover to finish closing. + if (isSafari()) { + triggerAttachmentPicker(); + return; + } + close(triggerAttachmentPicker); + }} + anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }} - style={({hovered, pressed}) => [ - styles.composerSizeButton, - StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered && shouldShowAskConcierge, pressed && shouldShowAskConcierge)), + menuItems={[ + { + icon: icons.Paperclip, + text: translate('reportActionCompose.addAttachment'), + shouldCallAfterModalHide: shouldUseNarrowLayout, + }, ]} - > - {({hovered, pressed}) => ( - - )} - - - setIsMenuVisible(false)} - onItemSelected={() => { - setIsMenuVisible(false); - - // On Safari the file picker must be opened from within the user-initiated - // event handler, so it can't wait for the popover to finish closing. - if (isSafari()) { - triggerAttachmentPicker(); - return; - } - close(triggerAttachmentPicker); - }} - anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} - anchorAlignment={{ - horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, - vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, - }} - menuItems={[ - { - icon: icons.Paperclip, - text: translate('reportActionCompose.addAttachment'), - shouldCallAfterModalHide: shouldUseNarrowLayout, - }, - ]} - anchorRef={actionButtonRef} - /> - - ); - }} - + anchorRef={actionButtonRef} + /> + + ); + }} + + + - - - - setSelection(event.nativeEvent.selection)} - shouldCalculateCaretPosition - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onKeyPress={handleKeyPress} - maxLines={MAX_INPUT_LINES} - multiline - textAlignVertical="top" - placeholder={placeholder} - placeholderTextColor={theme.placeholderText} - accessibilityLabel={placeholder} - /> - {/* Hidden probe stretched to the input's width. Its height reveals whether the long placeholder wraps past one line. */} - setLongPlaceholderHeight(event.nativeEvent.layout.height)} - > - + { + setValue(text); + debouncedCommentMaxLengthValidation(text); + }} + selection={selection} + onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} + shouldCalculateCaretPosition + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onKeyPress={handleKeyPress} + maxLines={MAX_INPUT_LINES} + multiline + textAlignVertical="top" + placeholder={placeholder} + placeholderTextColor={theme.placeholderText} + accessibilityLabel={placeholder} + /> + {/* Hidden probe stretched to the input's width. Its height reveals whether the long placeholder wraps past one line. */} + setLongPlaceholderHeight(event.nativeEvent.layout.height)} > - {longPlaceholder} - + + {longPlaceholder} + + + + {/* Mirror ComposerSendButton: the justifyContentEnd wrapper stretches to the row height and anchors the send button to the bottom. */} + + - {/* Mirror ComposerSendButton: the justifyContentEnd wrapper stretches to the row height and anchors the send button to the bottom. */} - - - + )} {PDFValidationComponent} diff --git a/tests/actions/TaskTest.ts b/tests/actions/TaskTest.ts index ba007a82bc2e..d92cab7e9911 100644 --- a/tests/actions/TaskTest.ts +++ b/tests/actions/TaskTest.ts @@ -1059,6 +1059,24 @@ describe('actions/Task', () => { expect(writeSpy).not.toHaveBeenCalled(); }); + it('does not create a task when the title exceeds the title character limit', () => { + // Given a shorthand whose title is one character over the limit the composer enforces + const overLimitTitle = 'a'.repeat(CONST.TITLE_CHARACTER_LIMIT + 1); + + // Then it is refused, so the caller falls back to sending it as a plain comment + expect(createTaskFromMarkdown({text: `[] ${overLimitTitle}`, parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(false); + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('creates a task from a title that is exactly at the title character limit', () => { + // Given a shorthand whose title is exactly at the limit + const maxLengthTitle = 'a'.repeat(CONST.TITLE_CHARACTER_LIMIT); + + // Then the task is still created + expect(createTaskFromMarkdown({text: `[] ${maxLengthTitle}`, parentReport, currentUserPersonalDetails, quickAction: undefined})).toBe(true); + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_TASK, expect.anything(), expect.anything()); + }); + it('does not create a task when there is no parent report', () => { expect(createTaskFromMarkdown({text: '[] Buy milk', parentReport: undefined, currentUserPersonalDetails, quickAction: undefined})).toBe(false); expect(writeSpy).not.toHaveBeenCalled(); From 3527129e07a9cc4ebbacd347f1a15f851a083535 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 11:07:26 +0000 Subject: [PATCH 397/466] Update version to 9.4.61-1 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2bd08a4a8626..252d2d996857 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046100 - versionName "9.4.61-0" + versionCode 1009046101 + versionName "9.4.61-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 51d4e4a43e73..9bd83797ce92 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.61.0 + 9.4.61.1 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 18ab69f3f3d8..afaeb3b99d31 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.61 CFBundleVersion - 9.4.61.0 + 9.4.61.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 1332dda6eaef..4d23f22d430b 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.61 CFBundleVersion - 9.4.61.0 + 9.4.61.1 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 3f3f478bff99..74930012e03a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.61-0", + "version": "9.4.61-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.61-0", + "version": "9.4.61-1", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 6acfa79cd244..1559240fc453 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.61-0", + "version": "9.4.61-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 997e8d485dbb930f7a7ed133230698a371e46f1b Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 11:07:26 +0000 Subject: [PATCH 398/466] Update Mobile-Expensify submodule version to 9.4.61-1 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 89db19cb689b..562f7235cf3e 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 89db19cb689bcdc0bcff014d9a7e634b8fa0c4cc +Subproject commit 562f7235cf3ec5b5f0213b70b8a1dfb050d651f2 From b404f974bd46990c8f6d277a9fcd8985ca8f088e Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 26 Aug 2026 12:25:39 +0200 Subject: [PATCH 399/466] Gate the Home Concierge prompt box behind sign in for anonymous users --- .../home/ForYouSection/ConciergePromptBox.tsx | 9 ++-- tests/ui/ConciergePromptBoxTest.tsx | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 223b3232697b..7b646ec11b19 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -21,6 +21,7 @@ import {isSafari} from '@libs/Browser'; import {canSkipTriggerHotkeys} from '@libs/ComposerUtils'; import DateUtils from '@libs/DateUtils'; import getButtonState from '@libs/getButtonState'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import SubmitDraftButton from '@pages/inbox/report/ReportActionCompose/SubmitDraftButton'; @@ -117,8 +118,10 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo if (!canSubmit) { return; } - askConcierge(value); - clearInput(); + interceptAnonymousUser(() => { + askConcierge(value); + clearInput(); + }); }; const handleKeyPress = (event: TextInputKeyPressEvent) => { @@ -163,7 +166,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo onPress={(e) => { e?.preventDefault(); actionButtonRef.current?.blur(); - setIsMenuVisible((prev) => !prev); + interceptAnonymousUser(() => setIsMenuVisible((prev) => !prev)); }} style={({hovered, pressed}) => [ styles.composerSizeButton, diff --git a/tests/ui/ConciergePromptBoxTest.tsx b/tests/ui/ConciergePromptBoxTest.tsx index baa5ca4a3eaa..7c5638ea9733 100644 --- a/tests/ui/ConciergePromptBoxTest.tsx +++ b/tests/ui/ConciergePromptBoxTest.tsx @@ -11,6 +11,7 @@ import {isSafari} from '@libs/Browser'; import ConciergePromptBox from '@pages/home/ForYouSection/ConciergePromptBox'; import {close} from '@userActions/Modal'; +import {isAnonymousUser, signOutAndRedirectToSignIn} from '@userActions/Session'; import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -88,11 +89,18 @@ jest.mock('@userActions/Modal', () => ({ close: jest.fn(), })); +jest.mock('@userActions/Session', () => ({ + isAnonymousUser: jest.fn(() => false), + signOutAndRedirectToSignIn: jest.fn(), +})); + const mockUseAskConcierge = jest.mocked(useAskConcierge); const mockUseResponsiveLayout = jest.mocked(useResponsiveLayout); const mockUseKeyboardState = jest.mocked(useKeyboardState); const mockIsSafari = jest.mocked(isSafari); const mockClose = jest.mocked(close); +const mockIsAnonymousUser = jest.mocked(isAnonymousUser); +const mockSignOutAndRedirectToSignIn = jest.mocked(signOutAndRedirectToSignIn); function ConciergePromptBoxWrapper() { const [isMenuVisible, setIsMenuVisible] = useState(false); @@ -162,6 +170,7 @@ describe('ConciergePromptBox', () => { setResponsiveLayout(false); setKeyboardShown(false); mockIsSafari.mockReturnValue(false); + mockIsAnonymousUser.mockReturnValue(false); }); describe('sending a message', () => { @@ -310,6 +319,50 @@ describe('ConciergePromptBox', () => { }); }); + describe('anonymous user', () => { + beforeEach(() => { + mockIsAnonymousUser.mockReturnValue(true); + }); + + it('asks to sign in instead of sending on the send button', () => { + // Given an anonymous user with a typed message + render(); + fireEvent.changeText(getInput(), 'Where is my expense?'); + + // When the send button is pressed + fireEvent.press(screen.getByLabelText(SEND_BUTTON)); + + // Then nothing is sent and the sign in flow opens + expect(mockAskConcierge).not.toHaveBeenCalled(); + expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalled(); + }); + + it('asks to sign in instead of sending on Enter', () => { + // Given an anonymous user with a typed message + render(); + fireEvent.changeText(getInput(), 'Where is my expense?'); + + // When Enter is pressed + pressEnter(); + + // Then nothing is sent and the sign in flow opens + expect(mockAskConcierge).not.toHaveBeenCalled(); + expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalled(); + }); + + it('asks to sign in instead of opening the actions menu', () => { + // Given an anonymous user + render(); + + // When the "+" button is pressed + fireEvent.press(screen.getByLabelText(PLUS_BUTTON)); + + // Then the menu stays closed and the sign in flow opens + expect(screen.queryByLabelText(ADD_ATTACHMENT)).toBeNull(); + expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalled(); + }); + }); + describe('placeholder', () => { it('uses the long copy only once it is known to fit one line', () => { // Given a wide layout From fe4d36dd7ef4719a76e7df8cbbf8556b34f7fd9c Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 13:33:07 +0000 Subject: [PATCH 400/466] Update version to 9.4.61-2 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 252d2d996857..8d212793d3dc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046101 - versionName "9.4.61-1" + versionCode 1009046102 + versionName "9.4.61-2" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 9bd83797ce92..d6cd2d464c07 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.61.1 + 9.4.61.2 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index afaeb3b99d31..c158a527a388 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.61 CFBundleVersion - 9.4.61.1 + 9.4.61.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 4d23f22d430b..0daf56e262f1 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.61 CFBundleVersion - 9.4.61.1 + 9.4.61.2 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 74930012e03a..f8b1a8a8c11c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.61-1", + "version": "9.4.61-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.61-1", + "version": "9.4.61-2", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 1559240fc453..e21c701904e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.61-1", + "version": "9.4.61-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From c80d595350ebfa74eeff68382d0dc3709c17e18b Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 13:33:08 +0000 Subject: [PATCH 401/466] Update Mobile-Expensify submodule version to 9.4.61-2 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 562f7235cf3e..4586a6702dfb 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 562f7235cf3ec5b5f0213b70b8a1dfb050d651f2 +Subproject commit 4586a6702dfb31a9c7659bc6fe53555baa2fbf43 From b67514bdc2c4cc83650ca81fc9a882608456dbd5 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 13:44:15 +0000 Subject: [PATCH 402/466] Update version to 9.4.61-3 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8d212793d3dc..6d41c64b6983 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046102 - versionName "9.4.61-2" + versionCode 1009046103 + versionName "9.4.61-3" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index d6cd2d464c07..8a928a60a697 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.61.2 + 9.4.61.3 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index c158a527a388..34cda4c6e4bc 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.61 CFBundleVersion - 9.4.61.2 + 9.4.61.3 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 0daf56e262f1..b2a87a8c6299 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.61 CFBundleVersion - 9.4.61.2 + 9.4.61.3 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index f8b1a8a8c11c..a5ebbb475d31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.61-2", + "version": "9.4.61-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.61-2", + "version": "9.4.61-3", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index e21c701904e7..a3f6330db0b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.61-2", + "version": "9.4.61-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 256192c060598e0a1a542a32d64eeb885b345d4e Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 13:44:16 +0000 Subject: [PATCH 403/466] Update Mobile-Expensify submodule version to 9.4.61-3 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 4586a6702dfb..bd5bbaf280b9 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 4586a6702dfb31a9c7659bc6fe53555baa2fbf43 +Subproject commit bd5bbaf280b91c5a472ca0c07533ff62809ebb8e From 4d00f12c727a8165e0be0dd9910e8535a0ffdcf2 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Wed, 26 Aug 2026 15:51:52 +0200 Subject: [PATCH 404/466] Store Concierge prompt box draft message in Onyx --- src/ONYXKEYS.ts | 4 +++ src/libs/actions/Report/index.ts | 6 ++++ .../home/ForYouSection/ConciergePromptBox.tsx | 34 +++++++++++++++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 88e502dcc567..448f805400e8 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -733,6 +733,9 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /** Draft message typed into the Concierge prompt box on the home page */ + CONCIERGE_PROMPT_DRAFT: 'conciergePromptDraft', + /** The user's Self DM reportID */ SELF_DM_REPORT_ID: 'selfDMReportID', @@ -1788,6 +1791,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_THINKING_KICKOFF]: boolean; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; + [ONYXKEYS.CONCIERGE_PROMPT_DRAFT]: string; [ONYXKEYS.SELF_DM_REPORT_ID]: string; [ONYXKEYS.SHARE_UNKNOWN_USER_DETAILS]: Participant; [ONYXKEYS.SHARE_TEMP_FILE]: OnyxTypes.ShareTempFile; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 999b1dd3d6b1..92cfd2523176 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -3227,6 +3227,11 @@ function saveReportDraftComment(reportID: string, comment: string | null, callba Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, comment || null).then(callback); } +/** Saves the message the user is typing into the Concierge prompt box on the home page. Pass null to delete the draft. */ +function saveConciergePromptDraft(draft: string | null) { + Onyx.set(ONYXKEYS.CONCIERGE_PROMPT_DRAFT, draft); +} + /** Broadcasts whether or not a user is typing on a report over the report's private pusher channel. */ function broadcastUserIsTyping(reportID: string, currentUserAccountID: number) { const privateReportChannelName = getReportChannelName(reportID); @@ -8717,6 +8722,7 @@ export { subscribeToReportLeavingEvents, clearAgentZeroProcessingIndicator, clearConciergeThinkingKickoff, + saveConciergePromptDraft, subscribeToReportReasoningEvents, unsubscribeFromReportReasoningChannel, subscribeToReportTypingEvents, diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 223b3232697b..7e5d275f4bb3 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -11,6 +11,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import usePopoverPosition from '@hooks/usePopoverPosition'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -23,12 +24,15 @@ import DateUtils from '@libs/DateUtils'; import getButtonState from '@libs/getButtonState'; import SubmitDraftButton from '@pages/inbox/report/ReportActionCompose/SubmitDraftButton'; +import useDebouncedSaveDraft from '@pages/inbox/report/useDebouncedSaveDraft'; import variables from '@styles/variables'; import {close} from '@userActions/Modal'; +import {saveConciergePromptDraft} from '@userActions/Report'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {AnchorPosition} from '@src/styles'; import type {FileObject} from '@src/types/utils/Attachment'; @@ -66,11 +70,29 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const {askConcierge, askConciergeWithAttachment, shouldShowAskConcierge, conciergeTargetReportID} = useAskConcierge({forceConcierge: true}); const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Send', 'Paperclip']); const {calculatePopoverPosition} = usePopoverPosition(); - const [value, setValue] = useState(''); + const [draft] = useOnyx(ONYXKEYS.CONCIERGE_PROMPT_DRAFT); + const [value, setValue] = useState(draft ?? ''); // Composer is a controlled input: the caret position must be tracked and fed back in (with // shouldCalculateCaretPosition), otherwise every value update re-renders it with the caret at the start. - const [selection, setSelection] = useState({start: 0, end: 0}); + const [selection, setSelection] = useState({start: value.length, end: value.length}); + const [lastSyncedDraft, setLastSyncedDraft] = useState(draft); + + // Onyx owns the draft, so it survives refresh and is wiped along with the rest of the cache. + if (draft !== lastSyncedDraft) { + setLastSyncedDraft(draft); + setValue(draft ?? ''); + setSelection({start: draft?.length ?? 0, end: draft?.length ?? 0}); + } + + const {saveDraft: debouncedSaveDraft, cancelSaveDraft} = useDebouncedSaveDraft( + (nextDraft: string) => { + setLastSyncedDraft(nextDraft); + saveConciergePromptDraft(nextDraft); + }, + undefined, + true, + ); const [isFocused, setIsFocused] = useState(false); const [longPlaceholderHeight, setLongPlaceholderHeight] = useState(null); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); @@ -79,6 +101,9 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const clearInput = () => { setValue(''); setSelection({start: 0, end: 0}); + cancelSaveDraft(); + setLastSyncedDraft(undefined); + saveConciergePromptDraft(null); }; const sendAttachment = (attachments: FileObject | FileObject[]) => { @@ -218,7 +243,10 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo { + setValue(nextValue); + debouncedSaveDraft(nextValue); + }} selection={selection} onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} shouldCalculateCaretPosition From 32461e3d263befd1a501ca4263afbea7be5d734d Mon Sep 17 00:00:00 2001 From: GCyganek Date: Wed, 26 Aug 2026 16:16:34 +0200 Subject: [PATCH 405/466] Fix ExceededCommentLength adding more space below composer --- src/pages/home/ForYouSection/ConciergePromptBox.tsx | 12 +++++++----- src/styles/index.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index a55cb451e310..af38746cd31d 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -159,7 +159,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo {dateLabel} {greeting} - + {!!exceededMaxLength && ( - + + + )} {PDFValidationComponent} diff --git a/src/styles/index.ts b/src/styles/index.ts index cd6b56a5b1a2..e688492a680e 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -7231,6 +7231,15 @@ const plainStyles = (theme: ThemeColors) => minHeight: COMPOSER_SIZE_BUTTON_SIZE, }, + // Overlays the exceeded-length message just below the compose box so showing it never grows the box's + // footprint and pushes the content underneath down. + conciergePromptBoxExceededLength: { + position: 'absolute', + top: '100%', + left: 0, + right: 0, + }, + // Hidden probe that measures whether the long placeholder wraps. The paddingRight renders it a few px // narrower than the composer so it wraps first, avoiding a flash at borderline widths. conciergePromptBoxPlaceholderProbe: { From d1903e309ac1a2d3160d05c81f708f78a2a63a67 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 14:34:28 +0000 Subject: [PATCH 406/466] Update version to 9.4.61-4 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 6d41c64b6983..ad0d9a13674e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046103 - versionName "9.4.61-3" + versionCode 1009046104 + versionName "9.4.61-4" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 8a928a60a697..cb3f49e18a8f 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.61.3 + 9.4.61.4 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 34cda4c6e4bc..738fede7b438 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.61 CFBundleVersion - 9.4.61.3 + 9.4.61.4 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index b2a87a8c6299..4dabfd83f67e 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.61 CFBundleVersion - 9.4.61.3 + 9.4.61.4 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index a5ebbb475d31..c7fdf02fa295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.61-3", + "version": "9.4.61-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.61-3", + "version": "9.4.61-4", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index a3f6330db0b5..5d8a4f23b6a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.61-3", + "version": "9.4.61-4", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 9e5f8e6fa02e146dec5fffe345a54aa549fb332e Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 14:34:29 +0000 Subject: [PATCH 407/466] Update Mobile-Expensify submodule version to 9.4.61-4 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index bd5bbaf280b9..86e17219c82e 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit bd5bbaf280b91c5a472ca0c07533ff62809ebb8e +Subproject commit 86e17219c82e046baa095dd466772fb10302f030 From f6c314ca5ad861e257769813a307e9f4608774e7 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:31:46 +0530 Subject: [PATCH 408/466] Ignore feed and bank account filters in similarSearchHash so reconciliation searchKey is preserved --- src/libs/SearchQueryUtils.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index d76856248cf2..a3e083be9842 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -576,9 +576,24 @@ function getQueryHashes(query: SearchQueryJSON) { filterSet.add(exactMatchIdentity); } - // Certain filters shouldn't affect whether two searchers are similar or not, since they dont - // actually filter out results - const similarSearchIgnoredFilters = new Set([CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY]); + // Only filters that are part of a suggested search's base query should affect the + // similarSearchHash (i.e. the tab identity). All other flat filters are user-applied + // refinements within the same tab and must not change the tab identity or the + // backend searchKey derived from it. FEED is intentionally excluded because it is + // a user-applied refinement in reconciliation even though it is part of the + // UNAPPROVED_CARD base query — UNAPPROVED_CARD is still uniquely identified by its + // groupBy and status. + const similarSearchIdentityFilters = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN, + CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, + CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER, + CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER, + CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE, + CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE, + ]); // Certain filters' values are significant in deciding which search we are on, so we want to include // their value when computing the similarSearchHash @@ -599,7 +614,7 @@ function getQueryHashes(query: SearchQueryJSON) { continue; } - if (!similarSearchIgnoredFilters.has(filterKey)) { + if (similarSearchIdentityFilters.has(filterKey)) { filterSet.add(filterKey); } From d60b0671eb1233774ae6ef24303f6b736fcf2216 Mon Sep 17 00:00:00 2001 From: "Aimane Chnaif (via MelvinBot)" Date: Wed, 26 Aug 2026 18:01:40 +0000 Subject: [PATCH 409/466] Fix required-field error appearing on upload in signer info documents step Co-authored-by: Aimane Chnaif --- .../EnterSignerInfo/subSteps/UploadDocuments.tsx | 2 +- .../NonUSD/SignerInfo/subSteps/UploadDocuments.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx index 02d60c340150..2722a7cffb66 100644 --- a/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx +++ b/src/pages/ReimbursementAccount/EnterSignerInfo/subSteps/UploadDocuments.tsx @@ -75,7 +75,6 @@ function UploadDocuments({onNext, isEditing, policyID}: UploadDocumentsProps) { const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { - setIsPDSandFSGDownloadedTouched(true); return getFieldRequiredErrors(values, STEP_FIELDS, translate); }, [STEP_FIELDS, translate], @@ -126,6 +125,7 @@ function UploadDocuments({onNext, isEditing, policyID}: UploadDocumentsProps) { formID={ONYXKEYS.FORMS.ENTER_SINGER_INFO_FORM} submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')} onSubmit={handleSubmitWithDownload} + onBeforeSubmit={() => setIsPDSandFSGDownloadedTouched(true)} validate={validate} style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={[styles.mb0]} diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx index 8e3c7e4ff0d5..2bf063c8a602 100644 --- a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx +++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/subSteps/UploadDocuments.tsx @@ -70,7 +70,6 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) { const STEP_FIELDS: Array> = [COPY_OF_ID, ADDRESS_PROOF, PROOF_OF_DIRECTORS, CODICE_FISCALE]; const validate = (values: FormOnyxValues): FormInputErrors => { - setIsPDSandFSGDownloadedTouched(true); return getFieldRequiredErrors(values, STEP_FIELDS, translate); }; @@ -122,6 +121,7 @@ function UploadDocuments({onNext, isEditing}: UploadDocumentsProps) { formID={ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM} submitButtonText={translate(isEditing ? 'common.confirm' : 'common.next')} onSubmit={handleSubmitWithDownload} + onBeforeSubmit={() => setIsPDSandFSGDownloadedTouched(true)} validate={validate} style={[styles.mh5, styles.flexGrow1]} submitButtonStyles={[styles.mb0]} From f6bcd25d8cb8aa2e88ab949599946fe5e243fc07 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 18:42:33 +0000 Subject: [PATCH 410/466] Auto-tighten eslint-seatbelt baseline --- config/eslint/eslint.seatbelt.tsv | 1 - 1 file changed, 1 deletion(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 3e6374057afd..b60605ec90a3 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -830,7 +830,6 @@ "../../src/libs/actions/Wallet.ts" "no-restricted-syntax" 1 "../../src/libs/actions/Welcome/OnboardingFlow.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/libs/actions/Welcome/index.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/Workflow.ts" "no-restricted-syntax" 1 "../../src/libs/actions/__mocks__/App.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/__mocks__/App.ts" "no-restricted-syntax" 2 "../../src/libs/actions/__mocks__/OnyxUpdates.ts" "no-restricted-syntax" 1 From 8941661097169ec8d3cc1ac2775902cbb288cadd Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 20:33:10 +0000 Subject: [PATCH 411/466] Update Mobile-Expensify submodule to 4dfc345 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 86e17219c82e..4dfc345cf7da 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 86e17219c82e046baa095dd466772fb10302f030 +Subproject commit 4dfc345cf7dacc788b4de3bee6365fcffe1a78d4 From c6600c960befdddb0fac3eac0dabc8399c34e3e6 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 20:46:30 +0000 Subject: [PATCH 412/466] Update version to 9.4.62-0 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 4 ++-- ios/NotificationServiceExtension/Info.plist | 4 ++-- ios/ShareViewController/Info.plist | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index ad0d9a13674e..f0ddfbfd4b0f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046104 - versionName "9.4.61-4" + versionCode 1009046200 + versionName "9.4.62-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index cb3f49e18a8f..49c9bdf0cbbd 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.4.61 + 9.4.62 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.61.4 + 9.4.62.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 738fede7b438..622580eae655 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.61 + 9.4.62 CFBundleVersion - 9.4.61.4 + 9.4.62.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 4dabfd83f67e..3d988bc6d48c 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.61 + 9.4.62 CFBundleVersion - 9.4.61.4 + 9.4.62.0 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index c7fdf02fa295..58ef24c5974c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.61-4", + "version": "9.4.62-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.61-4", + "version": "9.4.62-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 5d8a4f23b6a2..a46bf9487883 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.61-4", + "version": "9.4.62-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 24ae4d8340a0a34397ea94522ac44dbb5c2cfb97 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 20:46:30 +0000 Subject: [PATCH 413/466] Update Mobile-Expensify submodule version to 9.4.62-0 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 4dfc345cf7da..af1ea6bfcd80 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 4dfc345cf7dacc788b4de3bee6365fcffe1a78d4 +Subproject commit af1ea6bfcd80c2f63a0b22cc5d0a0c26f3644855 From f9c8c6075a7847ba4059da538ac283e7283f5d68 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 20:48:35 +0000 Subject: [PATCH 414/466] Update version to 9.4.62-1 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index f0ddfbfd4b0f..daa19e7588b8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046200 - versionName "9.4.62-0" + versionCode 1009046201 + versionName "9.4.62-1" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 49c9bdf0cbbd..b4823630715e 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.62.0 + 9.4.62.1 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 622580eae655..7533e7914478 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.62 CFBundleVersion - 9.4.62.0 + 9.4.62.1 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 3d988bc6d48c..ac3e6d82262c 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.62 CFBundleVersion - 9.4.62.0 + 9.4.62.1 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 58ef24c5974c..412065ca3901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.62-0", + "version": "9.4.62-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.62-0", + "version": "9.4.62-1", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index a46bf9487883..611ae426c563 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.62-0", + "version": "9.4.62-1", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From ca1a58c05ca3754865192bfbc2893692553452fb Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 20:48:35 +0000 Subject: [PATCH 415/466] Update Mobile-Expensify submodule version to 9.4.62-1 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index af1ea6bfcd80..e7c0c59dfac2 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit af1ea6bfcd80c2f63a0b22cc5d0a0c26f3644855 +Subproject commit e7c0c59dfac2d6060a040dbea9e088bc8c7e1b02 From 18a6e603bd45c4eb4029bef52f96695257430a3a Mon Sep 17 00:00:00 2001 From: Andrew Rosiclair Date: Wed, 26 Aug 2026 16:59:26 -0400 Subject: [PATCH 416/466] log optimisticData --- src/libs/API/makeRequest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/API/makeRequest.ts b/src/libs/API/makeRequest.ts index 563a3684f37d..05139c6066e5 100644 --- a/src/libs/API/makeRequest.ts +++ b/src/libs/API/makeRequest.ts @@ -109,7 +109,7 @@ function prepareRequest( const {optimisticData, successData, failureData, ...onyxDataWithoutOptimisticData} = onyxData; if (optimisticData && shouldApplyOptimisticData) { - Log.info('[API] Applying optimistic data', false, {command, type}); + Log.info('[API] Applying optimistic data', false, {command, type}, undefined, optimisticData); Onyx.update(optimisticData); } From 630e8de3d7f0cdf3339a596eae01d3bb92f3d0f6 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Wed, 26 Aug 2026 21:15:45 +0000 Subject: [PATCH 417/466] Update Mobile-Expensify submodule to fc7a42b --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index e7c0c59dfac2..fc7a42bec8fa 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit e7c0c59dfac2d6060a040dbea9e088bc8c7e1b02 +Subproject commit fc7a42bec8fac5a4243551d52f8f37f7621fefba From a6f2ec0f0472ab4e08c054432b691c4029e12884 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Wed, 26 Aug 2026 21:22:44 +0000 Subject: [PATCH 418/466] Fix false Missing Merchant RBR on self-DM split submitted to workspace Co-authored-by: Kevin Brian Bader --- src/libs/actions/IOU/SearchUpdate.ts | 10 ++++ tests/actions/IOU/SearchUpdateTest.ts | 75 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/libs/actions/IOU/SearchUpdate.ts b/src/libs/actions/IOU/SearchUpdate.ts index 614d642e595a..b9c60b41254a 100644 --- a/src/libs/actions/IOU/SearchUpdate.ts +++ b/src/libs/actions/IOU/SearchUpdate.ts @@ -174,6 +174,16 @@ function getSearchOnyxUpdate({ ...(transactionThreadReportID && {transactionThreadReportID}), ...(isFromOneTransactionReport && {isFromOneTransactionReport}), ...transaction, + // Onyx.merge cannot clear a key by spreading `undefined`, so a stale snapshot `modifiedMerchant` would + // survive when the freshly built transaction has none. This happens for a self-DM split submitted to a + // workspace: the split snapshot inherited the original expense's `(none)`/`Expense` placeholder + // `modifiedMerchant` at creation time, and the submit only updates `merchant`. Because `isMerchantMissing` + // reads `modifiedMerchant` before `merchant`, that stale placeholder shows a false "Missing Merchant" + // error. Align `modifiedMerchant` with the transaction's own merchant when it isn't a genuine edit so the + // snapshot can't disagree with the merchant it represents (same approach the distance split already uses). + // A real edited `modifiedMerchant` is truthy and is preserved. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty modifiedMerchant is not a genuine edit, so fall back to merchant just like `undefined`. + modifiedMerchant: transaction.modifiedMerchant || transaction.merchant, }; if (policy) { baseSnapshotData[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy; diff --git a/tests/actions/IOU/SearchUpdateTest.ts b/tests/actions/IOU/SearchUpdateTest.ts index 16915c2d8957..9abed80986e5 100644 --- a/tests/actions/IOU/SearchUpdateTest.ts +++ b/tests/actions/IOU/SearchUpdateTest.ts @@ -591,5 +591,80 @@ describe('actions/IOU', () => { // false and the page renders "Nothing to show" even though the transaction data was merged in. expect(cannedUpdate?.value).toHaveProperty('search.hash', cannedExpensesHash); }); + + it('aligns modifiedMerchant with the merchant so a stale placeholder cannot survive the Onyx.merge', () => { + // Repro of #99500: a self-DM split submitted to a workspace inherits a stale `(none)` placeholder + // `modifiedMerchant` in its snapshot at split-creation time. Because `isMerchantMissing` reads + // `modifiedMerchant` before `merchant`, spreading the fresh transaction (which has no modifiedMerchant) + // via Onyx.merge would keep the stale placeholder and show a false "Missing Merchant" error. The + // snapshot write must overwrite modifiedMerchant with the transaction's merchant so the stale value is + // replaced by the merchant the user actually entered. + const iouReport: Report = { + ...createRandomReport(2, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const transaction = { + ...createRandomTransaction(1), + reimbursable: true, + merchant: 'Coffee Shop', + modifiedMerchant: undefined, + }; + + const result = getSearchOnyxUpdate({ + transaction, + participant: {accountID: 42, login: 'test@test.com'}, + iouReport, + iouAction: undefined, + policy: undefined, + transactionThreadReportID: undefined, + isFromOneTransactionReport: false, + isInvoice: false, + }); + + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`; + const update = result?.optimisticData?.find((u) => u.key === snapshotKey); + expect(update).toBeDefined(); + + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; + expect(update?.value).toHaveProperty(['data', transactionKey, 'merchant'], 'Coffee Shop'); + expect(update?.value).toHaveProperty(['data', transactionKey, 'modifiedMerchant'], 'Coffee Shop'); + }); + + it('preserves a genuinely edited modifiedMerchant in the snapshot', () => { + // The alignment must only overwrite an absent modifiedMerchant. A real edited merchant (or a distance + // rate string) is truthy and must be preserved so the Search view keeps showing it. + const iouReport: Report = { + ...createRandomReport(2, undefined), + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const transaction = { + ...createRandomTransaction(1), + reimbursable: true, + merchant: 'Coffee Shop', + modifiedMerchant: 'Edited Merchant', + }; + + const result = getSearchOnyxUpdate({ + transaction, + participant: {accountID: 42, login: 'test@test.com'}, + iouReport, + iouAction: undefined, + policy: undefined, + transactionThreadReportID: undefined, + isFromOneTransactionReport: false, + isInvoice: false, + }); + + const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`; + const update = result?.optimisticData?.find((u) => u.key === snapshotKey); + expect(update).toBeDefined(); + + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; + expect(update?.value).toHaveProperty(['data', transactionKey, 'modifiedMerchant'], 'Edited Merchant'); + }); }); }); From e4a96d0e3bccd40b80a53b4a14fb77bcac0477f1 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:36:57 -1000 Subject: [PATCH 419/466] Update Upcoming-Integrations-And-Accounting.md --- .../Unlisted/Upcoming-Integrations-And-Accounting.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md index 9b1738d28900..f2d65c7aab14 100644 --- a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md +++ b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md @@ -1,7 +1,7 @@ --- title: Upcoming Features – Integrations & Accounting (New Expensify) -description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, DualEntry, NetSuite REST, Greenhouse, Ashby, Lever, Campfire, Workable, and HR imports. Not for public search indexing. -keywords: [upcoming, coming soon, planned, roadmap, Campfire, Rillet, Puzzle.io, DualEntry, Greenhouse, Ashby, Lever, Workable, Microsoft Dynamics 365, Microsoft Dynamics 365 Business Central, Dynamics, D365, NetSuite REST API, SuiteTalk, merge.dev, HR import, HRIS, OAuth, Integration Server, accounting integration] +description: Internal AgentZero reference for planned and in-development accounting and integration features in New Expensify, including Puzzle.io, Microsoft Dynamics 365 Business Central, DualEntry, NetSuite REST, Greenhouse, Ashby, Lever, Campfire, Workable, and HR imports. Not for public search indexing. +keywords: [upcoming, coming soon, planned, roadmap, Campfire, Puzzle.io, DualEntry, Greenhouse, Ashby, Lever, Workable, Microsoft Dynamics 365, Microsoft Dynamics 365 Business Central, Dynamics, D365, NetSuite REST API, SuiteTalk, merge.dev, HR import, HRIS, OAuth, Integration Server, accounting integration] noindex: true sitemap: false internalScope: For AgentZero and internal tooling only. Use this page to give accurate, forward-looking answers about accounting and integration features that are planned but not yet generally available. Do not expose this URL in user-facing help. @@ -11,7 +11,6 @@ internalScope: For AgentZero and internal tooling only. Use this page to give ac This page is an **AgentZero reference** for accounting and integration features that are planned or in development for New Expensify. Use it to answer "is this coming soon?" questions accurately. These items are subject to change. -- **Rillet integration** – Accounting integration with Rillet, a modern general ledger, enabling direct sync of expenses and reports. - **Puzzle.io integration** – Accounting integration with Puzzle.io, adding support for startups and SMBs using Puzzle as their accounting system. - **Microsoft Dynamics 365 Business Central integration** – Accounting integration with Microsoft Dynamics 365 Business Central, covering a significant share of accounting users who currently rely on manual CSV/API workarounds. - **Campfire** – Accounting integration with Campfire, an AI-powered ERP and general ledger, enabling customers to import accounting data, export expenses and card spend, and keep reimbursements and settlements in sync without relying on CSV exports or custom workarounds. @@ -22,3 +21,4 @@ This page is an **AgentZero reference** for accounting and integration features - **Sage Accounting** – Accounting integration with Sage Accounting, supporting core accounting workflows for businesses in the UK and Europe and making it easier to connect, import, export, and sync accounting data directly with Expensify. - **NetSuite REST API migration** – Move the NetSuite integration from the legacy SuiteTalk SOAP API to the modern REST API, improving reliability, sync speed, and long-term supportability. - **Extend OAuth to the Integration Server (IS)** – Extend the OAuth framework (originally built for MCP integrations) to the Integration Server API, making it easier for third-party developers to build secure integrations with Expensify. +- From 37d253aa4acd268486e025cd3d6bf3a630032bd4 Mon Sep 17 00:00:00 2001 From: "Kevin Brian Bader (via MelvinBot)" Date: Wed, 26 Aug 2026 23:50:35 +0000 Subject: [PATCH 420/466] Clear stale modifiedMerchant with null and cover placeholder cases Co-authored-by: Kevin Brian Bader --- src/libs/actions/IOU/SearchUpdate.ts | 22 ++++----- tests/actions/IOU/SearchUpdateTest.ts | 67 ++++++++++----------------- 2 files changed, 33 insertions(+), 56 deletions(-) diff --git a/src/libs/actions/IOU/SearchUpdate.ts b/src/libs/actions/IOU/SearchUpdate.ts index b9c60b41254a..1768cdf53126 100644 --- a/src/libs/actions/IOU/SearchUpdate.ts +++ b/src/libs/actions/IOU/SearchUpdate.ts @@ -3,6 +3,7 @@ import type {SearchQueryJSON} from '@components/Search/types'; import {isExpenseReport, isOptimisticPersonalDetail} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON, getFilterFromQuery} from '@libs/SearchQueryUtils'; import {getSuggestedSearches, isEligibleForStatus} from '@libs/SearchUIUtils'; +import {isInvalidMerchantValue} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -11,7 +12,7 @@ import type {Participant} from '@src/types/onyx/IOU'; import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; -import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -156,7 +157,7 @@ function getSearchOnyxUpdate({ } // Common transaction payload merged into every matching snapshot. - const baseSnapshotData: SearchResultDataType = {}; + const baseSnapshotData: NullishDeep = {}; baseSnapshotData[ONYXKEYS.PERSONAL_DETAILS_LIST] = { [toAccountID]: { accountID: toAccountID, @@ -170,20 +171,15 @@ function getSearchOnyxUpdate({ login: deprecatedCurrentUserPersonalDetails?.login, }, }; + const hasGenuineModifiedMerchant = !!transaction.modifiedMerchant && !isInvalidMerchantValue(transaction.modifiedMerchant); baseSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { ...(transactionThreadReportID && {transactionThreadReportID}), ...(isFromOneTransactionReport && {isFromOneTransactionReport}), ...transaction, - // Onyx.merge cannot clear a key by spreading `undefined`, so a stale snapshot `modifiedMerchant` would - // survive when the freshly built transaction has none. This happens for a self-DM split submitted to a - // workspace: the split snapshot inherited the original expense's `(none)`/`Expense` placeholder - // `modifiedMerchant` at creation time, and the submit only updates `merchant`. Because `isMerchantMissing` - // reads `modifiedMerchant` before `merchant`, that stale placeholder shows a false "Missing Merchant" - // error. Align `modifiedMerchant` with the transaction's own merchant when it isn't a genuine edit so the - // snapshot can't disagree with the merchant it represents (same approach the distance split already uses). - // A real edited `modifiedMerchant` is truthy and is preserved. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty modifiedMerchant is not a genuine edit, so fall back to merchant just like `undefined`. - modifiedMerchant: transaction.modifiedMerchant || transaction.merchant, + // Onyx.merge can't clear a key by spreading `undefined`, so a stale snapshot `modifiedMerchant` (e.g. the + // `(none)`/`Expense` placeholder a self-DM split inherits) would win over `merchant` in `isMerchantMissing` + // and show a false "Missing Merchant". Clear it with `null` unless it's a genuine user edit (#99500). + modifiedMerchant: hasGenuineModifiedMerchant ? transaction.modifiedMerchant : null, }; if (policy) { baseSnapshotData[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy; @@ -209,7 +205,7 @@ function getSearchOnyxUpdate({ return; } - const snapshotData: SearchResultDataType = {...baseSnapshotData}; + const snapshotData: NullishDeep = {...baseSnapshotData}; if (queryJSON.groupBy === CONST.SEARCH.GROUP_BY.FROM) { const groupKey = `${CONST.SEARCH.GROUP_PREFIX}${fromAccountID}` as const; diff --git a/tests/actions/IOU/SearchUpdateTest.ts b/tests/actions/IOU/SearchUpdateTest.ts index 9abed80986e5..4fef7b93c0da 100644 --- a/tests/actions/IOU/SearchUpdateTest.ts +++ b/tests/actions/IOU/SearchUpdateTest.ts @@ -592,13 +592,9 @@ describe('actions/IOU', () => { expect(cannedUpdate?.value).toHaveProperty('search.hash', cannedExpensesHash); }); - it('aligns modifiedMerchant with the merchant so a stale placeholder cannot survive the Onyx.merge', () => { - // Repro of #99500: a self-DM split submitted to a workspace inherits a stale `(none)` placeholder - // `modifiedMerchant` in its snapshot at split-creation time. Because `isMerchantMissing` reads - // `modifiedMerchant` before `merchant`, spreading the fresh transaction (which has no modifiedMerchant) - // via Onyx.merge would keep the stale placeholder and show a false "Missing Merchant" error. The - // snapshot write must overwrite modifiedMerchant with the transaction's merchant so the stale value is - // replaced by the merchant the user actually entered. + // Builds the snapshot update for a transaction whose `modifiedMerchant` starts at the given value, and + // returns the optimistic snapshot update plus its transaction key so each case only asserts on the outcome. + const getSnapshotUpdateForModifiedMerchant = (modifiedMerchant: string | undefined) => { const iouReport: Report = { ...createRandomReport(2, undefined), type: CONST.REPORT.TYPE.EXPENSE, @@ -609,7 +605,7 @@ describe('actions/IOU', () => { ...createRandomTransaction(1), reimbursable: true, merchant: 'Coffee Shop', - modifiedMerchant: undefined, + modifiedMerchant, }; const result = getSearchOnyxUpdate({ @@ -625,45 +621,30 @@ describe('actions/IOU', () => { const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`; const update = result?.optimisticData?.find((u) => u.key === snapshotKey); - expect(update).toBeDefined(); - const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; - expect(update?.value).toHaveProperty(['data', transactionKey, 'merchant'], 'Coffee Shop'); - expect(update?.value).toHaveProperty(['data', transactionKey, 'modifiedMerchant'], 'Coffee Shop'); - }); + return {update, transactionKey}; + }; + + // Repro of #99500: a self-DM split submitted to a workspace inherits a stale `(none)`/`Expense` placeholder + // `modifiedMerchant` in its snapshot at split-creation time. Because `isMerchantMissing` reads + // `modifiedMerchant` before `merchant`, spreading the fresh transaction via Onyx.merge would keep the stale + // placeholder and show a false "Missing Merchant". The snapshot write must clear it with `null` (which + // Onyx.merge honors) so `isMerchantMissing` falls through to the merchant the user actually entered. + it.each([undefined, '', CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, CONST.TRANSACTION.DEFAULT_MERCHANT])( + 'clears a non-genuine modifiedMerchant (%s) with null so the stale placeholder cannot survive the Onyx.merge', + (modifiedMerchant) => { + const {update, transactionKey} = getSnapshotUpdateForModifiedMerchant(modifiedMerchant); + expect(update).toBeDefined(); + expect(update?.value).toHaveProperty(['data', transactionKey, 'merchant'], 'Coffee Shop'); + expect(update?.value).toHaveProperty(['data', transactionKey, 'modifiedMerchant'], null); + }, + ); it('preserves a genuinely edited modifiedMerchant in the snapshot', () => { - // The alignment must only overwrite an absent modifiedMerchant. A real edited merchant (or a distance - // rate string) is truthy and must be preserved so the Search view keeps showing it. - const iouReport: Report = { - ...createRandomReport(2, undefined), - type: CONST.REPORT.TYPE.EXPENSE, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - }; - const transaction = { - ...createRandomTransaction(1), - reimbursable: true, - merchant: 'Coffee Shop', - modifiedMerchant: 'Edited Merchant', - }; - - const result = getSearchOnyxUpdate({ - transaction, - participant: {accountID: 42, login: 'test@test.com'}, - iouReport, - iouAction: undefined, - policy: undefined, - transactionThreadReportID: undefined, - isFromOneTransactionReport: false, - isInvoice: false, - }); - - const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${unapprovedCashHash}`; - const update = result?.optimisticData?.find((u) => u.key === snapshotKey); + // The clear must only apply to an absent/placeholder modifiedMerchant. A real edited merchant is a + // genuine value and must be preserved so the Search view keeps showing it. + const {update, transactionKey} = getSnapshotUpdateForModifiedMerchant('Edited Merchant'); expect(update).toBeDefined(); - - const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; expect(update?.value).toHaveProperty(['data', transactionKey, 'modifiedMerchant'], 'Edited Merchant'); }); }); From 34d1d3d054e159f7c2bb70191af655b9eb3b0e4b Mon Sep 17 00:00:00 2001 From: huutech <20178761+huult@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:40:00 +0700 Subject: [PATCH 421/466] rename splitExpenseTransactionID to editSplitExpenseTransactionID --- src/ROUTES.ts | 10 ++++++---- src/libs/Navigation/types.ts | 2 +- src/pages/iou/DynamicSplitExpenseEditPage.tsx | 2 +- tests/navigation/splitExpenseDynamicRouteTests.ts | 11 +++++++++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2aa339fe2255..54ec49de1f6a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -446,13 +446,15 @@ const DYNAMIC_ROUTES = { queryParams: ['action', 'iouType', 'orderWeight', 'transactionID', 'reportID', 'reportActionID'], }, SPLIT_EXPENSE_EDIT: { - path: 'split-expense/edit/:reportID/:splitExpenseTransactionID?', + // `editSplitExpenseTransactionID` is deliberately not named `splitExpenseTransactionID` so this path param cannot be + // shadowed by the overview route's `splitExpenseTransactionID` query param when this route is nested under it. + path: 'split-expense/edit/:reportID/:editSplitExpenseTransactionID?', entryScreens: [SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE, SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_SEARCH], - getRoute: (reportID: string, splitExpenseTransactionID?: string) => { - if (!splitExpenseTransactionID) { + getRoute: (reportID: string, editSplitExpenseTransactionID?: string) => { + if (!editSplitExpenseTransactionID) { return `split-expense/edit/${reportID}` as const; } - return `split-expense/edit/${reportID}/${splitExpenseTransactionID}` as const; + return `split-expense/edit/${reportID}/${editSplitExpenseTransactionID}` as const; }, }, MONEY_REQUEST_STEP_MERCHANT: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ceac1fb57cc9..49adac5f6b6f 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2744,7 +2744,7 @@ type RightModalNavigatorParamList = { reportID: string; /** Inherited from the base path's `originalTransactionID` query param (see DYNAMIC_SPLIT_EXPENSE). */ originalTransactionID: string; - splitExpenseTransactionID?: string; + editSplitExpenseTransactionID?: string; }; [SCREENS.RIGHT_MODAL.ADD_EXISTING_EXPENSE]: NavigatorScreenParams<{reportId: string | undefined}>; [SCREENS.RIGHT_MODAL.SCHEDULE_CALL]: NavigatorScreenParams; diff --git a/src/pages/iou/DynamicSplitExpenseEditPage.tsx b/src/pages/iou/DynamicSplitExpenseEditPage.tsx index dc435d62a23b..9c1b5089d962 100644 --- a/src/pages/iou/DynamicSplitExpenseEditPage.tsx +++ b/src/pages/iou/DynamicSplitExpenseEditPage.tsx @@ -99,7 +99,7 @@ function DynamicSplitExpenseEditPage({route}: DynamicSplitExpenseEditPageProps) const {convertToDisplayString, getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const {currentSearchResults} = useSearchResultsContext(); - const {reportID, originalTransactionID: transactionID, splitExpenseTransactionID = ''} = route.params; + const {reportID, originalTransactionID: transactionID, editSplitExpenseTransactionID: splitExpenseTransactionID = ''} = route.params; const backTo = useDynamicBackPath(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.path); const [splitExpenseDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`); diff --git a/tests/navigation/splitExpenseDynamicRouteTests.ts b/tests/navigation/splitExpenseDynamicRouteTests.ts index e1c4ad01a041..7a52be7ae669 100644 --- a/tests/navigation/splitExpenseDynamicRouteTests.ts +++ b/tests/navigation/splitExpenseDynamicRouteTests.ts @@ -82,6 +82,17 @@ describe('split expense dynamic routes', () => { expect(route?.params).toMatchObject({splitReportID: SPLIT_REPORT_ID, originalTransactionID: TRANSACTION_ID}); }); + it('resolves the tapped split, not the split inherited from the edit-splits overview query, when their transaction IDs differ', () => { + const OTHER_SPLIT_TRANSACTION_ID = '1112223334445556'; + const overviewUrl = createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_SPLIT_EXPENSE.getRoute(SPLIT_REPORT_ID, TRANSACTION_ID, SPLIT_TRANSACTION_ID), `/r/${CHAT_REPORT_ID}`); + const editUrl = createDynamicRoute(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.getRoute(SPLIT_REPORT_ID, OTHER_SPLIT_TRANSACTION_ID), overviewUrl); + + const route = getFocusedRoute(editUrl); + + expect(route?.name).toBe(SCREENS.MONEY_REQUEST.DYNAMIC_SPLIT_EXPENSE_EDIT); + expect(route?.params).toMatchObject({editSplitExpenseTransactionID: OTHER_SPLIT_TRANSACTION_ID}); + }); + it('does not collide with the transactionID query param used by expense step screens opened from the split edit page', () => { const overviewUrl = createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_SPLIT_EXPENSE.getRoute(SPLIT_REPORT_ID, TRANSACTION_ID), `/r/${CHAT_REPORT_ID}`); const editUrl = createDynamicRoute(DYNAMIC_ROUTES.SPLIT_EXPENSE_EDIT.getRoute(SPLIT_REPORT_ID, SPLIT_TRANSACTION_ID), overviewUrl); From 9a3973e3fbc80ab7484696b19fcc15b03b9c67f9 Mon Sep 17 00:00:00 2001 From: Nguyen Van Duc <129500732+dukenv0307@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:59:15 +0700 Subject: [PATCH 422/466] Update src/hooks/useLoadDepositAccountSetup.ts Co-authored-by: Andrew Rosiclair --- src/hooks/useLoadDepositAccountSetup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useLoadDepositAccountSetup.ts b/src/hooks/useLoadDepositAccountSetup.ts index 419cb8ca57d6..b48a709182e9 100644 --- a/src/hooks/useLoadDepositAccountSetup.ts +++ b/src/hooks/useLoadDepositAccountSetup.ts @@ -33,7 +33,7 @@ function useLoadDepositAccountSetup(): boolean { // Reading the flag through an effect event keeps it out of the effect's dependencies, so the request the flag // itself triggers can't feed back and re-run the effect. - const openDepositAccountSetupEvent = useEffectEvent(() => { + const loadDepositAccountSetup = useEffectEvent(() => { if (!isFlowEntryMount && isLoadingDepositAccountSetup !== undefined) { return; } From bfdc0fd3a96a6a87c15258a592d1d442ada235ad Mon Sep 17 00:00:00 2001 From: Nguyen Van Duc <129500732+dukenv0307@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:59:31 +0700 Subject: [PATCH 423/466] Update src/hooks/useLoadDepositAccountSetup.ts Co-authored-by: Andrew Rosiclair --- src/hooks/useLoadDepositAccountSetup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useLoadDepositAccountSetup.ts b/src/hooks/useLoadDepositAccountSetup.ts index b48a709182e9..4ba821654b95 100644 --- a/src/hooks/useLoadDepositAccountSetup.ts +++ b/src/hooks/useLoadDepositAccountSetup.ts @@ -46,7 +46,7 @@ function useLoadDepositAccountSetup(): boolean { if (isReadingFlagFromOnyx) { return; } - openDepositAccountSetupEvent(); + loadDepositAccountSetup(); }, [isReadingFlagFromOnyx]); return isLoadingDepositAccountSetup ?? true; From 7ab16ea3d8a86c3316a2c0434159ae93989a166d Mon Sep 17 00:00:00 2001 From: Nguyen Van Duc <129500732+dukenv0307@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:01:21 +0700 Subject: [PATCH 424/466] Update src/pages/settings/Wallet/InternationalDepositAccount/utils.ts Co-authored-by: Andrew Rosiclair --- src/pages/settings/Wallet/InternationalDepositAccount/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts index 979e8e887813..800ec4aafd0e 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts +++ b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts @@ -171,6 +171,7 @@ function getAccountDetailsFieldsMap(accountDetailsFields: CorpayFieldsMap | unde return fields; } + // When we need to collect international bank account details, we'll check the initial set of bank account fields and mark the international fields as required. This minimizes the need to ask for more details in the next page. const nextFields: CorpayFieldsMap = {}; for (const [fieldName, field] of Object.entries(fields)) { nextFields[fieldName] = isInternationalBankAccountFieldLabel(field.label) From 27eeb3c33cc71a172decdd2968e48d8ea18ac594 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 27 Aug 2026 09:08:07 +0700 Subject: [PATCH 425/466] move getSkippedPages to component --- .../InternationalDepositAccountContent.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx index 532a7ea3d503..6c2b0394760a 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -65,20 +65,6 @@ const pages = [ {pageName: CONST.CORPAY_FIELDS.PAGE_NAME.SUCCESS, component: Success}, ]; -function getSkippedPages(skipAccountTypeStep: boolean, skipAccountHolderInformationStep: boolean, skipInternationalBankAccountDetailsStep: boolean) { - const skippedSteps = []; - if (skipInternationalBankAccountDetailsStep) { - skippedSteps.push(CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS); - } - if (skipAccountTypeStep) { - skippedSteps.push(CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE); - } - if (skipAccountHolderInformationStep) { - skippedSteps.push(CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS); - } - return skippedSteps; -} - function InternationalDepositAccountContent({ privatePersonalDetails, corpayFields, @@ -108,7 +94,16 @@ function InternationalDepositAccountContent({ const startFrom = getInitialSubstep(values, fieldsMap, skipInternationalBankAccountDetailsStep); - const skippedPages = getSkippedPages(skipAccountTypeStep, skipAccountHolderInformationStep, skipInternationalBankAccountDetailsStep); + const skippedPages = []; + if (skipInternationalBankAccountDetailsStep) { + skippedPages.push(CONST.CORPAY_FIELDS.PAGE_NAME.INTERNATIONAL_BANK_ACCOUNT_DETAILS); + } + if (skipAccountTypeStep) { + skippedPages.push(CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_TYPE); + } + if (skipAccountHolderInformationStep) { + skippedPages.push(CONST.CORPAY_FIELDS.PAGE_NAME.ACCOUNT_HOLDER_DETAILS); + } const route = useRoute>(); const topmostFullScreenRoute = useRootNavigationState((state) => state?.routes.findLast((r) => isFullScreenName(r.name))); From f8184884e4e900672a1532c015f94717f38a191d Mon Sep 17 00:00:00 2001 From: "Hans Vu (via MelvinBot)" Date: Thu, 27 Aug 2026 02:20:33 +0000 Subject: [PATCH 426/466] Fix: exclude offline-deleted flag for review rule from Copy settings preview Co-authored-by: Hans Vu --- src/pages/workspace/duplicate/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/duplicate/utils.ts b/src/pages/workspace/duplicate/utils.ts index 876efdc64d50..fc60abfa2bfc 100644 --- a/src/pages/workspace/duplicate/utils.ts +++ b/src/pages/workspace/duplicate/utils.ts @@ -68,7 +68,7 @@ function getWorkspaceRules(policy: Policy | undefined, translate: LocaleContextP if (enabledCategories.some((category) => categoryHasAnyRequireFieldsRule(category))) { total.push(translate('workspace.rules.tabs.requireFields')); } - if (enabledCategories.some((category) => hasExplicitFlagAmount(category.maxExpenseAmount))) { + if (enabledCategories.some((category) => hasExplicitFlagAmount(category.maxExpenseAmount) && category.pendingFields?.maxExpenseAmount !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { total.push(translate('workspace.rules.tabs.flagForReview')); } From 3c3d343aa9350ee58e837a22b0d7a8fa3d44f240 Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Thu, 27 Aug 2026 03:10:27 +0000 Subject: [PATCH 427/466] Revert native BaseTextInput split; keep single shared index Per review feedback, the separate index.native.tsx variant added more complexity than the removed onKeyPress wiring was worth. Fold it back into the shared index.tsx. Co-authored-by: mkhutornyi --- .../TextInput/BaseTextInput/index.native.tsx | 28 ------------------- .../TextInput/BaseTextInput/index.tsx | 6 ++-- 2 files changed, 3 insertions(+), 31 deletions(-) delete mode 100644 src/components/TextInput/BaseTextInput/index.native.tsx diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx deleted file mode 100644 index 0f3626049821..000000000000 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import variables from '@styles/variables'; - -import React from 'react'; - -import type {BaseTextInputProps} from './types'; - -import BaseTextInputImplementation from './implementation'; - -/** - * Native variant of `autoGrowSingleLine`: grows the input up to `variables.textInputAutoGrowMaxHeight` then scrolls - * internally. Native submits via `submitBehavior`/`returnKeyType` and the consumer sanitizes line breaks, so unlike - * web there is no Shift+Enter handling and `onKeyPress` is left untouched. - * Pass `useAutoFocusInput(true)` when auto-focusing so the caret lands after an existing value. - */ -function BaseTextInput({ref, autoGrowSingleLine = false, ...props}: BaseTextInputProps) { - return ( - - ); -} - -export default BaseTextInput; diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 8fc061ffacd1..2b286dc78bf4 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -11,9 +11,9 @@ import type {BaseTextInputProps} from './types'; import BaseTextInputImplementation from './implementation'; /** - * Web/desktop variant of `autoGrowSingleLine`: grows the input up to `variables.textInputAutoGrowMaxHeight` then - * scrolls internally, and the return key (incl. Shift+Enter) submits instead of inserting a line break. The native - * counterpart (`index.native.tsx`) has no keypress wiring — it submits via `submitBehavior`/`returnKeyType`. + * `autoGrowSingleLine` grows the input up to `variables.textInputAutoGrowMaxHeight` then scrolls internally, while the + * return key (incl. Shift+Enter) submits instead of inserting a line break. Only web passes a keyboard event to the + * keypress handler, so on native the wiring is a no-op and submitting is driven by `submitBehavior`/`returnKeyType`. * Pass `useAutoFocusInput(true)` when auto-focusing so the caret lands after an existing value. */ function BaseTextInput({ref, autoGrowSingleLine = false, ...props}: BaseTextInputProps) { From 3042363312396334fa5b749fcb8474961c370e2f Mon Sep 17 00:00:00 2001 From: "mkhutornyi (via MelvinBot)" Date: Thu, 27 Aug 2026 03:26:25 +0000 Subject: [PATCH 428/466] Update BaseTextInput tests for single shared component after native revert The native/web test split validated the reverted index.native.tsx variant. With one shared index.tsx, consolidate into a single BaseTextInputTest that covers grow/submit props and the web-only Shift+Enter wiring, and drop the now false native 'onKeyPress forwarded untouched' assertion. Co-authored-by: mkhutornyi --- tests/unit/BaseTextInputTest.tsx | 54 +++++++++++---- tests/unit/BaseTextInputWebTest.tsx | 101 ---------------------------- 2 files changed, 42 insertions(+), 113 deletions(-) delete mode 100644 tests/unit/BaseTextInputWebTest.tsx diff --git a/tests/unit/BaseTextInputTest.tsx b/tests/unit/BaseTextInputTest.tsx index 89bf02967604..5d1982077efc 100644 --- a/tests/unit/BaseTextInputTest.tsx +++ b/tests/unit/BaseTextInputTest.tsx @@ -6,9 +6,12 @@ import variables from '@styles/variables'; import React from 'react'; +// A key event shaped like what react-native-web hands to `onKeyPress`; the code under test only reads these fields. +type KeyEventLike = {key: string; shiftKey: boolean; preventDefault: () => void}; + /* eslint-disable react/no-unused-prop-types -- this types the props captured from the mock, not a component's declared PropTypes */ type ForwardedProps = { - onKeyPress?: (event: unknown) => void; + onKeyPress?: (event: KeyEventLike) => void; autoGrowHeight?: boolean; maxAutoGrowHeight?: number; submitBehavior?: string; @@ -16,8 +19,8 @@ type ForwardedProps = { }; /* eslint-enable react/no-unused-prop-types */ -// Capture the props BaseTextInput forwards to its implementation so we can inspect the resolved `onKeyPress` -// handler and the grow/submit props, without rendering the real input. +// Capture the props BaseTextInput forwards to its implementation so we can invoke the resolved `onKeyPress` +// handler and inspect the grow/submit props, without rendering the real input. const mockCaptureProps = jest.fn(); jest.mock('@components/TextInput/BaseTextInput/implementation', () => { function MockImplementation(props: ForwardedProps) { @@ -32,9 +35,9 @@ function getForwardedProps(): ForwardedProps { return mockCaptureProps.mock.calls.at(-1)?.[0] ?? {}; } -// jest-expo resolves the `.native` variant by default, so this file exercises the native implementation. Native -// intentionally skips the web-only Shift+Enter keypress wiring — see BaseTextInputWebTest for that behavior. -describe('BaseTextInput (native) - autoGrowSingleLine', () => { +// BaseTextInput is a single shared component across platforms. Only web actually passes a keyboard event to the +// keypress handler, but the Shift+Enter wiring below is exercised the same way in Jest regardless of platform. +describe('BaseTextInput - autoGrowSingleLine', () => { beforeEach(() => { mockCaptureProps.mockClear(); }); @@ -49,17 +52,44 @@ describe('BaseTextInput (native) - autoGrowSingleLine', () => { expect(forwarded.returnKeyType).toBe('go'); }); - it('forwards onKeyPress untouched even with the flag, adding no keypress subscription', () => { - const onKeyPress = jest.fn(); + it('submits and prevents the default line break on Shift+Enter', () => { + const onSubmitEditing = jest.fn(); render( , ); - // Native must not wrap onKeyPress — doing so subscribes the native input to every keystroke for no benefit. - expect(getForwardedProps()).toHaveProperty('onKeyPress', onKeyPress); + const preventDefault = jest.fn(); + getForwardedProps().onKeyPress?.({key: 'Enter', shiftKey: true, preventDefault}); + + expect(onSubmitEditing).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalled(); + }); + + it('does not submit on plain Enter', () => { + const onSubmitEditing = jest.fn(); + render( + , + ); + + getForwardedProps().onKeyPress?.({key: 'Enter', shiftKey: false, preventDefault: jest.fn()}); + + expect(onSubmitEditing).not.toHaveBeenCalled(); + }); + + it('does not prevent the default on Shift+Enter when there is no submit handler', () => { + render(); + + const preventDefault = jest.fn(); + getForwardedProps().onKeyPress?.({key: 'Enter', shiftKey: true, preventDefault}); + + // With nothing to submit there is no reason to swallow the keystroke. + expect(preventDefault).not.toHaveBeenCalled(); }); it('passes props through untouched without the flag', () => { @@ -67,7 +97,7 @@ describe('BaseTextInput (native) - autoGrowSingleLine', () => { render(); const forwarded = getForwardedProps(); - // The consumer's own handler is forwarded verbatim (not wrapped) and no grow/submit defaults are injected. + // Without the flag the consumer's own handler is forwarded verbatim (not wrapped) and no grow/submit defaults are injected. expect(forwarded).toHaveProperty('onKeyPress', onKeyPress); expect(forwarded.autoGrowHeight).toBeFalsy(); expect(forwarded.submitBehavior).toBeUndefined(); diff --git a/tests/unit/BaseTextInputWebTest.tsx b/tests/unit/BaseTextInputWebTest.tsx deleted file mode 100644 index 914bc6d54702..000000000000 --- a/tests/unit/BaseTextInputWebTest.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import {render} from '@testing-library/react-native'; - -import type BaseTextInputType from '@components/TextInput/BaseTextInput'; - -import variables from '@styles/variables'; - -import React from 'react'; - -// A key event shaped like what react-native-web hands to `onKeyPress`; the code under test only reads these fields. -type KeyEventLike = {key: string; shiftKey: boolean; preventDefault: () => void}; - -/* eslint-disable react/no-unused-prop-types -- this types the props captured from the mock, not a component's declared PropTypes */ -type ForwardedProps = { - onKeyPress?: (event: KeyEventLike) => void; - autoGrowHeight?: boolean; - maxAutoGrowHeight?: number; - submitBehavior?: string; - returnKeyType?: string; -}; -/* eslint-enable react/no-unused-prop-types */ - -// Capture the props BaseTextInput forwards to its implementation so we can invoke the resolved `onKeyPress` -// handler and inspect the grow/submit props, without rendering the real input. -const mockCaptureProps = jest.fn(); -jest.mock('@components/TextInput/BaseTextInput/implementation', () => { - function MockImplementation(props: ForwardedProps) { - mockCaptureProps(props); - return null; - } - return {__esModule: true, default: MockImplementation}; -}); - -// jest-expo resolves the `.native` variant by default, so we require the web entry point explicitly (with its -// `.tsx` extension) to exercise the web implementation and its Shift+Enter handling. -const baseTextInputModule: unknown = require('@components/TextInput/BaseTextInput/index.tsx'); - -// The `require` above yields `any`, and narrowing it to the module shape needs a type assertion that can't be -// avoided for this test-only web-entry-point escape hatch, so disable the rule on this line. -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const BaseTextInput = (baseTextInputModule as {default: typeof BaseTextInputType}).default; - -/** The props BaseTextInput forwarded to its implementation on the latest render. */ -function getForwardedProps(): ForwardedProps { - return mockCaptureProps.mock.calls.at(-1)?.[0] ?? {}; -} - -describe('BaseTextInput (web) - autoGrowSingleLine', () => { - beforeEach(() => { - mockCaptureProps.mockClear(); - }); - - it('submits and prevents the default line break on Shift+Enter', () => { - const onSubmitEditing = jest.fn(); - render( - , - ); - - const preventDefault = jest.fn(); - getForwardedProps().onKeyPress?.({key: 'Enter', shiftKey: true, preventDefault}); - - expect(onSubmitEditing).toHaveBeenCalledTimes(1); - expect(preventDefault).toHaveBeenCalled(); - }); - - it('does not submit on plain Enter', () => { - const onSubmitEditing = jest.fn(); - render( - , - ); - - getForwardedProps().onKeyPress?.({key: 'Enter', shiftKey: false, preventDefault: jest.fn()}); - - expect(onSubmitEditing).not.toHaveBeenCalled(); - }); - - it('does not prevent the default on Shift+Enter when there is no submit handler', () => { - render(); - - const preventDefault = jest.fn(); - getForwardedProps().onKeyPress?.({key: 'Enter', shiftKey: true, preventDefault}); - - // With nothing to submit there is no reason to swallow the keystroke. - expect(preventDefault).not.toHaveBeenCalled(); - }); - - it('enables the grow and submit props', () => { - render(); - - const forwarded = getForwardedProps(); - expect(forwarded.autoGrowHeight).toBe(true); - expect(forwarded.maxAutoGrowHeight).toBe(variables.textInputAutoGrowMaxHeight); - expect(forwarded.submitBehavior).toBe('blurAndSubmit'); - expect(forwarded.returnKeyType).toBe('go'); - }); -}); From ccdebdd4c320838250a018aee1010b8327d46384 Mon Sep 17 00:00:00 2001 From: "Stephanie Elliott (via MelvinBot)" Date: Thu, 27 Aug 2026 04:25:09 +0000 Subject: [PATCH 429/466] Update billing-and-subscriptions help docs for the free Submit plan Co-authored-by: Stephanie Elliott --- .../Compare-Collect-and-Control-Plans.md | 8 +++++--- .../Understand-Expensify-Pricing.md | 4 ++++ .../Change-Your-Workspace-Plan.md | 18 +++++++++++++++--- .../getting-started/Submit-Workspace.md | 2 +- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md index 16cd70a7fd92..3f6f411a8559 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md @@ -1,7 +1,7 @@ --- title: Compare Collect and Control Plans description: Learn the primary differences between Expensify's Collect and Control plans and which organizations each plan is designed for. -keywords: [Collect plan, Control plan, compare plans, Expensify plans, Workspace plans] +keywords: [Collect plan, Control plan, Submit plan, free plan, compare plans, Expensify plans, Workspace plans] internalScope: Audience is anyone evaluating Expensify plans. Covers the high-level differences between Collect and Control. Does not provide a complete feature list or pricing. retrievalIntent: Which plan should I choose contentType: topic @@ -11,13 +11,15 @@ order: 2 # Compare Collect and Control Plans -Expensify offers two workspace plans: +Expensify offers two paid workspace plans: - **Collect** for organizations that need straightforward expense management and reimbursements. - **Control** for organizations that need advanced spend management, automation, integrations, and administrative controls. This article provides a high-level comparison to help you choose the right workspace plan. For the complete feature comparison and current pricing, visit [Expensify Pricing](https://www.expensify.com/pricing). +Expensify also offers a free **Submit** plan for individual employees who want to code their expenses and submit them to an approver, even if their company hasn't adopted Expensify yet. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). + ## When to choose the Collect plan Collect is best for organizations that primarily want to: @@ -64,7 +66,7 @@ To learn more, see [Learn About Plan and Subscription Management](/articles/new- ## Does every workspace have a plan? -Yes. Every workspace uses either the Collect or Control plan. +Yes. Paid workspaces use either the Collect or Control plan. Expensify also offers a free [Submit workspace](/articles/new-expensify/getting-started/Submit-Workspace) that submitters can upgrade to a paid Collect or Control plan. ## Does the Expensify Card work with both plans? diff --git a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md index 647f48e8facc..a88f781f20bf 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md @@ -166,3 +166,7 @@ Yes. If you increase your subscription size or exceed it during an Annual subscr ## Which currencies does Expensify support for localized pricing? Expensify offers localized pricing for USD, GBP, EUR, AUD, and NZD billing. + +## Is there a free Expensify plan? + +Yes. Expensify offers a free Submit workspace for individual employees who want to code their expenses and submit them to an approver. It doesn't include premium features like approvals, payments, company cards, or accounting integrations. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md index b2a435220256..a1eccb683e1a 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md @@ -1,8 +1,8 @@ --- title: Change Your Workspace Plan description: Switch a Workspace's Plan type between Collect and Control. -keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, workspace admin] -internalScope: Audience is Workspace owners. Covers how to switch a Workspace between the Collect and Control plans. Does not cover the differences between plans or how to change a subscription. +keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, Submit plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, upgrade Submit workspace, workspace admin] +internalScope: Audience is Workspace owners. Covers how to switch a Workspace between the Collect and Control plans, and how upgrading a free Submit workspace works. Does not cover the differences between plans or how to change a subscription. retrievalIntent: How to switch a Workspace plan between Collect and Control. contentType: task platform: new @@ -11,7 +11,7 @@ order: 4 # Change Your Workspace Plan -Every workspace uses either the Collect or Control plan. Your workspace plan determines which features are available. If your needs change, you can switch the workspace's **Plan type**. +Paid workspaces use either the Collect or Control plan. Your workspace plan determines which features are available. If your needs change, you can switch the workspace's **Plan type**. To learn which plan is right for your organization, see [Compare Collect and Control Plans](/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans). @@ -35,6 +35,14 @@ If you have an Annual subscription, changes to your plan may be restricted. To l --- +## Upgrading a free Submit workspace + +If you have a free [Submit workspace](/articles/new-expensify/getting-started/Submit-Workspace), you can upgrade it to a paid Collect or Control plan to unlock premium features such as approvals, payments, company cards, and accounting integrations. An upgrade is also triggered automatically when an approver approves a report or when a member enables a premium feature. + +Upgrading a Submit workspace is one-way. You can't downgrade a paid workspace back to a Submit workspace. To use a Submit workspace again, delete the upgraded workspace and create a new Submit workspace. + +--- + ## Related articles - [View Your Plan and Subscription](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/View-Your-Plan-and-Subscription) @@ -60,3 +68,7 @@ This happens when you're on the Control plan with an Annual subscription. You've You can't downgrade the plan on an invoiced subscription from the app. Reach out to your Account Manager or Concierge to discuss or make changes to your subscription. +## Can I downgrade a paid workspace back to a Submit workspace? + +No. Once a Submit workspace is upgraded to Collect or Control, it can't be downgraded back to Submit. To use a Submit workspace again, delete the upgraded workspace and create a new one. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). + diff --git a/docs/articles/new-expensify/getting-started/Submit-Workspace.md b/docs/articles/new-expensify/getting-started/Submit-Workspace.md index 5ce5d28fd9e4..30dcde38edca 100644 --- a/docs/articles/new-expensify/getting-started/Submit-Workspace.md +++ b/docs/articles/new-expensify/getting-started/Submit-Workspace.md @@ -7,7 +7,7 @@ internalScope: Audience is employees who want to get reimbursed by their employe A Submit workspace is a free workspace in New Expensify for employees who want to get reimbursed by their employer. You can scan receipts, code your expenses with categories and tags, and submit reports to an approver — even if your company hasn't adopted Expensify yet. There's no cost to submit, and your approver doesn't need an account to receive your report. -Premium features are not included in a Submit workspace. To use approvals, payments, company cards, the Expensify Card, Per Diem, Expensify Travel, invoices, or accounting integrations, you'll need to [learn how to upgrade your workspace plan](https://github.com/articles/new-expensify/billing-and-subscriptions/Changing-Your-Workspace-Plan). +Premium features are not included in a Submit workspace. To use approvals, payments, company cards, the Expensify Card, Per Diem, Expensify Travel, invoices, or accounting integrations, you'll need to [learn how to upgrade your workspace plan](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan). --- From 994de17196c986d38bc2797ae1498fbc4e380e86 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:32:58 -1000 Subject: [PATCH 430/466] Update Compare-Collect-and-Control-Plans.md --- .../Compare-Collect-and-Control-Plans.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md index 3f6f411a8559..2e6aad0a2157 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Compare-Collect-and-Control-Plans.md @@ -11,14 +11,14 @@ order: 2 # Compare Collect and Control Plans -Expensify offers two paid workspace plans: +Expensify offers two workspace plans for organizations: - **Collect** for organizations that need straightforward expense management and reimbursements. - **Control** for organizations that need advanced spend management, automation, integrations, and administrative controls. This article provides a high-level comparison to help you choose the right workspace plan. For the complete feature comparison and current pricing, visit [Expensify Pricing](https://www.expensify.com/pricing). -Expensify also offers a free **Submit** plan for individual employees who want to code their expenses and submit them to an approver, even if their company hasn't adopted Expensify yet. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). +Expensify also offers a free **Submit** plan for individual employees who want to code their expenses and submit them to an approver if their company hasn't adopted Expensify yet. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). ## When to choose the Collect plan @@ -66,10 +66,10 @@ To learn more, see [Learn About Plan and Subscription Management](/articles/new- ## Does every workspace have a plan? -Yes. Paid workspaces use either the Collect or Control plan. Expensify also offers a free [Submit workspace](/articles/new-expensify/getting-started/Submit-Workspace) that submitters can upgrade to a paid Collect or Control plan. +Yes. Every workspace has a plan that controls which features are available on the workspace. -## Does the Expensify Card work with both plans? +## Does the Expensify Card work with both Collect and Control plans? -Yes. The Expensify Card works with both plans. Certain subscription pricing benefits are only available with eligible Control workspaces. +Yes. The Expensify Card works with both Collect and Control plans. Certain subscription pricing benefits are only available with eligible Control workspaces. To learn more, see [Understand Expensify Pricing](/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing). From aa0064269333bcc6025016e366c2ce28d9b46f15 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:34:23 -1000 Subject: [PATCH 431/466] Update Understand-Expensify-Pricing.md --- .../Understand-Expensify-Pricing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md index a88f781f20bf..cee415fff29e 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing.md @@ -169,4 +169,4 @@ Expensify offers localized pricing for USD, GBP, EUR, AUD, and NZD billing. ## Is there a free Expensify plan? -Yes. Expensify offers a free Submit workspace for individual employees who want to code their expenses and submit them to an approver. It doesn't include premium features like approvals, payments, company cards, or accounting integrations. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). +Yes. Expensify offers a free Submit workspace for individual employees who want to code expenses and submit them to an approver when their company hasn’t adopted Expensify. It doesn't include premium features like approvals, payments, company cards, or accounting integrations. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). From 54c349311f452963d4e3e01be934650ab85ba839 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:56:38 -1000 Subject: [PATCH 432/466] Update Change-Your-Workspace-Plan.md --- .../Change-Your-Workspace-Plan.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md index a1eccb683e1a..7c3badcc2f62 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md @@ -1,6 +1,6 @@ --- title: Change Your Workspace Plan -description: Switch a Workspace's Plan type between Collect and Control. +description: Change your Workspace's Plan type to Collect or Control. keywords: [New Expensify, change workspace plan, switch plan, Collect plan, Control plan, Submit plan, plan type, upgrade, downgrade, upgrade to Control, downgrade to Collect, upgrade Submit workspace, workspace admin] internalScope: Audience is Workspace owners. Covers how to switch a Workspace between the Collect and Control plans, and how upgrading a free Submit workspace works. Does not cover the differences between plans or how to change a subscription. retrievalIntent: How to switch a Workspace plan between Collect and Control. @@ -25,7 +25,7 @@ If you have an Annual subscription, changes to your plan may be restricted. To l --- -## How to switch between the Collect and Control plans +## How to switch to the Collect or Control plans 1. In the navigation tabs (on the left on web, at the bottom on mobile), go to **Workspaces > [workspace name]**. 2. Select **Overview**. @@ -35,11 +35,11 @@ If you have an Annual subscription, changes to your plan may be restricted. To l --- -## Upgrading a free Submit workspace +## How to upgrade from a Submit workspace -If you have a free [Submit workspace](/articles/new-expensify/getting-started/Submit-Workspace), you can upgrade it to a paid Collect or Control plan to unlock premium features such as approvals, payments, company cards, and accounting integrations. An upgrade is also triggered automatically when an approver approves a report or when a member enables a premium feature. +A free Submit workspace can be upgraded to Collect or Control. Once upgraded, it can’t be downgraded back to Submit. -Upgrading a Submit workspace is one-way. You can't downgrade a paid workspace back to a Submit workspace. To use a Submit workspace again, delete the upgraded workspace and create a new Submit workspace. +[Learn how to upgrade a Submit workspace](/articles/new-expensify/getting-started/Submit-Workspace#what-happens-when-my-approver-clicks-approve). --- @@ -70,5 +70,4 @@ You can't downgrade the plan on an invoiced subscription from the app. Reach out ## Can I downgrade a paid workspace back to a Submit workspace? -No. Once a Submit workspace is upgraded to Collect or Control, it can't be downgraded back to Submit. To use a Submit workspace again, delete the upgraded workspace and create a new one. To learn more, see [Use a Free Submit Workspace](/articles/new-expensify/getting-started/Submit-Workspace). - +No. Once a Submit workspace is upgraded to Collect or Control, it can't be downgraded back to Submit. From fa944b42c1e7763575980031fe244bb4921844e1 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:00:14 -1000 Subject: [PATCH 433/466] Update Submit-Workspace.md --- .../getting-started/Submit-Workspace.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/articles/new-expensify/getting-started/Submit-Workspace.md b/docs/articles/new-expensify/getting-started/Submit-Workspace.md index 30dcde38edca..8e338cd1065b 100644 --- a/docs/articles/new-expensify/getting-started/Submit-Workspace.md +++ b/docs/articles/new-expensify/getting-started/Submit-Workspace.md @@ -119,6 +119,19 @@ Marking a report as paid lets you track which of your submitted reports are stil --- +## How to upgrade a Submit workspace + +If you have a free Submit workspace, you can upgrade it to a paid Collect or Control plan to unlock premium features such as approvals, payments, company cards, and accounting integrations. + +Workspace Editors and anyone who approves a report on a Submit workspace can upgrade the workspace: + + - Workspace Editors can switch the **Plan type** in the workspace **Overview** tab. + - Anyone who approves a report can upgrade the workspace by clicking Approve on a report submitted to them. + +You can’t downgrade a Collect or Control workspace back to a Submit workspace after upgrading. + +--- + ## What happens when my approver clicks Approve? If your approver clicks **Approve**, they're prompted to upgrade the workspace to a paid plan before they can complete the approval. @@ -153,3 +166,4 @@ Editors can configure the workspace, but can't see other members' submitted repo No. Submitting expenses and marking reports as paid are free. Payment is only required when someone upgrades the workspace to a paid plan, such as when an approver approves a report. + From 63ee7da9e8709f4db5699bccaac7f2318ad726e5 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:03:24 -1000 Subject: [PATCH 434/466] Clean up formatting in Submit-Workspace.md Removed extra line breaks in the Submit Workspace documentation. --- .../articles/new-expensify/getting-started/Submit-Workspace.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/articles/new-expensify/getting-started/Submit-Workspace.md b/docs/articles/new-expensify/getting-started/Submit-Workspace.md index 8e338cd1065b..1ae0b32bf5d6 100644 --- a/docs/articles/new-expensify/getting-started/Submit-Workspace.md +++ b/docs/articles/new-expensify/getting-started/Submit-Workspace.md @@ -17,7 +17,6 @@ Premium features are not included in a Submit workspace. To use approvals, payme - Anyone on your company's email domain can join your Submit workspace and is added as an **Editor**. - An **Editor** can configure the workspace (such as adding categories and tags) but cannot see other members' expenses or reports, and cannot change member roles. - --- ## How to create a Submit workspace @@ -53,7 +52,6 @@ Editors can't: - View other members' expenses or reports. - Change member roles. - --- ## How to join an existing Submit workspace @@ -68,6 +66,7 @@ If someone on your email domain already owns a Submit workspace, you can join it When you join, you're added as an **Editor**. You can collaborate on the workspace configuration, but you can't see or take action on other members' expenses or reports. If you weren't automatically added to the workspace when you created your account, ask a current Editor of the Submit workspace to invite you to join. Learn how to [invite members to a workspace](/articles/new-expensify/workspaces/Managing-Workspace-Members#invite-members-to-a-workspace). + --- ## How to submit an expense to an approver From f5fe964d22a222dae4583c80e29503c09d478fc0 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:35:46 -1000 Subject: [PATCH 435/466] Update Change-Your-Workspace-Plan.md --- .../manage-subscription/Change-Your-Workspace-Plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md index 7c3badcc2f62..89d5a075c53c 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Change-Your-Workspace-Plan.md @@ -39,7 +39,7 @@ If you have an Annual subscription, changes to your plan may be restricted. To l A free Submit workspace can be upgraded to Collect or Control. Once upgraded, it can’t be downgraded back to Submit. -[Learn how to upgrade a Submit workspace](/articles/new-expensify/getting-started/Submit-Workspace#what-happens-when-my-approver-clicks-approve). +[Learn how to upgrade a Submit workspace](/articles/new-expensify/getting-started/Submit-Workspace#how-to-upgrade-a-submit-workspace). --- From 7a0c1d980efa40ce30a7166a5ece05944a4bc7b3 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:37:25 -1000 Subject: [PATCH 436/466] Emphasize 'Approve' button in workspace upgrade instructions --- docs/articles/new-expensify/getting-started/Submit-Workspace.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/articles/new-expensify/getting-started/Submit-Workspace.md b/docs/articles/new-expensify/getting-started/Submit-Workspace.md index 1ae0b32bf5d6..9ee3f5fa0e46 100644 --- a/docs/articles/new-expensify/getting-started/Submit-Workspace.md +++ b/docs/articles/new-expensify/getting-started/Submit-Workspace.md @@ -125,7 +125,7 @@ If you have a free Submit workspace, you can upgrade it to a paid Collect or Con Workspace Editors and anyone who approves a report on a Submit workspace can upgrade the workspace: - Workspace Editors can switch the **Plan type** in the workspace **Overview** tab. - - Anyone who approves a report can upgrade the workspace by clicking Approve on a report submitted to them. + - Anyone who approves a report can upgrade the workspace by clicking **Approve** on a report submitted to them. You can’t downgrade a Collect or Control workspace back to a Submit workspace after upgrading. From d13e39b078a9105eea21b8e5af4d69341e9f9e78 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:44:03 -1000 Subject: [PATCH 437/466] Update How-to-Export-Expenses.md --- .../reports-and-expenses/How-to-Export-Expenses.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md index 699e29e2fa5d..755467113f90 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Expenses.md @@ -61,6 +61,8 @@ The CSV includes one row per expense using the same columns displayed on the pag --- +# FAQ + ## What export templates can I choose from? Expensify offers pre-built export templates, or you can build your own custom export template. From 4ec98ba6092c4f97dc4c63168c3848327956bef1 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Wed, 26 Aug 2026 14:47:55 +0200 Subject: [PATCH 438/466] Fix pasting an image into the Home Concierge prompt box --- jest/setup.ts | 3 + .../home/ForYouSection/ConciergePromptBox.tsx | 22 ++++++- .../useConciergeAttachmentPicker.ts | 5 +- tests/ui/ConciergePromptBoxTest.tsx | 62 ++++++++++++++++++- 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/jest/setup.ts b/jest/setup.ts index 6e63c1ea3015..3a6464839967 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -131,6 +131,9 @@ jest.mock('react-native-share', () => ({ jest.mock('react-native-reanimated', () => ({ ...jest.requireActual('react-native-reanimated/mock'), createAnimatedPropAdapter: jest.fn, + // react-native-reanimated/mock leaves dispatchCommand out (see its own "ADD ME IF NEEDED" comment). forceClearInput + // (src/libs/ComponentUtils) dispatches it from a UI-thread worklet, so any test exercising that path needs it mocked. + dispatchCommand: jest.fn(), useReducedMotion: jest.fn, useScrollViewOffset: jest.fn(() => 0), useAnimatedRef: jest.fn(() => jest.fn()), diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 2821fafff233..01c9cf0413ef 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -82,8 +82,12 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const actionButtonRef = useRef(null); const animatedRef = useAnimatedRef(); + // The native Composer only forwards its underlying input to a callback ref, so an object ref would never be populated. + const composerRef = useRef(null); + const setComposerRef = (element: ComposerRef) => { animatedRef(element); + composerRef.current = element; }; const clearInput = () => { @@ -93,8 +97,10 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo }; const sendAttachment = (attachments: FileObject | FileObject[]) => { - askConciergeWithAttachment(attachments, value); - clearInput(); + interceptAnonymousUser(() => { + askConciergeWithAttachment(attachments, value); + clearInput(); + }); }; const {pickAttachments, PDFValidationComponent} = useConciergeAttachmentPicker(conciergeTargetReportID, sendAttachment); @@ -239,6 +245,18 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} onKeyPress={handleKeyPress} + onPasteFile={(files) => { + // Concierge isn't reachable yet, so there is nowhere to send the paste. Mirrors the disabled "+" button. + if (!shouldShowAskConcierge) { + return; + } + + interceptAnonymousUser(() => { + // Blur before the attachment preview modal takes over, so the keyboard doesn't stay up over it. + composerRef.current?.blur(); + pickAttachments(files); + }); + }} maxLines={MAX_INPUT_LINES} multiline textAlignVertical="top" diff --git a/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts b/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts index 16a6c7b93bff..accc1f219a5b 100644 --- a/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts +++ b/src/pages/home/ForYouSection/useConciergeAttachmentPicker.ts @@ -39,8 +39,9 @@ function useConciergeAttachmentPicker(reportID: string | undefined, onConfirm: ( const {validateFiles, PDFValidationComponent} = useFilesValidation(onFilesValidated); - const pickAttachments = (files: FileObject[]) => { - const fileObjects = files.map((item) => cleanFileObjectName(cleanFileObject(item))); + // A pasted file arrives as a single FileObject on native and for base64/Google Workspace images on web, so both shapes are accepted. + const pickAttachments = (files: FileObject | FileObject[]) => { + const fileObjects = (Array.isArray(files) ? files : [files]).map((item) => cleanFileObjectName(cleanFileObject(item))); if (!fileObjects.length) { return; } diff --git a/tests/ui/ConciergePromptBoxTest.tsx b/tests/ui/ConciergePromptBoxTest.tsx index 7c5638ea9733..71e4088709e1 100644 --- a/tests/ui/ConciergePromptBoxTest.tsx +++ b/tests/ui/ConciergePromptBoxTest.tsx @@ -1,4 +1,4 @@ -import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import useAskConcierge from '@components/Search/SearchRouter/useAskConcierge'; @@ -18,6 +18,8 @@ import type {FileObject} from '@src/types/utils/Attachment'; import React, {useState} from 'react'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + const CONCIERGE_REPORT_ID = '100'; const LONG_PLACEHOLDER = 'homePage.conciergePrompt.inputPlaceholder'; const SHORT_PLACEHOLDER = 'homePage.conciergePrompt.inputPlaceholderMobile'; @@ -85,6 +87,8 @@ jest.mock('@libs/Browser', () => ({ isSafari: jest.fn(() => false), })); +jest.mock('@pages/Share/getFileSize', () => jest.fn(() => Promise.resolve(100))); + jest.mock('@userActions/Modal', () => ({ close: jest.fn(), })); @@ -158,6 +162,10 @@ function pressEnter(options?: {shiftKey?: boolean}) { }); } +function pasteImage() { + fireEvent(getInput(), 'paste', {nativeEvent: {items: [{type: 'image/png', data: 'file:///image.png'}]}}); +} + function measureLongPlaceholder(height: number) { fireEvent(screen.getByText(LONG_PLACEHOLDER), 'layout', {nativeEvent: {layout: {height}}}); } @@ -317,6 +325,32 @@ describe('ConciergePromptBox', () => { expect(mockAskConciergeWithAttachment).toHaveBeenCalledWith(files, 'Here it is'); expect(getInput()).toHaveDisplayValue(''); }); + + it('starts the attachment flow for a pasted image', async () => { + // Given the prompt box + render(); + + // When an image is pasted into the input + pasteImage(); + + // Then the pasted file goes through the same validation and preview flow as the picker + await waitFor(() => { + expect(mockPickAttachments).toHaveBeenCalled(); + }); + }); + + it('ignores a pasted image until the Concierge report is ready', async () => { + // Given a prompt box that has nowhere to send the attachment yet + setAskConcierge(false); + render(); + + // When an image is pasted into the input + pasteImage(); + await waitForBatchedUpdatesWithAct(); + + // Then the attachment flow is not started + expect(mockPickAttachments).not.toHaveBeenCalled(); + }); }); describe('anonymous user', () => { @@ -361,6 +395,32 @@ describe('ConciergePromptBox', () => { expect(screen.queryByLabelText(ADD_ATTACHMENT)).toBeNull(); expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalled(); }); + + it('asks to sign in instead of starting the attachment flow for a pasted image', async () => { + // Given an anonymous user + render(); + + // When an image is pasted into the input + pasteImage(); + await waitForBatchedUpdatesWithAct(); + + // Then the attachment flow is not started and the sign in flow opens + expect(mockPickAttachments).not.toHaveBeenCalled(); + expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalled(); + }); + + it('asks to sign in instead of sending a confirmed attachment', () => { + // Given an anonymous user with attachments confirmed in the preview modal + const files: FileObject[] = [{name: 'receipt.jpg', type: 'image/jpeg', uri: 'file://receipt.jpg'}]; + render(); + + // When the modal confirms + act(() => pickerHandler.onConfirm?.(files)); + + // Then nothing is sent and the sign in flow opens + expect(mockAskConciergeWithAttachment).not.toHaveBeenCalled(); + expect(mockSignOutAndRedirectToSignIn).toHaveBeenCalled(); + }); }); describe('placeholder', () => { From f74a290076922abd6a3bbb5a76f57cc5bb6e2c0a Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 27 Aug 2026 09:57:42 +0200 Subject: [PATCH 439/466] Add the new onyx key to ExportOnyxState/common.ts --- src/libs/ExportOnyxState/common.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 0419155fb542..20543cf00f62 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -403,6 +403,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT, ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT, ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT, + ONYXKEYS.CONCIERGE_PROMPT_DRAFT, ONYXKEYS.CORPAY_FIELDS, ONYXKEYS.CORPAY_ONBOARDING_FIELDS, ONYXKEYS.COUNTRY, From e65215db60b928028639e0975f509a33b136286f Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 27 Aug 2026 10:02:44 +0200 Subject: [PATCH 440/466] Small refactor --- src/libs/actions/Report/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 92cfd2523176..97344434934a 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -3227,11 +3227,6 @@ function saveReportDraftComment(reportID: string, comment: string | null, callba Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`, comment || null).then(callback); } -/** Saves the message the user is typing into the Concierge prompt box on the home page. Pass null to delete the draft. */ -function saveConciergePromptDraft(draft: string | null) { - Onyx.set(ONYXKEYS.CONCIERGE_PROMPT_DRAFT, draft); -} - /** Broadcasts whether or not a user is typing on a report over the report's private pusher channel. */ function broadcastUserIsTyping(reportID: string, currentUserAccountID: number) { const privateReportChannelName = getReportChannelName(reportID); @@ -8638,6 +8633,11 @@ function mergeReports({ }); } +/** Saves the message the user is typing into the Concierge prompt box on the home page. */ +function saveConciergePromptDraft(draft: string | null) { + Onyx.set(ONYXKEYS.CONCIERGE_PROMPT_DRAFT, draft); +} + export type {Video, GuidedSetupData, GuidedSetupTask, TaskForParameters, IntroSelected, OpenReportActionParams}; export { @@ -8722,7 +8722,6 @@ export { subscribeToReportLeavingEvents, clearAgentZeroProcessingIndicator, clearConciergeThinkingKickoff, - saveConciergePromptDraft, subscribeToReportReasoningEvents, unsubscribeFromReportReasoningChannel, subscribeToReportTypingEvents, @@ -8765,4 +8764,5 @@ export { getGuidedSetupDataForOpenReport, getReportChannelName, setViewingPublicRoomReportID, + saveConciergePromptDraft, }; From 44d741dd35490f1bf4c0cfb1f65327ed2115b1e8 Mon Sep 17 00:00:00 2001 From: GCyganek Date: Thu, 27 Aug 2026 10:22:20 +0200 Subject: [PATCH 441/466] Additional bottom padding when only showing concierge prompt box --- src/pages/home/ForYouSection/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/home/ForYouSection/index.tsx b/src/pages/home/ForYouSection/index.tsx index fe5f92f0762a..cf747b998727 100644 --- a/src/pages/home/ForYouSection/index.tsx +++ b/src/pages/home/ForYouSection/index.tsx @@ -213,10 +213,13 @@ function ForYouSection({isConciergeMenuVisible, setIsConciergeMenuVisible}: ForY isOnboardingStatusKnown, }); + const willOnlyShowConciergePromptBox = timeSensitiveItems.length === 0 && hideForYou; + // The card always renders so the Concierge input stays on the home page. `hideForYou` only gates the "For you" // heading and todos or empty-state below it. When hidden with no time-sensitive content, the card is just the box. return ( Date: Thu, 27 Aug 2026 10:32:40 +0200 Subject: [PATCH 442/466] Cancel debounced write when syncing onyx to state --- .../home/ForYouSection/ConciergePromptBox.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 77fffd211dc8..8f33d24a4120 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -83,13 +83,6 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo const [selection, setSelection] = useState({start: value.length, end: value.length}); const [lastSyncedDraft, setLastSyncedDraft] = useState(draft); - // Onyx owns the draft, so it survives refresh and is wiped along with the rest of the cache. - if (draft !== lastSyncedDraft) { - setLastSyncedDraft(draft); - setValue(draft ?? ''); - setSelection({start: draft?.length ?? 0, end: draft?.length ?? 0}); - } - const {saveDraft: debouncedSaveDraft, cancelSaveDraft} = useDebouncedSaveDraft( (nextDraft: string) => { setLastSyncedDraft(nextDraft); @@ -98,6 +91,15 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo undefined, true, ); + + // Onyx owns the draft, so it survives refresh and is wiped along with the rest of the cache. + if (draft !== lastSyncedDraft) { + cancelSaveDraft(); + setLastSyncedDraft(draft); + setValue(draft ?? ''); + setSelection({start: draft?.length ?? 0, end: draft?.length ?? 0}); + } + const [isFocused, setIsFocused] = useState(false); const [longPlaceholderHeight, setLongPlaceholderHeight] = useState(null); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); From feee926988b4a2b5ac5644d8ba726d1dab54ad15 Mon Sep 17 00:00:00 2001 From: "cretadn22 (via MelvinBot)" Date: Thu, 27 Aug 2026 08:50:33 +0000 Subject: [PATCH 443/466] Fix: use normalized validity dates in Expensify Card limit type optimistic update Co-authored-by: cretadn22 --- src/libs/actions/Card.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 8c70922c76e9..dec9ea4b7cde 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -1127,6 +1127,9 @@ function updateExpensifyCardLimitType( validThru?: string, shouldClearValidityDates?: boolean, ) { + const normalizedValidFrom = validFrom ? DateUtils.normalizeDateToStartOfDay(validFrom, timeZone) : undefined; + const normalizedValidThru = validThru ? DateUtils.normalizeDateToEndOfDay(validThru, timeZone) : undefined; + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1140,8 +1143,8 @@ function updateExpensifyCardLimitType( validFrom: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, validThru: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, - validFrom: shouldClearValidityDates ? null : validFrom, - validThru: shouldClearValidityDates ? null : validThru, + validFrom: shouldClearValidityDates ? null : normalizedValidFrom, + validThru: shouldClearValidityDates ? null : normalizedValidThru, }, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, pendingFields: {availableSpend: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, @@ -1193,8 +1196,8 @@ function updateExpensifyCardLimitType( const parameters: UpdateExpensifyCardLimitTypeParams = { cardID, limitType: newLimitType, - validFrom: validFrom ? DateUtils.normalizeDateToStartOfDay(validFrom, timeZone) : undefined, - validThru: validThru ? DateUtils.normalizeDateToEndOfDay(validThru, timeZone) : undefined, + validFrom: normalizedValidFrom, + validThru: normalizedValidThru, clearValidityDates: shouldClearValidityDates, }; From 6f4598c6d84690b01fb5e82e98a32450651fc032 Mon Sep 17 00:00:00 2001 From: "Shridhar Goel (via MelvinBot)" Date: Thu, 27 Aug 2026 11:45:52 +0000 Subject: [PATCH 444/466] Simplify Authorized Payer role comment to a one-liner Co-authored-by: Shridhar Goel --- src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index caef14be0cae..9ba89e150ece 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -145,11 +145,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const phoneNumber = getPhoneNumber(details); const reimburserEmail = getReimburserEmail(policy); const isReimburser = !!reimburserEmail && reimburserEmail === memberLogin; - // The Authorized Payer (reimburser) may only hold a role that can pay (for example Admin or Payments Admin), so they can only be - // changed to one of those roles. Keep the Role row interactive for them only when there is another payer role they can - // actually move to, for example promoting a non-admin payer to Admin, or switching between Admin and Payments Admin on a - // Control workspace. Otherwise, such as an Admin payer on a non-Control workspace where Payments Admin is unavailable, - // there is no valid change to make, so the row stays read-only. + // Only let the Authorized Payer change roles when there is another payer role they can actually move to. const assignablePayerRoles = PAYER_ROLES.filter((payerRole) => canMemberAssignRole(policy, currentUserLogin, payerRole)); const canReimburserChangeRole = assignablePayerRoles.some((payerRole) => payerRole !== member?.role); const canEditSelectedMemberRole = !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canManageSelectedMemberRole && (!isReimburser || canReimburserChangeRole); From f7fe2c8bdea469a631a4033292cfa148e5f32a56 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:19:33 +0530 Subject: [PATCH 445/466] Derive similarSearchHash from suggested searches in SearchUpdateTest --- tests/actions/IOU/SearchUpdateTest.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/actions/IOU/SearchUpdateTest.ts b/tests/actions/IOU/SearchUpdateTest.ts index 16915c2d8957..e82ad293e423 100644 --- a/tests/actions/IOU/SearchUpdateTest.ts +++ b/tests/actions/IOU/SearchUpdateTest.ts @@ -10,6 +10,7 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import {buildCannedSearchQuery} from '@src/libs/SearchQueryUtils'; +import {getSuggestedSearches} from '@src/libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report} from '@src/types/onyx'; @@ -110,6 +111,11 @@ jest.mock('@libs/PolicyUtils', () => ({ const RORY_EMAIL = 'rory@expensifail.com'; const RORY_ACCOUNT_ID = 3; +const suggestedSearches = getSuggestedSearches(RORY_ACCOUNT_ID); +const submitSimilarSearchHash = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.SUBMIT].similarSearchHash; +const approveSimilarSearchHash = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.APPROVE].similarSearchHash; +const unapprovedCashRuntimeSimilarSearchHash = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH].similarSearchHash; + OnyxUpdateManager(); describe('actions/IOU', () => { beforeAll(() => { @@ -183,7 +189,7 @@ describe('actions/IOU', () => { ], hash: 939629734, recentSearchHash: 1023339253, - similarSearchHash: 1855682507, + similarSearchHash: submitSimilarSearchHash, }); const iouReport: Report = { ...createRandomReport(2, undefined), @@ -245,7 +251,7 @@ describe('actions/IOU', () => { hash: 1685631874, inputQuery: 'sortBy:date sortOrder:desc type:expense-report action:approve to:20671314', recentSearchHash: 244251677, - similarSearchHash: 1539858783, + similarSearchHash: approveSimilarSearchHash, }); const iouReport: Report = { ...createRandomReport(2, undefined), @@ -313,7 +319,7 @@ describe('actions/IOU', () => { hash: 1967417738, inputQuery: 'sortBy:date sortOrder:desc type:expense groupBy:from status:drafts,outstanding reimbursable:yes', recentSearchHash: 1043581824, - similarSearchHash: 1832274510, + similarSearchHash: unapprovedCashRuntimeSimilarSearchHash, }); const iouReport: Report = { From 2970d909a4ec7a138fae52e3bd3cc532fc0b106f Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 27 Aug 2026 14:38:00 +0200 Subject: [PATCH 446/466] Decompose MoneyRequestView into data hook and field components --- .../ReportActionItem/MoneyRequestView.tsx | 1712 +---------------- .../components/AmountField.tsx | 36 + .../components/AttendeesField.tsx | 54 + .../components/BillableField.tsx | 54 + .../MoneyRequestView/components/CardField.tsx | 28 + .../components/CategoryField.tsx | 34 + .../CompanyCardViolationMessage.tsx | 21 + .../MoneyRequestView/components/DateField.tsx | 34 + .../components/DescriptionField.tsx | 36 + .../components/DistanceRequestFields.tsx | 52 + .../components/MerchantField.tsx | 71 + .../components/PerDiemOutOfPolicyBanner.tsx | 33 + .../components/ReimbursableField.tsx | 40 + .../components/ReportField.tsx | 31 + .../MoneyRequestView/components/TagField.tsx | 92 + .../components/TaxAmountField.tsx | 30 + .../components/TaxRateField.tsx | 34 + .../MoneyRequestView/components/TripField.tsx | 31 + .../components/VendorField.tsx | 32 + .../components/ViewTripDetailsAction.tsx | 21 + .../MoneyRequestView/types.ts | 30 + .../useMoneyRequestViewData.ts | 1339 +++++++++++++ 22 files changed, 2194 insertions(+), 1651 deletions(-) create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/AmountField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/AttendeesField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/BillableField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/CardField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/CategoryField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/CompanyCardViolationMessage.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/DateField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/DescriptionField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/DistanceRequestFields.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/MerchantField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/PerDiemOutOfPolicyBanner.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/ReimbursableField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/ReportField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/TagField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/TaxAmountField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/TaxRateField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/TripField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/VendorField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/components/ViewTripDetailsAction.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestView/types.ts create mode 100644 src/components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData.ts diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index a171c743379b..051e4565baef 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1,1667 +1,77 @@ -import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import HighlightableMenuItemWithTopDescription from '@components/HighlightableMenuItemWithTopDescription'; -import Icon from '@components/Icon'; -import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {usePersonalDetails, usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; -import {useSearchResultsContext} from '@components/Search/SearchContext'; -import Switch from '@components/Switch'; -import Text from '@components/Text'; -import UserPills from '@components/UserPills'; -import ViolationMessages from '@components/ViolationMessages'; -import {useWideRHPState} from '@components/WideRHPContextProvider'; - -import useActiveRoute from '@hooks/useActiveRoute'; -import useAttendees from '@hooks/useAttendees'; -import useCardFeedErrors from '@hooks/useCardFeedErrors'; -import useConfirmModal from '@hooks/useConfirmModal'; -import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDelegateAccountID from '@hooks/useDelegateAccountID'; -import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; -import useEnvironment from '@hooks/useEnvironment'; -import useHasMultipleSplitChildren from '@hooks/useHasMultipleSplitChildren'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; -import usePersonalPolicy from '@hooks/usePersonalPolicy'; -import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; -import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; -import useReportIsArchived from '@hooks/useReportIsArchived'; -import useReportTransactions from '@hooks/useReportTransactions'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useRestrictedActionPolicyID from '@hooks/useRestrictedActionPolicyID'; -import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useTransactionViolations from '@hooks/useTransactionViolations'; -import type {ViolationField} from '@hooks/useViolations'; -import useViolations from '@hooks/useViolations'; - -import {updateMoneyRequestBillable, updateMoneyRequestCategory, updateMoneyRequestReimbursable, updateMoneyRequestTag, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest'; -import {openExternalLink} from '@libs/actions/Link'; -import initSplitExpense from '@libs/actions/SplitExpenses'; -import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; -import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from '@libs/CardUtils'; -import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; -import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils'; -import {getRateFromMerchant} from '@libs/MergeTransactionUtils'; -import {isBillableEnabledOnPolicy, isSingleTransactionReport} from '@libs/MoneyRequestReportUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; -import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import Parser from '@libs/Parser'; -import { - canSubmitPerDiemExpenseFromWorkspace, - findVendorByID, - getDistanceRateCustomUnitRate, - getLengthOfTag, - getPerDiemCustomUnit, - getPolicyByCustomUnitID, - getTagLists, - hasDependentTags as hasDependentTagsPolicyUtils, - hasVendorFeature, - isAttendeeTrackingEnabled, - isGroupPolicyByType, - isPerDiemEnabled, - isPolicyAccessible, - isTaxTrackingEnabled, - resolveCurrentTaxCode, - isXeroActiveMatchingSource, -} from '@libs/PolicyUtils'; -import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {getReportName} from '@libs/ReportNameUtils'; -import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils'; -import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; -import { - canEditFieldOfMoneyRequest, - canEditMoneyRequest, - canUserPerformWriteAction as canUserPerformWriteActionReportUtils, - getTransactionDetails, - getTripIDFromTransactionParentReportID, - isExpenseReport, - isInvoiceReport, - isOpenReport, - isReportApproved, - isSettled as isSettledReportUtils, - isTrackExpenseReportNew, - shouldEnableNegative, -} from '@libs/ReportUtils'; -import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; -import { - getAttendeesListDisplayString, - getBillable, - getCurrency, - getDescription, - getDistanceInMeters, - getFormattedCreated, - getOriginalAmountForDisplay, - getOriginalTransactionWithSplitInfo, - getReimbursable, - getTagForDisplay, - getTaxName, - hasMissingSmartscanFields, - hasReservationList, - hasRoute as hasRouteTransactionUtils, - isFromCreditCardImport as isCardTransactionTransactionUtils, - isCategoryBeingAnalyzed, - isCustomUnitRateIDForP2P, - isDistanceRequest as isDistanceRequestTransactionUtils, - isDistanceTypeRequest, - isExpenseUnreported as isExpenseUnreportedTransactionUtils, - isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils, - isManagedCardTransaction as isManagedCardTransactionTransactionUtils, - isManualDistanceRequest as isManualDistanceRequestTransactionUtils, - isMapDistanceRequest as isMapDistanceRequestTransactionUtils, - isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, - isPerDiemRequest as isPerDiemRequestTransactionUtils, - isScanning, - isTimeRequest as isTimeRequestTransactionUtils, - isTransactionPendingDelete, - shouldShowAttendees as shouldShowAttendeesTransactionUtils, -} from '@libs/TransactionUtils'; -import {isInvalidMerchantValue} from '@libs/ValidationUtils'; -import ViolationsUtils from '@libs/Violations/ViolationsUtils'; - -import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/inbox/report/AnimatedEmptyStateBackground'; -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; -import type * as OnyxTypes from '@src/types/onyx'; -import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; - -import {useRoute} from '@react-navigation/native'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {policyTypeSelector} from '@selectors/Policy'; -import {Str} from 'expensify-common'; -import React, {useState} from 'react'; import {View} from 'react-native'; -// Use the original useOnyx hook to get the real-time data from Onyx and not from the snapshot -// eslint-disable-next-line no-restricted-imports -import {useOnyx as originalUseOnyx} from 'react-native-onyx'; - -import MoneyRequestReceiptView from './MoneyRequestReceiptView'; - -type MoneyRequestViewProps = { - /** The report currently being looked at */ - transactionThreadReport?: OnyxEntry; - - parentReportID?: string; - - /** Policy that the report belongs to */ - expensePolicy: OnyxEntry; - - /** Whether we should display the animated banner above the component */ - shouldShowAnimatedBackground: boolean; - - /** Whether we should show Money Request with disabled all fields */ - readonly?: boolean; - - /** whether this report is from review duplicates */ - isFromReviewDuplicates?: boolean; - - /** Updated transaction to show in duplicate & merge transaction flow */ - updatedTransaction?: OnyxEntry; - - /** Merge transaction ID to show in merge transaction flow */ - mergeTransactionID?: string; -}; - -const perDiemPoliciesSelector = (policies: OnyxCollection) => { - return Object.fromEntries( - Object.entries(policies ?? {}).filter(([, policy]) => { - const perDiemCustomUnit = getPerDiemCustomUnit(policy); - const hasPolicyPerDiemRates = !isEmptyObject(perDiemCustomUnit?.rates); - - return isPerDiemEnabled(policy) && hasPolicyPerDiemRates; - }), - ); -}; - -function MoneyRequestView({ - transactionThreadReport, - parentReportID, - expensePolicy, - shouldShowAnimatedBackground, - readonly = false, - updatedTransaction, - isFromReviewDuplicates = false, - mergeTransactionID, -}: MoneyRequestViewProps) { - const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase', 'NewWindow']); - const styles = useThemeStyles(); - const theme = useTheme(); - const StyleUtils = useStyleUtils(); - const {isOffline} = useNetwork(); - const {environmentURL} = useEnvironment(); - const {translate, toLocaleDigit, localeCompare, dateFnsLocale} = useLocalize(); - const {convertToDisplayString, getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); - const {getReportRHPActiveRoute} = useActiveRoute(); - const {showConfirmModal} = useConfirmModal(); - const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); - - const {currentSearchResults} = useSearchResultsContext(); - - // When this component is used when merging from the search page, we might not have the parent report stored in the main collection - const [parentReportFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); - const parentReport = parentReportFromOnyx ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; - const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); - const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - - const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); - const parentReportAction = transactionThreadReport?.parentReportActionID ? parentReportActions?.[transactionThreadReport.parentReportActionID] : undefined; - - const isFromMergeTransaction = !!mergeTransactionID; - const linkedTransactionID = parentReportAction && isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; - const [onyxTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(linkedTransactionID)}`); - const transaction = updatedTransaction ?? onyxTransaction; - const isExpenseUnreported = isExpenseUnreportedTransactionUtils(transaction); - const personalPolicy = usePersonalPolicy(); - const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy, shouldNavigateToUpgradePath} = usePolicyForMovingExpenses(); - const isTimeRequest = isTimeRequestTransactionUtils(transaction); - - const [policiesWithPerDiem] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { - selector: perDiemPoliciesSelector, - }); - const splitEffectivePolicy = useSplitEffectivePolicy(transactionThreadReport, undefined, transaction); - const isPerDiemRequest = isPerDiemRequestTransactionUtils(transaction); - const perDiemOriginalPolicy = getPolicyByCustomUnitID(transaction, policiesWithPerDiem); - - const customUnitRateID = isDistanceRequestTransactionUtils(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; - const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(expensePolicy, customUnitRateID); - const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); - - let policy; - let policyID; - // If the expense is unreported the policy should be the user's default policy, if the expense is a per diem request and is unreported - // the policy should be the one where the per diem rates are enabled, otherwise it should be the expense's report policy - if (isExpenseUnreported && !isPerDiemRequest) { - policy = policyForMovingExpenses; - policyID = policyForMovingExpensesID; - } else if (isExpenseUnreported && isPerDiemRequest) { - policy = perDiemOriginalPolicy; - policyID = perDiemOriginalPolicy?.id; - } else { - policy = expensePolicy; - policyID = parentReport?.policyID; - } - - // Use the report's real policy, not `policy` above (swapped to an unrelated workspace for - // unreported expenses), else self-DM split editing wrongly redirects to RESTRICTED_ACTION. - const restrictedActionPolicyID = useRestrictedActionPolicyID(expensePolicy); - - const allPolicyCategories = usePolicyCategories(); - const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; - const targetPolicyID = updatedTransaction?.reportID ? parentReport?.policyID : policyID; - const allPolicyTags = usePolicyTags(); - const policyTagList = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`]; - const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); - const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); - const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); - - const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${getNonEmptyStringOnyxID(linkedTransactionID)}`); - const transactionViolations = useTransactionViolations(transaction?.transactionID, true, distanceOriginalPolicy ?? policy); - const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const delegateAccountID = useDelegateAccountID(); - const personalDetailsList = usePersonalDetails(); - const currentUserAccountIDParam = currentUserPersonalDetails.accountID; - const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const {isBetaEnabled} = usePermissions(); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const isP2PDistanceRequest = isCustomUnitRateIDForP2P(transaction); - const moneyRequestReport = parentReport; - const parentReportTransactions = useReportTransactions(moneyRequestReport?.reportID); - // Exclude transactions pending deletion so the report is recognized as single-expense immediately after deleting one of its expenses, - // instead of waiting for the optimistic delete to be removed from Onyx (https://github.com/Expensify/App/issues/91058). - // While offline the deleted expense is still rendered, so keep counting it to stay consistent with the visible transaction list. - const visibleParentReportTransactions = parentReportTransactions.filter((t) => isOffline || !isTransactionPendingDelete(t)); - const isApproved = isReportApproved({report: moneyRequestReport}); - const isInvoice = isInvoiceReport(moneyRequestReport); - const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); - - let iouType: ValueOf; - if (isTrackExpense) { - iouType = CONST.IOU.TYPE.TRACK; - } else if (isInvoice) { - iouType = CONST.IOU.TYPE.INVOICE; - } else { - iouType = CONST.IOU.TYPE.SUBMIT; - } - - const allowNegativeAmount = shouldEnableNegative(parentReport, policy, iouType); - - const { - created: transactionDate, - amount: transactionAmount, - taxAmount: transactionTaxAmount, - currency: transactionCurrency, - comment: transactionDescription, - merchant: transactionMerchant, - reimbursable: transactionReimbursable, - billable: transactionBillable, - category: transactionCategory, - tag: transactionTag, - originalCurrency: transactionOriginalCurrency, - postedDate: transactionPostedDate, - convertedAmount: transactionConvertedAmount, - } = getTransactionDetails(transaction, undefined, undefined, allowNegativeAmount, false) ?? {}; - const transactionAttendees = useAttendees(transaction); - const isEmptyMerchant = isInvalidMerchantValue(transactionMerchant); - const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); - const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); - const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); - const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); - const isMapDistanceRequest = isMapDistanceRequestTransactionUtils(transaction) || isDistanceTypeRequest(transaction); - const isTransactionScanning = isScanning(updatedTransaction ?? transaction); - const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest); - - const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees; - const actualAttendees = enrichAndSortAttendees(rawActualAttendees, loginToAccountIDMap, personalDetailsList, localeCompare); - - // Use the updated transaction amount in merge flow to have correct positive/negative sign - const actualAmount = isFromMergeTransaction && updatedTransaction ? updatedTransaction.amount : transactionAmount; - const actualCurrency = updatedTransaction ? getCurrency(updatedTransaction) : transactionCurrency; - const shouldDisplayTransactionAmount = (isDistanceRequest && hasRoute) || !isDistanceRequest; - const formattedTransactionAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount, actualCurrency) : ''; - const formattedPerAttendeeAmount = - shouldDisplayTransactionAmount && actualAmount !== undefined ? convertToDisplayString(actualAmount / (transactionAttendees?.length ?? 1), actualCurrency) : ''; - - const transactionOriginalAmount = transaction && getOriginalAmountForDisplay(transaction, isExpenseReport(moneyRequestReport)); - const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); - const isFromCardImport = isCardTransactionTransactionUtils(transaction); - const cardProgramName = getCompanyCardDescription(translate, transaction?.cardName, transaction?.cardID, nonPersonalAndWorkspaceCards); - const shouldShowCard = isFromCardImport && cardProgramName; - - const taxRates = policy?.taxRates; - const formattedTaxAmount = - updatedTransaction?.taxAmount !== undefined - ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), actualCurrency) - : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); - // Skip a zero converted tax (e.g. tax exempt) so we don't render a redundant "Converted 0.00". - const formattedConvertedTaxAmount = transaction?.convertedTaxAmount ? convertToDisplayString(Math.abs(transaction.convertedTaxAmount), moneyRequestReport?.currency) : ''; - - const taxRatesDescription = taxRates?.name; - - const baseTransaction = updatedTransaction ?? transaction; - const {taxCode, taxValue} = baseTransaction ?? {}; - - const taxRateTitle = getTaxName(policy, baseTransaction, isExpenseUnreported); - const resolvedTaxCode = taxCode ? resolveCurrentTaxCode(policy, taxCode) : undefined; - const selectedPolicyTaxValue = resolvedTaxCode ? policy?.taxRates?.taxes?.[resolvedTaxCode]?.value : undefined; - const hasTaxValueChanged = taxCode && taxValue !== undefined ? selectedPolicyTaxValue !== taxValue : false; - - const actualTransactionDate = isFromMergeTransaction && updatedTransaction ? getFormattedCreated(updatedTransaction) : transactionDate; - const fallbackTaxRateTitle = transaction?.taxValue; - - const isSettled = isSettledReportUtils(moneyRequestReport); - const isCancelled = moneyRequestReport?.isCancelledIOU; - const isChatReportArchived = useReportIsArchived(moneyRequestReport?.chatReportID); - const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); - const pendingAction = transaction?.pendingAction; - const shouldShowPaid = isSettled && transactionReimbursable && !pendingAction; - - // Flags for allowing or disallowing editing an expense - // Used for non-restricted fields such as: description, category, tag, billable, etc... - const isReportArchived = useReportIsArchived(transactionThreadReport?.reportID); - const isEditable = !!canUserPerformWriteActionReportUtils(transactionThreadReport, isReportArchived) && !readonly; - const canEdit = - isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, isChatReportArchived, moneyRequestReport, policy, parentReportActions) && isEditable; - const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(transactionThreadReport?.policyID)}`; - const {personalCardsWithBrokenConnection} = useCardFeedErrors(); - const connectionLink = getBrokenConnectionUrlToFixPersonalCard(personalCardsWithBrokenConnection, environmentURL); - const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`); - const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); - const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); - const [reportPolicyType] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport?.policyID}`, {selector: policyTypeSelector}); - const hasMultipleSplits = useHasMultipleSplitChildren(transaction?.comment?.originalTransactionID); - const isReportOpen = isOpenReport(moneyRequestReport); - const shouldShowSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); - const isSplitAvailable = - moneyRequestReport && - transaction && - isSplitAction(moneyRequestReport, [transaction], originalTransaction, currentUserPersonalDetails.login ?? '', currentUserPersonalDetails.accountID, policy); - - const canEditTaxFields = canEdit && !isDistanceRequest; - const canEditAmount = - !isGPSDistanceRequest && - isEditable && - (canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT, - isChatReportArchived, - reportNameValuePairs, - transaction, - }) || - (shouldShowSplitIndicator && isSplitAvailable)); - const canEditMerchant = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }); - - const canEditDate = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }); - - const canEditDistanceOrRate = isPolicyAccessible(policy, currentUserEmailParam) || isTrackExpense || isP2PDistanceRequest; - - const canEditDistance = - !isGPSDistanceRequest && - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.DISTANCE, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }) && - canEditDistanceOrRate; - - const canEditDistanceRate = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }) && - canEditDistanceOrRate; - - const canEditReport = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, - isChatReportArchived, - outstandingReportsByPolicyID, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }) && - (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(policy) || (isExpenseUnreported && !!perDiemOriginalPolicy)); - - // A flag for verifying that the current report is a sub-report of a expense chat - // if the policy of the report is either Collect or Control, then this report must be tied to expense chat - const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); - const policyTagLists = getTagLists(policyTagList); - - const category = transactionCategory ?? ''; - const categoryForDisplay = isCategoryMissing(category) ? '' : category; - - // Flags for showing categories and tags - // transactionCategory can be an empty string - - const shouldShowCategory = - (isPolicyExpenseChat && (categoryForDisplay || hasEnabledOptions(policyCategories ?? {}))) || - (isExpenseUnreported && (!policyForMovingExpenses || hasEnabledOptions(policyCategories ?? {}))); - // transactionTag can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const shouldShowTag = (isPolicyExpenseChat || isExpenseUnreported) && (transactionTag || (canEdit && hasEnabledTags(policyTagLists))); - // Surface a delete confirmation (like tax) when the value is stale and there's nothing valid to select, instead of - // navigating to edit. Categories need at least one, so they only hit this when disabled; tags can be fully emptied, - // so also cover "no enabled tags remain". Applies to multi-level tags too - deleting clears the whole tag value. - const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; - const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag; - const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); - const isCurrentTransactionReimbursableDifferentFromPolicyDefault = - policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; - const shouldShowReimbursable = - (isPolicyExpenseChat || (isExpenseUnreported && !!policy)) && - (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && - !isManagedCardTransactionTransactionUtils(transaction) && - !isInvoice; - const canEditReimbursable = - isEditable && - canEditFieldOfMoneyRequest({ - reportAction: parentReportAction, - fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, - isChatReportArchived, - reportNameValuePairs, - transaction, - report: moneyRequestReport, - policy, - }); - const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); - - const transactionVendor = transaction?.comment?.vendor; - - // The title prefers any matched vendor name (active vendor-matching integration first, then a - // permissive cross-connection lookup), then the display name persisted on the transaction at - // match/assign time. When no connection has the vendor any more and no name was persisted, fall - // back to the raw externalID so admins still see a stable identifier for what was assigned — the - // red INACTIVE_VENDOR violation (from ViolationsUtils, scoped to the active integration) provides - // the "Vendor no longer valid" indicator separately. - const matchedVendorName = findVendorByID(policy, transactionVendor?.externalID)?.name; - let transactionVendorName = ''; - if (matchedVendorName) { - transactionVendorName = matchedVendorName; - } else if (transactionVendor?.name) { - transactionVendorName = transactionVendor.name; - } else if (transactionVendor?.externalID) { - transactionVendorName = transactionVendor.externalID; - } - const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable) && !isInvoice; - const vendorFieldLabel = isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'); - - const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); - const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; - - const transactionTripID = transaction?.comment?.tripID; - - // Trip rooms are the grandparent report, so check that first before scanning the collection. - const grandparentReportID = parentReport?.parentReportID; - const tripRoomReportSelector = (reports: OnyxCollection): OnyxEntry => { - if (!transactionTripID || !reports) { - return undefined; - } - const grandparent = grandparentReportID ? reports[`${ONYXKEYS.COLLECTION.REPORT}${grandparentReportID}`] : undefined; - const match = - grandparent?.tripData?.tripID === transactionTripID ? grandparent : Object.values(reports).find((candidateReport) => candidateReport?.tripData?.tripID === transactionTripID); - if (!match?.reportID) { - return undefined; - } - return match; - }; - const [tripRoomReport] = originalUseOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: tripRoomReportSelector}); - const tripRoomReportID = tripRoomReport?.reportID; - - const derivedReportNames = useDerivedReportNamesByReportIDs([tripRoomReportID, parentReport?.reportID]); - - const tripRoomDerivedName = tripRoomReportID ? derivedReportNames?.[tripRoomReportID] : undefined; - const tripRoomName = tripRoomReport ? getReportName(tripRoomReport, tripRoomDerivedName) : undefined; - const shouldShowTripRoomLink = !!tripRoomReportID && !!tripRoomName; - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - - const {getViolationsForField} = useViolations(transactionViolations ?? [], isTransactionScanning || !isGroupPolicyByType(reportPolicyType)); - const hasViolations = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string): boolean => - getViolationsForField(field, data, policyHasDependentTags, tagValue).length > 0; - const isMarkAsCash = parentReport && currentUserEmailParam ? isMarkAsCashActionForTransaction(currentUserEmailParam, parentReport, transactionViolations, policy) : false; - // Need to return undefined when we have pendingAction to avoid the duplicate pending action - const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => (pendingAction ? undefined : transaction?.pendingFields?.[fieldPath]); - - const isTaxEnabled = isTaxTrackingEnabled(isPolicyExpenseChat || isExpenseUnreported, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); - const shouldShowTaxDisabledAlert = !isTaxEnabled && !!transaction?.taxCode && !isTimeRequest && !isPerDiemRequest; - const shouldShowTax = isFromMergeTransaction ? !!transaction?.taxName : isTaxEnabled || shouldShowTaxDisabledAlert; - - let amountDescription = `${translate('iou.amount')}`; - let dateDescription = `${translate('common.date')}`; - const { - unit, - rate, - name: rateName, - } = DistanceRequestUtils.getRate({ - transaction: updatedTransaction ?? transaction, - policy: distanceOriginalPolicy ?? policy, - personalPolicyOutputCurrency: personalPolicy?.outputCurrency, - }); - const distance = getDistanceInMeters(transactionBackup ?? updatedTransaction ?? transaction, unit); - const currency = transactionCurrency ?? CONST.CURRENCY.USD; - const hasRequiredCompanyCardViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.COMPANY_CARD_REQUIRED); - const isCustomUnitOutOfPolicy = - (transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY) || (isDistanceRequest && !rate)) && !isTrackExpense; - const calculateFromTransactionData = isTrackExpense && !rate; - const distanceUnit = calculateFromTransactionData ? transaction?.comment?.customUnit?.distanceUnit : unit; - const distanceRate = calculateFromTransactionData ? (transactionAmount ?? 0) / (transaction?.comment?.customUnit?.quantity ?? 1) : rate; - let rateToDisplay = DistanceRequestUtils.getRateForExpenseDisplay( - rateName, - isCustomUnitOutOfPolicy, - distanceUnit, - distanceRate, - currency, - translate, - toLocaleDigit, - getCurrencySymbol, - isOffline, - ); - - const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; - // Commuter exclusions are a workspace control, so they are never surfaced on a personal (self-DM or P2P) expense, - // even if one carries the fields from a workspace rate it was created with. - const commuterExclusionData = - transaction?.comment?.originalTransactionID || !isPolicyExpenseChat - ? null - : DistanceRequestUtils.getCommuterExclusionDisplayData(transaction?.comment?.customUnit, distanceUnitValue); - const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, translate, false, isManualDistanceRequest, commuterExclusionData); - const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, distanceUnitValue, translate); - - let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; - let amountTitle = formattedTransactionAmount?.toString() || ''; - if (isTransactionScanning) { - merchantTitle = translate('iou.receiptStatusTitle'); - amountTitle = translate('iou.receiptStatusTitle'); - } - - const updatedTransactionDescription = getDescription(updatedTransaction) || undefined; - const shouldHideEmptyDescription = (isFromReviewDuplicates || isFromMergeTransaction) && !(updatedTransactionDescription ?? transactionDescription); - const isEmptyUpdatedMerchant = isInvalidMerchantValue(updatedTransaction?.modifiedMerchant); - const updatedMerchantTitle = isEmptyUpdatedMerchant ? '' : (updatedTransaction?.modifiedMerchant ?? merchantTitle); - const originalMerchantForGoogleSearch = isInvalidMerchantValue(onyxTransaction?.merchant) ? '' : Str.recapitalize(onyxTransaction?.merchant ?? ''); - const shouldShowGoogleMerchantSearchLink = !!originalMerchantForGoogleSearch && !isTransactionScanning && isFromCardImport; - - const renderGoogleMerchantSearchLink = () => ( - { - event?.stopPropagation(); - openExternalLink(`${CONST.GOOGLE_SEARCH_URL}${encodeURIComponent(originalMerchantForGoogleSearch)}`); - }} - style={[styles.flexRow, styles.alignItemsCenter, styles.mt1, styles.alignSelfStart]} - > - - {translate('common.googleThisMerchant', { - merchant: originalMerchantForGoogleSearch, - })} - - - - ); - - const shouldShowConvertedAmount = - transactionConvertedAmount && - currency !== moneyRequestReport?.currency && - !isFromCardImport && - transaction?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID && - !isFromMergeTransaction && - !isFromReviewDuplicates && - !getPendingFieldAction('amount') && - !pendingAction; - - const saveBillable = (newBillable: boolean) => { - // If the value hasn't changed, don't request to save changes on the server and just close the modal - if (newBillable === getBillable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - updateMoneyRequestBillable({ - transactionID: transaction.transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - value: newBillable, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }; - - const saveReimbursable = (newReimbursable: boolean) => { - // If the value hasn't changed, don't request to save changes on the server and just close the modal - if (newReimbursable === getReimbursable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - updateMoneyRequestReimbursable({ - transactionID: transaction.transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - value: newReimbursable, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }; - - if (isFromCardImport) { - if (transactionPostedDate) { - dateDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.posted')} ${transactionPostedDate}`; - } - if (formattedOriginalAmount) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.purchase')} ${formattedOriginalAmount}`; - } - if (isCancelled) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; - } - } else if (isCancelled) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; - } else if (isApproved) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.approved')}`; - } else if (shouldShowPaid) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.settledExpensify')}`; - } - if (shouldShowSplitIndicator) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.split')}`; - } - if (shouldShowConvertedAmount) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${convertToDisplayString(transactionConvertedAmount, moneyRequestReport?.currency)}`; - } - const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; - if (!isCurrentTransactionReimbursable && isSingleTransactionReport(moneyRequestReport, visibleParentReportTransactions)) { - amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`; - } - - // Show the converted tax here since the redundant report-level Tax total is hidden for a single-expense report. - let taxAmountDescription = translate('iou.taxAmount'); - if (shouldShowConvertedAmount && formattedConvertedTaxAmount) { - taxAmountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${formattedConvertedTaxAmount}`; - } - - if (isFromMergeTransaction && !rateName) { - // Because we lack the necessary data in policy.customUnits to determine the rate in merge flow, - // We need to extract the rate from the merchant string - // See https://github.com/Expensify/App/pull/71675#issuecomment-3425488228 for more information - rateToDisplay = getRateFromMerchant(updatedMerchantTitle); - } - - const hasErrors = hasMissingSmartscanFields(transaction, transactionReport); - const isMissingAttendeesViolation = getIsMissingAttendeesViolation( - policyCategories, - updatedTransaction?.category ?? categoryForDisplay, - actualAttendees, - currentUserPersonalDetails, - isAttendeeTrackingEnabled(policy), - policy?.type === CONST.POLICY.TYPE.CORPORATE, - ); - - const getErrorForField = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { - // Checks applied when creating a new expense - // NOTE: receipt field can return multiple violations, so we need to handle it separately - const fieldChecks: Partial> = { - merchant: { - isError: !isSettled && !isCancelled && isPolicyExpenseChat && isEmptyMerchant, - translationPath: canEditMerchant ? 'common.error.enterMerchant' : 'common.error.missingMerchantName', - }, - date: { - isError: transactionDate === '', - translationPath: canEditDate ? 'common.error.enterDate' : 'common.error.missingDate', - }, - }; - - const {isError, translationPath} = fieldChecks[field] ?? {}; - - if (readonly) { - return ''; - } - - // Return form errors if there are any - if (hasErrors && isError && translationPath) { - return translate(translationPath); - } - - if (isCustomUnitOutOfPolicy && field === 'customUnitRateID') { - return translate('violations.customUnitOutOfPolicy'); - } - - // Return violations if there are any - if (field !== 'merchant' && hasViolations(field, data, policyHasDependentTags, tagValue)) { - const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); - return `${violations - .map((violation) => { - const cardID = violation.data?.cardID; - const card = cardID ? cardList?.[cardID] : undefined; - return ViolationsUtils.getViolationTranslation({ - dateFnsLocale, - violation, - translate, - convertToDisplayString, - canEdit, - companyCardPageURL, - connectionLink, - card, - isMarkAsCash, - routeDistanceMeters: transaction?.comment?.customUnit?.routeDistanceMeters, - distanceUnit: transaction?.comment?.customUnit?.distanceUnit, - }); - }) - .join('. ')}.`; - } - - if (field === 'attendees' && isMissingAttendeesViolation) { - return translate('violations.missingAttendees'); - } - - return ''; - }; - - const showTaxDisabledAlert = () => { - showConfirmModal({ - title: translate('iou.taxDisabledAlert.title'), - prompt: translate('iou.taxDisabledAlert.prompt'), - confirmText: translate('iou.taxDisabledAlert.confirmText'), - cancelText: translate('common.cancel'), - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !canEditTaxFields) { - return; - } - - updateMoneyRequestTaxRate({ - transactionID: transaction?.transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - taxCode: '', - taxValue: '', - taxAmount: 0, - policy, - policyTagList, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }); - }; - - const showCategoryDisabledAlert = () => { - const transactionID = transaction?.transactionID; - if (!transactionID) { - return; - } - showConfirmModal({ - title: translate('iou.categoryDisabledAlert.title'), - prompt: translate('iou.categoryDisabledAlert.prompt'), - confirmText: translate('iou.categoryDisabledAlert.confirmText'), - cancelText: translate('common.cancel'), - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !canEdit) { - return; - } - - updateMoneyRequestCategory({ - transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - category: '', - policy, - policyTagList, - policyCategories, - policyRecentlyUsedCategories: undefined, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }); - }; +import type {MoneyRequestViewProps} from './MoneyRequestView/types'; - const showTagDisabledAlert = (tagListIndex: number) => { - const transactionID = transaction?.transactionID; - if (!transactionID) { - return; - } - showConfirmModal({ - title: translate('iou.tagDisabledAlert.title'), - prompt: translate('iou.tagDisabledAlert.prompt'), - confirmText: translate('iou.tagDisabledAlert.confirmText'), - cancelText: translate('common.cancel'), - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !canEdit) { - return; - } - - // Clear only the pressed level so the other levels of a multi-level tag are kept. - const updatedTag = insertTagIntoTransactionTagsString(transactionTag ?? '', '', tagListIndex, policy?.hasMultipleTagLists ?? false); - updateMoneyRequestTag({ - transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - tag: updatedTag, - policy, - policyTagList, - policyRecentlyUsedTags: undefined, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - }); - }); - }; - - const distanceCopyValue = !canEditDistance ? distanceToDisplay : undefined; - const distanceRateCopyValue = !canEditDistanceRate ? rateToDisplay : undefined; - const amountCopyValue = !canEditAmount ? amountTitle : undefined; - const descriptionHTML = updatedTransactionDescription ?? transactionDescription; - const descriptionCopyValue = !canEdit && descriptionHTML ? Parser.htmlToText(descriptionHTML) : undefined; - const merchantCopyValue = !canEditMerchant ? updatedMerchantTitle : undefined; - const dateCopyValue = !canEditDate ? transactionDate : undefined; - const categoryValue = updatedTransaction?.category ?? categoryForDisplay; - const decodedCategoryName = getDecodedLeafCategoryName(categoryValue); - const categoryCopyValue = !canEdit ? decodedCategoryName : undefined; - const cardCopyValue = cardProgramName; - const taxRateValue = hasTaxValueChanged ? taxValue : (transaction?.taxName ?? taxRateTitle ?? fallbackTaxRateTitle ?? ''); - const taxRateCopyValue = !canEditTaxFields ? taxRateValue : undefined; - const taxAmountTitle = formattedTaxAmount ? formattedTaxAmount.toString() : ''; - const taxAmountCopyValue = !canEditTaxFields ? taxAmountTitle : undefined; - - const distanceRequestFields = ( - <> - - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (isOdometerDistanceRequest) { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ); - return; - } - - if (isManualDistanceRequest) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - return; - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('waypoints') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('waypoints')} - copyValue={distanceCopyValue} - copyable={!!distanceCopyValue} - /> - - - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (isTrackExpense) { - if (shouldNavigateToUpgradePath && transactionThreadReport) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.DISTANCE_RATES, - }), - ), - ); - return; - } - if (!policy && shouldSelectPolicy) { - Navigation.navigate( - ROUTES.SET_DEFAULT_WORKSPACE.getRoute( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ), - ); - return; - } - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('customUnitRateID') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('customUnitRateID')} - copyValue={distanceRateCopyValue} - copyable={!!distanceRateCopyValue} - /> - - - ); - - const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTagList); - - const [previousTransactionTag, setPreviousTransactionTag] = useState(transactionTag); - const [previousTag, setPreviousTag] = useState(undefined); - const [currentTransactionTag, setCurrentTransactionTag] = useState(undefined); - if (transactionTag !== previousTransactionTag) { - setPreviousTransactionTag(transactionTag); - setPreviousTag(previousTransactionTag); - setCurrentTransactionTag(transactionTag); - } - const previousTagLength = getLengthOfTag(previousTag ?? ''); - const currentTagLength = getLengthOfTag(currentTransactionTag ?? ''); - - // actualAttendees is already sorted by enrichAndSortAttendees above; pass without localeCompare to preserve that order while stripping the SMS domain. - const getAttendeesTitle = Array.isArray(actualAttendees) ? getAttendeesListDisplayString(actualAttendees) : ''; - const attendeesCopyValue = !canEdit ? getAttendeesTitle : undefined; - - const tagList = policyTagLists.map(({name, orderWeight, tags}, index) => { - const tagForDisplay = getTagForDisplay(updatedTransaction ?? transaction, index); - let shouldShow = false; - if (hasDependentTags) { - shouldShow = shouldShowDependentTagList(index, transactionTag, tags); - } else { - shouldShow = !!tagForDisplay || (canEdit && hasEnabledOptions(tags)); - } - - if (!shouldShow) { - return null; - } - - const tagError = getErrorForField( - 'tag', - { - tagListIndex: index, - tagListName: name, - }, - hasDependentTags, - tagForDisplay, - ); - const tagCopyValue = !canEdit ? tagForDisplay : undefined; - - return ( - - previousTagLength} - description={name ?? translate('common.tag')} - title={tagForDisplay} - numberOfLinesTitle={2} - interactive={canEdit} - shouldShowRightIcon={canEdit} - titleStyle={styles.flex1} - onPress={() => { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - if (shouldShowTagDisabledAlert) { - showTagDisabledAlert(index); - return; - } - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - }} - brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={tagError} - shouldShowBasicTitle - shouldShowDescriptionOnTop - copyValue={tagCopyValue} - copyable={!!tagCopyValue} - /> - - ); - }); - - const parentReportDerivedName = parentReport?.reportID ? derivedReportNames?.[parentReport.reportID] : undefined; - const reportNameToDisplay = isFromMergeTransaction ? (updatedTransaction?.reportName ?? translate('common.none')) : getReportName(parentReport, parentReportDerivedName); - const shouldShowReport = !!parentReportID || (isFromMergeTransaction && !!reportNameToDisplay); - const reportCopyValue = !canEditReport && reportNameToDisplay !== translate('common.none') ? reportNameToDisplay : undefined; - const shouldShowCategoryAnalyzing = isCategoryBeingAnalyzed(updatedTransaction ?? transaction); - - // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - const {wideRHPRouteKeys} = useWideRHPState(); - const route = useRoute(); - // The receipt is hidden only for the MoneyRequestView rendered inside a wide RHP, where the wide RHP's left - // receipt panel already shows it. Instances mounted on other screens (e.g. the central report pane behind - // the RHP) must keep their inline receipt, so the check is scoped to this view's own route key. - const isInWideRHP = wideRHPRouteKeys.includes(route.key); - - // If the view is readonly, we don't need the transactionThread dependency - if ((!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID) { +import MoneyRequestReceiptView from './MoneyRequestReceiptView'; +import AmountField from './MoneyRequestView/components/AmountField'; +import AttendeesField from './MoneyRequestView/components/AttendeesField'; +import BillableField from './MoneyRequestView/components/BillableField'; +import CardField from './MoneyRequestView/components/CardField'; +import CategoryField from './MoneyRequestView/components/CategoryField'; +import CompanyCardViolationMessage from './MoneyRequestView/components/CompanyCardViolationMessage'; +import DateField from './MoneyRequestView/components/DateField'; +import DescriptionField from './MoneyRequestView/components/DescriptionField'; +import DistanceRequestFields from './MoneyRequestView/components/DistanceRequestFields'; +import MerchantField from './MoneyRequestView/components/MerchantField'; +import PerDiemOutOfPolicyBanner from './MoneyRequestView/components/PerDiemOutOfPolicyBanner'; +import ReimbursableField from './MoneyRequestView/components/ReimbursableField'; +import ReportField from './MoneyRequestView/components/ReportField'; +import TagField from './MoneyRequestView/components/TagField'; +import TaxAmountField from './MoneyRequestView/components/TaxAmountField'; +import TaxRateField from './MoneyRequestView/components/TaxRateField'; +import TripField from './MoneyRequestView/components/TripField'; +import VendorField from './MoneyRequestView/components/VendorField'; +import ViewTripDetailsAction from './MoneyRequestView/components/ViewTripDetailsAction'; +import useMoneyRequestViewData from './MoneyRequestView/useMoneyRequestViewData'; + +function MoneyRequestView(props: MoneyRequestViewProps) { + const data = useMoneyRequestViewData(props); + + if (data.isLoading) { return ; } - return ( - - {shouldShowAnimatedBackground && } - <> - {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( - - )} - {isCustomUnitOutOfPolicy && isPerDiemRequest && ( - - - - {translate('violations.customUnitOutOfPolicy')} - - - )} - - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (shouldShowSplitIndicator && isSplitAvailable) { - initSplitExpense( - transaction, - transactionThreadReport, - splitEffectivePolicy, - selfDMReportID, - restrictedActionPolicyID, - personalPolicy?.outputCurrency, - getCurrencyDecimals, - getCurrencySymbol, - ); - return; - } - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - '', - '', - getReportRHPActiveRoute(), - ), - ); - }} - brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('amount')} - copyValue={amountCopyValue} - copyable={!!amountCopyValue} - /> - - {!shouldHideEmptyDescription && ( - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} - brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('comment')} - numberOfLinesTitle={0} - copyValue={descriptionCopyValue} - copyable={!!descriptionCopyValue} - /> - - )} - {isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( - distanceRequestFields - ) : ( - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - wrapperStyle={[styles.taskDescriptionMenuItem]} - furtherDetailsComponent={shouldShowGoogleMerchantSearchLink ? renderGoogleMerchantSearchLink() : undefined} - brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('merchant')} - numberOfLinesTitle={0} - copyValue={merchantCopyValue} - copyable={!!merchantCopyValue} - /> - - )} - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('date')} - copyValue={dateCopyValue} - copyable={!!dateCopyValue} - /> - - {!!shouldShowCategory && ( - - { - if (shouldShowCategoryDisabledAlert) { - showCategoryDisabledAlert(); - return; - } - - if (shouldNavigateToUpgradePath && transactionThreadReport) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, - upgradeBackTo: createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - }), - ), - ); - } else if (!policy && shouldSelectPolicy) { - Navigation.navigate( - ROUTES.SET_DEFAULT_WORKSPACE.getRoute( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - ), - ); - } else { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - ); - } - }} - brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('category')} - copyValue={categoryCopyValue} - copyable={!!categoryCopyValue} - /> - - )} - {shouldShowVendor && ( - - { - if (!transactionThreadReport?.reportID) { - return; - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - getReportRHPActiveRoute(), - ), - ); - }} - brickRoadIndicator={getErrorForField('vendor') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('vendor')} - /> - - )} - {shouldShowTag && tagList} - {!!shouldShowCard && ( - - - - )} - {shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('tax')} - copyValue={taxRateCopyValue} - copyable={!!taxRateCopyValue} - /> - - )} - {shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; - } + const shouldShowDistanceFields = + data.isManualDistanceRequest || data.isGPSDistanceRequest || data.isOdometerDistanceRequest || (data.isMapDistanceRequest && data.transaction?.comment?.waypoints); - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - copyValue={taxAmountCopyValue} - copyable={!!taxAmountCopyValue} - /> - - )} - {shouldShowAttendees && ( - - 1 && formattedPerAttendeeAmount - ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` - : '' - }`} - descriptionTextStyle={styles.textLabelSupportingNormal} - titleComponent={ - Array.isArray(actualAttendees) ? ( - ({ - avatar: a?.avatarUrl, - displayName: a?.displayName ?? a?.email ?? '', - accountID: a?.accountID, - email: a?.email, - }))} - maxVisible={canEdit ? undefined : actualAttendees.length} - /> - ) : undefined - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - onPress={() => { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('attendees')} - interactive={canEdit} - shouldShowRightIcon={canEdit} - copyValue={attendeesCopyValue} - copyable={!!attendeesCopyValue} - /> - - )} - {shouldShowReimbursable && ( - - - - {Str.UCFirst(translate('iou.reimbursable'))} - - - - - )} - {shouldShowBillable && ( - - - - {translate('common.billable')} - - {!!getErrorForField('billable') && ( - - )} - - - - )} - {shouldShowReport && ( - - { - if (!canEditReport || !transactionThreadReport) { - return; - } - if (shouldNavigateToUpgradePath) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - iouType, - action: CONST.IOU.ACTION.EDIT, - transactionID: transaction?.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.REPORTS, - }), - ), - ); - return; - } - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_REPORT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - interactive={canEditReport} - shouldRenderAsHTML - copyValue={reportCopyValue} - copyable={!!reportCopyValue} - /> - - )} - {shouldShowTripRoomLink && ( - <> - { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(tripRoomReportID, undefined, undefined, Navigation.getActiveRoute())); - }} - interactive - /> - - - )} - {/* Note: "View trip details" should be always the last item */} - {shouldShowViewTripDetails && ( - { - const reservations = transaction?.receipt?.reservationList?.length ?? 0; - if (reservations > 1) { - Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(transactionThreadReport?.reportID, transaction.transactionID, getReportRHPActiveRoute())); - } - Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(transactionThreadReport?.reportID, transaction.transactionID, '0', 0, getReportRHPActiveRoute())); - }} - /> - )} - - {hasRequiredCompanyCardViolation && ( - - )} - + return ( + + {data.shouldShowAnimatedBackground && } + {(!data.isInWideRHP || data.isSmallScreenWidth || data.isFromReviewDuplicates || data.isFromMergeTransaction) && ( + + )} + {data.isCustomUnitOutOfPolicy && data.isPerDiemRequest && } + + {!data.shouldHideEmptyDescription && } + {shouldShowDistanceFields ? : } + + {!!data.shouldShowCategory && } + + {data.shouldShowVendor && } + {!!data.shouldShowTag && } + {!!data.shouldShowCard && } + {data.shouldShowTax && } + {data.shouldShowTax && } + {data.shouldShowAttendees && } + {data.shouldShowReimbursable && } + {data.shouldShowBillable && } + {data.shouldShowReport && } + {data.shouldShowTripRoomLink && } + {data.shouldShowViewTripDetails && } + {data.hasRequiredCompanyCardViolation && } ); } +MoneyRequestView.displayName = 'MoneyRequestView'; + export default MoneyRequestView; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/AmountField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/AmountField.tsx new file mode 100644 index 000000000000..392d7648c010 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/AmountField.tsx @@ -0,0 +1,36 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import CONST from '@src/CONST'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type AmountFieldProps = { + data: MoneyRequestViewData; +}; + +function AmountField({data}: AmountFieldProps) { + return ( + + + + ); +} + +AmountField.displayName = 'AmountField'; + +export default AmountField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/AttendeesField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/AttendeesField.tsx new file mode 100644 index 000000000000..b1e035519109 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/AttendeesField.tsx @@ -0,0 +1,54 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import UserPills from '@components/UserPills'; + +import CONST from '@src/CONST'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type AttendeesFieldProps = { + data: MoneyRequestViewData; +}; + +function AttendeesField({data}: AttendeesFieldProps) { + return ( + + 1 && data.formattedPerAttendeeAmount + ? `${CONST.DOT_SEPARATOR} ${data.formattedPerAttendeeAmount} ${data.translate('common.perPerson')}` + : '' + }`} + descriptionTextStyle={data.styles.textLabelSupportingNormal} + titleComponent={ + Array.isArray(data.actualAttendees) ? ( + ({ + avatar: a?.avatarUrl, + displayName: a?.displayName ?? a?.email ?? '', + accountID: a?.accountID, + email: a?.email, + }))} + maxVisible={data.canEdit ? undefined : data.actualAttendees.length} + /> + ) : undefined + } + style={[data.styles.moneyRequestMenuItem]} + titleStyle={data.styles.flex1} + onPress={data.onAttendeesPress} + brickRoadIndicator={data.getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={data.getErrorForField('attendees')} + interactive={data.canEdit} + shouldShowRightIcon={data.canEdit} + copyValue={data.attendeesCopyValue} + copyable={!!data.attendeesCopyValue} + /> + + ); +} + +AttendeesField.displayName = 'AttendeesField'; + +export default AttendeesField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/BillableField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/BillableField.tsx new file mode 100644 index 000000000000..2919c2ebdc2a --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/BillableField.tsx @@ -0,0 +1,54 @@ +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import ViolationMessages from '@components/ViolationMessages'; + +import {View} from 'react-native'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type BillableFieldProps = { + data: MoneyRequestViewData; +}; + +function BillableField({data}: BillableFieldProps) { + return ( + + + + {data.translate('common.billable')} + + {!!data.getErrorForField('billable') && ( + + )} + + + + ); +} + +BillableField.displayName = 'BillableField'; + +export default BillableField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/CardField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/CardField.tsx new file mode 100644 index 000000000000..dfc978f5a441 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/CardField.tsx @@ -0,0 +1,28 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type CardFieldProps = { + data: MoneyRequestViewData; +}; + +function CardField({data}: CardFieldProps) { + return ( + + + + ); +} + +CardField.displayName = 'CardField'; + +export default CardField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/CategoryField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/CategoryField.tsx new file mode 100644 index 000000000000..649f0fbcb0bb --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/CategoryField.tsx @@ -0,0 +1,34 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import CONST from '@src/CONST'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type CategoryFieldProps = { + data: MoneyRequestViewData; +}; + +function CategoryField({data}: CategoryFieldProps) { + return ( + + + + ); +} + +CategoryField.displayName = 'CategoryField'; + +export default CategoryField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/CompanyCardViolationMessage.tsx b/src/components/ReportActionItem/MoneyRequestView/components/CompanyCardViolationMessage.tsx new file mode 100644 index 000000000000..b5949e1ecd44 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/CompanyCardViolationMessage.tsx @@ -0,0 +1,21 @@ +import DotIndicatorMessage from '@components/DotIndicatorMessage'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type CompanyCardViolationMessageProps = { + data: MoneyRequestViewData; +}; + +function CompanyCardViolationMessage({data}: CompanyCardViolationMessageProps) { + return ( + + ); +} + +CompanyCardViolationMessage.displayName = 'CompanyCardViolationMessage'; + +export default CompanyCardViolationMessage; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/DateField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/DateField.tsx new file mode 100644 index 000000000000..ac8db0a7861e --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/DateField.tsx @@ -0,0 +1,34 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import CONST from '@src/CONST'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type DateFieldProps = { + data: MoneyRequestViewData; +}; + +function DateField({data}: DateFieldProps) { + return ( + + + + ); +} + +DateField.displayName = 'DateField'; + +export default DateField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/DescriptionField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/DescriptionField.tsx new file mode 100644 index 000000000000..a6eef9500f39 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/DescriptionField.tsx @@ -0,0 +1,36 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import CONST from '@src/CONST'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type DescriptionFieldProps = { + data: MoneyRequestViewData; +}; + +function DescriptionField({data}: DescriptionFieldProps) { + return ( + + + + ); +} + +DescriptionField.displayName = 'DescriptionField'; + +export default DescriptionField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/DistanceRequestFields.tsx b/src/components/ReportActionItem/MoneyRequestView/components/DistanceRequestFields.tsx new file mode 100644 index 000000000000..c8de7a84529a --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/DistanceRequestFields.tsx @@ -0,0 +1,52 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import CONST from '@src/CONST'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type DistanceRequestFieldsProps = { + data: MoneyRequestViewData; +}; + +function DistanceRequestFields({data}: DistanceRequestFieldsProps) { + return ( + <> + + + + + + + + ); +} + +DistanceRequestFields.displayName = 'DistanceRequestFields'; + +export default DistanceRequestFields; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/MerchantField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/MerchantField.tsx new file mode 100644 index 000000000000..23816aaff903 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/MerchantField.tsx @@ -0,0 +1,71 @@ +import Icon from '@components/Icon'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import Text from '@components/Text'; + +import {openExternalLink} from '@libs/actions/Link'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type MerchantFieldProps = { + data: MoneyRequestViewData; +}; + +function MerchantField({data}: MerchantFieldProps) { + return ( + + { + event?.stopPropagation(); + openExternalLink(`${CONST.GOOGLE_SEARCH_URL}${encodeURIComponent(data.originalMerchantForGoogleSearch)}`); + }} + style={[data.styles.flexRow, data.styles.alignItemsCenter, data.styles.mt1, data.styles.alignSelfStart]} + > + + {data.translate('common.googleThisMerchant', { + merchant: data.originalMerchantForGoogleSearch, + })} + + + + ) : undefined + } + brickRoadIndicator={data.getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={data.getErrorForField('merchant')} + numberOfLinesTitle={0} + copyValue={data.merchantCopyValue} + copyable={!!data.merchantCopyValue} + /> + + ); +} + +MerchantField.displayName = 'MerchantField'; + +export default MerchantField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/PerDiemOutOfPolicyBanner.tsx b/src/components/ReportActionItem/MoneyRequestView/components/PerDiemOutOfPolicyBanner.tsx new file mode 100644 index 000000000000..3ed580ddbb2d --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/PerDiemOutOfPolicyBanner.tsx @@ -0,0 +1,33 @@ +import Icon from '@components/Icon'; +import Text from '@components/Text'; + +import {View} from 'react-native'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type PerDiemOutOfPolicyBannerProps = { + data: MoneyRequestViewData; +}; + +function PerDiemOutOfPolicyBanner({data}: PerDiemOutOfPolicyBannerProps) { + return ( + + + + {data.translate('violations.customUnitOutOfPolicy')} + + + ); +} + +PerDiemOutOfPolicyBanner.displayName = 'PerDiemOutOfPolicyBanner'; + +export default PerDiemOutOfPolicyBanner; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/ReimbursableField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/ReimbursableField.tsx new file mode 100644 index 000000000000..d1a7f3b3bea6 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/ReimbursableField.tsx @@ -0,0 +1,40 @@ +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; + +import {Str} from 'expensify-common'; +import {View} from 'react-native'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type ReimbursableFieldProps = { + data: MoneyRequestViewData; +}; + +function ReimbursableField({data}: ReimbursableFieldProps) { + return ( + + + + {Str.UCFirst(data.translate('iou.reimbursable'))} + + + + + ); +} + +ReimbursableField.displayName = 'ReimbursableField'; + +export default ReimbursableField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/ReportField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/ReportField.tsx new file mode 100644 index 000000000000..11c7a50e26b0 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/ReportField.tsx @@ -0,0 +1,31 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type ReportFieldProps = { + data: MoneyRequestViewData; +}; + +function ReportField({data}: ReportFieldProps) { + return ( + + + + ); +} + +ReportField.displayName = 'ReportField'; + +export default ReportField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/TagField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/TagField.tsx new file mode 100644 index 000000000000..3909b3644307 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/TagField.tsx @@ -0,0 +1,92 @@ +import HighlightableMenuItemWithTopDescription from '@components/HighlightableMenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import {hasEnabledOptions} from '@libs/OptionsListUtils'; +import {shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; +import {getTagForDisplay} from '@libs/TransactionUtils'; + +import Navigation from '@navigation/Navigation'; + +import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type TagFieldProps = { + data: MoneyRequestViewData; +}; + +function TagField({data}: TagFieldProps) { + return data.policyTagLists.map(({name, orderWeight, tags}, index) => { + const tagForDisplay = getTagForDisplay(data.updatedTransaction ?? data.transaction, index); + let shouldShow = false; + if (data.hasDependentTags) { + shouldShow = shouldShowDependentTagList(index, data.transactionTag, tags); + } else { + shouldShow = !!tagForDisplay || (data.canEdit && hasEnabledOptions(tags)); + } + + if (!shouldShow) { + return null; + } + + const tagError = data.getErrorForField( + 'tag', + { + tagListIndex: index, + tagListName: name, + }, + data.hasDependentTags, + tagForDisplay, + ); + const tagCopyValue = !data.canEdit ? tagForDisplay : undefined; + + return ( + + data.previousTagLength} + description={name ?? data.translate('common.tag')} + title={tagForDisplay} + numberOfLinesTitle={2} + interactive={data.canEdit} + shouldShowRightIcon={data.canEdit} + titleStyle={data.styles.flex1} + onPress={() => { + if (!data.transaction?.transactionID || !data.transactionThreadReport?.reportID) { + return; + } + if (data.shouldShowTagDisabledAlert) { + data.showTagDisabledAlert(index); + return; + } + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( + CONST.IOU.ACTION.EDIT, + data.iouType, + orderWeight, + data.transaction.transactionID, + data.transactionThreadReport.reportID, + ), + ), + ); + }} + brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={tagError} + shouldShowBasicTitle + shouldShowDescriptionOnTop + copyValue={tagCopyValue} + copyable={!!tagCopyValue} + /> + + ); + }); +} + +TagField.displayName = 'TagField'; + +export default TagField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/TaxAmountField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/TaxAmountField.tsx new file mode 100644 index 000000000000..24fe0086ce6f --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/TaxAmountField.tsx @@ -0,0 +1,30 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type TaxAmountFieldProps = { + data: MoneyRequestViewData; +}; + +function TaxAmountField({data}: TaxAmountFieldProps) { + return ( + + + + ); +} + +TaxAmountField.displayName = 'TaxAmountField'; + +export default TaxAmountField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/TaxRateField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/TaxRateField.tsx new file mode 100644 index 000000000000..f9f1e94c9d89 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/TaxRateField.tsx @@ -0,0 +1,34 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import CONST from '@src/CONST'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type TaxRateFieldProps = { + data: MoneyRequestViewData; +}; + +function TaxRateField({data}: TaxRateFieldProps) { + return ( + + + + ); +} + +TaxRateField.displayName = 'TaxRateField'; + +export default TaxRateField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/TripField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/TripField.tsx new file mode 100644 index 000000000000..0a1d4fe1ce15 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/TripField.tsx @@ -0,0 +1,31 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; + +import {View} from 'react-native'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type TripFieldProps = { + data: MoneyRequestViewData; +}; + +function TripField({data}: TripFieldProps) { + return ( + <> + + + + ); +} + +TripField.displayName = 'TripField'; + +export default TripField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/VendorField.tsx b/src/components/ReportActionItem/MoneyRequestView/components/VendorField.tsx new file mode 100644 index 000000000000..6f89d975621e --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/VendorField.tsx @@ -0,0 +1,32 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import CONST from '@src/CONST'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type VendorFieldProps = { + data: MoneyRequestViewData; +}; + +function VendorField({data}: VendorFieldProps) { + return ( + + + + ); +} + +VendorField.displayName = 'VendorField'; + +export default VendorField; diff --git a/src/components/ReportActionItem/MoneyRequestView/components/ViewTripDetailsAction.tsx b/src/components/ReportActionItem/MoneyRequestView/components/ViewTripDetailsAction.tsx new file mode 100644 index 000000000000..3352505b9eba --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/components/ViewTripDetailsAction.tsx @@ -0,0 +1,21 @@ +import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; + +import type {MoneyRequestViewData} from '../useMoneyRequestViewData'; + +type ViewTripDetailsActionProps = { + data: MoneyRequestViewData; +}; + +function ViewTripDetailsAction({data}: ViewTripDetailsActionProps) { + return ( + + ); +} + +ViewTripDetailsAction.displayName = 'ViewTripDetailsAction'; + +export default ViewTripDetailsAction; diff --git a/src/components/ReportActionItem/MoneyRequestView/types.ts b/src/components/ReportActionItem/MoneyRequestView/types.ts new file mode 100644 index 000000000000..9fd0262662ff --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/types.ts @@ -0,0 +1,30 @@ +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +type MoneyRequestViewProps = { + /** The report currently being looked at */ + transactionThreadReport?: OnyxEntry; + + parentReportID?: string; + + /** Policy that the report belongs to */ + expensePolicy: OnyxEntry; + + /** Whether we should display the animated banner above the component */ + shouldShowAnimatedBackground: boolean; + + /** Whether we should show Money Request with disabled all fields */ + readonly?: boolean; + + /** whether this report is from review duplicates */ + isFromReviewDuplicates?: boolean; + + /** Updated transaction to show in duplicate & merge transaction flow */ + updatedTransaction?: OnyxEntry; + + /** Merge transaction ID to show in merge transaction flow */ + mergeTransactionID?: string; +}; + +export type {MoneyRequestViewProps}; diff --git a/src/components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData.ts b/src/components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData.ts new file mode 100644 index 000000000000..191e453d913e --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestView/useMoneyRequestViewData.ts @@ -0,0 +1,1339 @@ +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import {usePersonalDetails, usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider'; +import {useSearchResultsContext} from '@components/Search/SearchContext'; +import {useWideRHPState} from '@components/WideRHPContextProvider'; + +import useActiveRoute from '@hooks/useActiveRoute'; +import useAttendees from '@hooks/useAttendees'; +import useCardFeedErrors from '@hooks/useCardFeedErrors'; +import useConfirmModal from '@hooks/useConfirmModal'; +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; +import useEnvironment from '@hooks/useEnvironment'; +import useHasMultipleSplitChildren from '@hooks/useHasMultipleSplitChildren'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePersonalPolicy from '@hooks/usePersonalPolicy'; +import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import {useDerivedReportNamesByReportIDs} from '@hooks/useReportAttributes'; +import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactions from '@hooks/useReportTransactions'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRestrictedActionPolicyID from '@hooks/useRestrictedActionPolicyID'; +import useSplitEffectivePolicy from '@hooks/useSplitEffectivePolicy'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionViolations from '@hooks/useTransactionViolations'; +import type {ViolationField} from '@hooks/useViolations'; +import useViolations from '@hooks/useViolations'; + +import {updateMoneyRequestBillable, updateMoneyRequestCategory, updateMoneyRequestReimbursable, updateMoneyRequestTag, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest'; +import initSplitExpense from '@libs/actions/SplitExpenses'; +import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; +import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from '@libs/CardUtils'; +import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils'; +import {getRateFromMerchant} from '@libs/MergeTransactionUtils'; +import {isBillableEnabledOnPolicy, isSingleTransactionReport} from '@libs/MoneyRequestReportUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import {hasEnabledOptions} from '@libs/OptionsListUtils'; +import Parser from '@libs/Parser'; +import { + canSubmitPerDiemExpenseFromWorkspace, + findVendorByID, + getDistanceRateCustomUnitRate, + getLengthOfTag, + getPerDiemCustomUnit, + getPolicyByCustomUnitID, + getTagLists, + hasDependentTags as hasDependentTagsPolicyUtils, + hasVendorFeature, + isAttendeeTrackingEnabled, + isGroupPolicyByType, + isPerDiemEnabled, + isPolicyAccessible, + isTaxTrackingEnabled, + resolveCurrentTaxCode, + isXeroActiveMatchingSource, +} from '@libs/PolicyUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getReportName} from '@libs/ReportNameUtils'; +import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils'; +import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; +import { + canEditFieldOfMoneyRequest, + canEditMoneyRequest, + canUserPerformWriteAction as canUserPerformWriteActionReportUtils, + getTransactionDetails, + getTripIDFromTransactionParentReportID, + isExpenseReport, + isInvoiceReport, + isOpenReport, + isReportApproved, + isSettled as isSettledReportUtils, + isTrackExpenseReportNew, + shouldEnableNegative, +} from '@libs/ReportUtils'; +import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; +import { + getAttendeesListDisplayString, + getBillable, + getCurrency, + getDescription, + getDistanceInMeters, + getFormattedCreated, + getOriginalAmountForDisplay, + getOriginalTransactionWithSplitInfo, + getReimbursable, + getTagForDisplay, + getTaxName, + hasMissingSmartscanFields, + hasReservationList, + hasRoute as hasRouteTransactionUtils, + isFromCreditCardImport as isCardTransactionTransactionUtils, + isCategoryBeingAnalyzed, + isCustomUnitRateIDForP2P, + isDistanceRequest as isDistanceRequestTransactionUtils, + isDistanceTypeRequest, + isExpenseUnreported as isExpenseUnreportedTransactionUtils, + isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils, + isManagedCardTransaction as isManagedCardTransactionTransactionUtils, + isManualDistanceRequest as isManualDistanceRequestTransactionUtils, + isMapDistanceRequest as isMapDistanceRequestTransactionUtils, + isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, + isPerDiemRequest as isPerDiemRequestTransactionUtils, + isScanning, + isTimeRequest as isTimeRequestTransactionUtils, + isTransactionPendingDelete, + shouldShowAttendees as shouldShowAttendeesTransactionUtils, +} from '@libs/TransactionUtils'; +import {isInvalidMerchantValue} from '@libs/ValidationUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; + +import Navigation from '@navigation/Navigation'; + +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import {useRoute} from '@react-navigation/native'; +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {policyTypeSelector} from '@selectors/Policy'; +import {Str} from 'expensify-common'; +import {useState} from 'react'; +// Use the original useOnyx hook to get the real-time data from Onyx and not from the snapshot +// eslint-disable-next-line no-restricted-imports +import {useOnyx as originalUseOnyx} from 'react-native-onyx'; + +import type {MoneyRequestViewProps} from './types'; + +const perDiemPoliciesSelector = (policies: OnyxCollection) => { + return Object.fromEntries( + Object.entries(policies ?? {}).filter(([, policyItem]) => { + const perDiemCustomUnit = getPerDiemCustomUnit(policyItem); + const hasPolicyPerDiemRates = !isEmptyObject(perDiemCustomUnit?.rates); + + return isPerDiemEnabled(policyItem) && hasPolicyPerDiemRates; + }), + ); +}; + +type MoneyRequestViewData = ReturnType; + +function useMoneyRequestViewData({ + transactionThreadReport, + parentReportID, + expensePolicy, + shouldShowAnimatedBackground, + readonly = false, + updatedTransaction, + isFromReviewDuplicates = false, + mergeTransactionID, +}: MoneyRequestViewProps) { + const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase', 'NewWindow']); + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + const {isOffline} = useNetwork(); + const {environmentURL} = useEnvironment(); + const {translate, toLocaleDigit, localeCompare, dateFnsLocale} = useLocalize(); + const {convertToDisplayString, getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); + const {getReportRHPActiveRoute} = useActiveRoute(); + const {showConfirmModal} = useConfirmModal(); + const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); + + const {currentSearchResults} = useSearchResultsContext(); + + // When this component is used when merging from the search page, we might not have the parent report stored in the main collection + const [parentReportFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); + const parentReport = parentReportFromOnyx ?? currentSearchResults?.data[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; + const [iouReportOwnerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(parentReport?.ownerAccountID)}); + const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); + + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`); + const parentReportAction = transactionThreadReport?.parentReportActionID ? parentReportActions?.[transactionThreadReport.parentReportActionID] : undefined; + + const isFromMergeTransaction = !!mergeTransactionID; + const linkedTransactionID = parentReportAction && isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; + const [onyxTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(linkedTransactionID)}`); + const transaction = updatedTransaction ?? onyxTransaction; + const isExpenseUnreported = isExpenseUnreportedTransactionUtils(transaction); + const personalPolicy = usePersonalPolicy(); + const {policyForMovingExpensesID, policyForMovingExpenses, shouldSelectPolicy, shouldNavigateToUpgradePath} = usePolicyForMovingExpenses(); + const isTimeRequest = isTimeRequestTransactionUtils(transaction); + + const [policiesWithPerDiem] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: perDiemPoliciesSelector, + }); + const splitEffectivePolicy = useSplitEffectivePolicy(transactionThreadReport, undefined, transaction); + const isPerDiemRequest = isPerDiemRequestTransactionUtils(transaction); + const perDiemOriginalPolicy = getPolicyByCustomUnitID(transaction, policiesWithPerDiem); + + const customUnitRateID = isDistanceRequestTransactionUtils(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; + const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(expensePolicy, customUnitRateID); + const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); + + let policy: OnyxEntry; + let policyID: string | undefined; + // If the expense is unreported the policy should be the user's default policy, if the expense is a per diem request and is unreported + // the policy should be the one where the per diem rates are enabled, otherwise it should be the expense's report policy + if (isExpenseUnreported && !isPerDiemRequest) { + policy = policyForMovingExpenses; + policyID = policyForMovingExpensesID; + } else if (isExpenseUnreported && isPerDiemRequest) { + policy = perDiemOriginalPolicy; + policyID = perDiemOriginalPolicy?.id; + } else { + policy = expensePolicy; + policyID = parentReport?.policyID; + } + + // Use the report's real policy, not `policy` above (swapped to an unrelated workspace for + // unreported expenses), else self-DM split editing wrongly redirects to RESTRICTED_ACTION. + const restrictedActionPolicyID = useRestrictedActionPolicyID(expensePolicy); + + const allPolicyCategories = usePolicyCategories(); + const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; + const targetPolicyID = updatedTransaction?.reportID ? parentReport?.policyID : policyID; + const allPolicyTags = usePolicyTags(); + const policyTagList = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`]; + const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); + + const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${getNonEmptyStringOnyxID(linkedTransactionID)}`); + const transactionViolations = useTransactionViolations(transaction?.transactionID, true, distanceOriginalPolicy ?? policy); + const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); + const personalDetailsList = usePersonalDetails(); + const currentUserAccountIDParam = currentUserPersonalDetails.accountID; + const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; + const {isBetaEnabled} = usePermissions(); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const isP2PDistanceRequest = isCustomUnitRateIDForP2P(transaction); + const moneyRequestReport = parentReport; + const parentReportTransactions = useReportTransactions(moneyRequestReport?.reportID); + // Exclude transactions pending deletion so the report is recognized as single-expense immediately after deleting one of its expenses, + // instead of waiting for the optimistic delete to be removed from Onyx (https://github.com/Expensify/App/issues/91058). + // While offline the deleted expense is still rendered, so keep counting it to stay consistent with the visible transaction list. + const visibleParentReportTransactions = parentReportTransactions.filter((t) => isOffline || !isTransactionPendingDelete(t)); + const isApproved = isReportApproved({report: moneyRequestReport}); + const isInvoice = isInvoiceReport(moneyRequestReport); + const isTrackExpense = !mergeTransactionID && isTrackExpenseReportNew(transactionThreadReport, moneyRequestReport, parentReportAction); + + let iouType: ValueOf; + if (isTrackExpense) { + iouType = CONST.IOU.TYPE.TRACK; + } else if (isInvoice) { + iouType = CONST.IOU.TYPE.INVOICE; + } else { + iouType = CONST.IOU.TYPE.SUBMIT; + } + + const allowNegativeAmount = shouldEnableNegative(parentReport, policy, iouType); + + const { + created: transactionDate, + amount: transactionAmount, + taxAmount: transactionTaxAmount, + currency: transactionCurrency, + comment: transactionDescription, + merchant: transactionMerchant, + reimbursable: transactionReimbursable, + billable: transactionBillable, + category: transactionCategory, + tag: transactionTag, + originalCurrency: transactionOriginalCurrency, + postedDate: transactionPostedDate, + convertedAmount: transactionConvertedAmount, + } = getTransactionDetails(transaction, undefined, undefined, allowNegativeAmount, false) ?? {}; + const transactionAttendees = useAttendees(transaction); + const isEmptyMerchant = isInvalidMerchantValue(transactionMerchant); + const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); + const isManualDistanceRequest = isManualDistanceRequestTransactionUtils(transaction); + const isGPSDistanceRequest = isGPSDistanceRequestTransactionUtils(transaction); + const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction); + const isMapDistanceRequest = isMapDistanceRequestTransactionUtils(transaction) || isDistanceTypeRequest(transaction); + const isTransactionScanning = isScanning(updatedTransaction ?? transaction); + const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest); + + const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees; + const actualAttendees = enrichAndSortAttendees(rawActualAttendees, loginToAccountIDMap, personalDetailsList, localeCompare); + + // Use the updated transaction amount in merge flow to have correct positive/negative sign + const actualAmount = isFromMergeTransaction && updatedTransaction ? updatedTransaction.amount : transactionAmount; + const actualCurrency = updatedTransaction ? getCurrency(updatedTransaction) : transactionCurrency; + const shouldDisplayTransactionAmount = (isDistanceRequest && hasRoute) || !isDistanceRequest; + const formattedTransactionAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount, actualCurrency) : ''; + const formattedPerAttendeeAmount = + shouldDisplayTransactionAmount && actualAmount !== undefined ? convertToDisplayString(actualAmount / (transactionAttendees?.length ?? 1), actualCurrency) : ''; + + const transactionOriginalAmount = transaction && getOriginalAmountForDisplay(transaction, isExpenseReport(moneyRequestReport)); + const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); + const isFromCardImport = isCardTransactionTransactionUtils(transaction); + const cardProgramName = getCompanyCardDescription(translate, transaction?.cardName, transaction?.cardID, nonPersonalAndWorkspaceCards); + const shouldShowCard = isFromCardImport && cardProgramName; + + const taxRates = policy?.taxRates; + const formattedTaxAmount = + updatedTransaction?.taxAmount !== undefined + ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), actualCurrency) + : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency); + // Skip a zero converted tax (e.g. tax exempt) so we don't render a redundant "Converted 0.00". + const formattedConvertedTaxAmount = transaction?.convertedTaxAmount ? convertToDisplayString(Math.abs(transaction.convertedTaxAmount), moneyRequestReport?.currency) : ''; + + const taxRatesDescription = taxRates?.name; + + const baseTransaction = updatedTransaction ?? transaction; + const {taxCode, taxValue} = baseTransaction ?? {}; + + const taxRateTitle = getTaxName(policy, baseTransaction, isExpenseUnreported); + const resolvedTaxCode = taxCode ? resolveCurrentTaxCode(policy, taxCode) : undefined; + const selectedPolicyTaxValue = resolvedTaxCode ? policy?.taxRates?.taxes?.[resolvedTaxCode]?.value : undefined; + const hasTaxValueChanged = taxCode && taxValue !== undefined ? selectedPolicyTaxValue !== taxValue : false; + + const actualTransactionDate = isFromMergeTransaction && updatedTransaction ? getFormattedCreated(updatedTransaction) : transactionDate; + const fallbackTaxRateTitle = transaction?.taxValue; + + const isSettled = isSettledReportUtils(moneyRequestReport); + const isCancelled = moneyRequestReport?.isCancelledIOU; + const isChatReportArchived = useReportIsArchived(moneyRequestReport?.chatReportID); + const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); + const pendingAction = transaction?.pendingAction; + const shouldShowPaid = isSettled && transactionReimbursable && !pendingAction; + + // Flags for allowing or disallowing editing an expense + // Used for non-restricted fields such as: description, category, tag, billable, etc... + const isReportArchived = useReportIsArchived(transactionThreadReport?.reportID); + const isEditable = !!canUserPerformWriteActionReportUtils(transactionThreadReport, isReportArchived) && !readonly; + const canEdit = + isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, isChatReportArchived, moneyRequestReport, policy, parentReportActions) && isEditable; + const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(transactionThreadReport?.policyID)}`; + const {personalCardsWithBrokenConnection} = useCardFeedErrors(); + const connectionLink = getBrokenConnectionUrlToFixPersonalCard(personalCardsWithBrokenConnection, environmentURL); + const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transaction?.comment?.originalTransactionID)}`); + const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); + const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); + const [reportPolicyType] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport?.policyID}`, {selector: policyTypeSelector}); + const hasMultipleSplits = useHasMultipleSplitChildren(transaction?.comment?.originalTransactionID); + const isReportOpen = isOpenReport(moneyRequestReport); + const shouldShowSplitIndicator = isExpenseSplit && (hasMultipleSplits || isReportOpen); + const isSplitAvailable = + moneyRequestReport && + transaction && + isSplitAction(moneyRequestReport, [transaction], originalTransaction, currentUserPersonalDetails.login ?? '', currentUserPersonalDetails.accountID, policy); + + const canEditTaxFields = canEdit && !isDistanceRequest; + const canEditAmount = + !isGPSDistanceRequest && + isEditable && + (canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.AMOUNT, + isChatReportArchived, + reportNameValuePairs, + transaction, + }) || + (shouldShowSplitIndicator && isSplitAvailable)); + const canEditMerchant = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.MERCHANT, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }); + + const canEditDate = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.DATE, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }); + + const canEditDistanceOrRate = isPolicyAccessible(policy, currentUserEmailParam) || isTrackExpense || isP2PDistanceRequest; + + const canEditDistance = + !isGPSDistanceRequest && + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.DISTANCE, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }) && + canEditDistanceOrRate; + + const canEditDistanceRate = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }) && + canEditDistanceOrRate; + + const canEditReport = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REPORT, + isChatReportArchived, + outstandingReportsByPolicyID, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }) && + (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(policy) || (isExpenseUnreported && !!perDiemOriginalPolicy)); + + // A flag for verifying that the current report is a sub-report of a expense chat + // if the policy of the report is either Collect or Control, then this report must be tied to expense chat + const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); + const policyTagLists = getTagLists(policyTagList); + + const category = transactionCategory ?? ''; + const categoryForDisplay = isCategoryMissing(category) ? '' : category; + + // Flags for showing categories and tags + // transactionCategory can be an empty string + + const shouldShowCategory = + (isPolicyExpenseChat && (categoryForDisplay || hasEnabledOptions(policyCategories ?? {}))) || + (isExpenseUnreported && (!policyForMovingExpenses || hasEnabledOptions(policyCategories ?? {}))); + // transactionTag can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const shouldShowTag = (isPolicyExpenseChat || isExpenseUnreported) && (transactionTag || (canEdit && hasEnabledTags(policyTagLists))); + // Surface a delete confirmation (like tax) when the value is stale and there's nothing valid to select, instead of + // navigating to edit. Categories need at least one, so they only hit this when disabled; tags can be fully emptied, + // so also cover "no enabled tags remain". Applies to multi-level tags too - deleting clears the whole tag value. + const shouldShowCategoryDisabledAlert = !policy?.areCategoriesEnabled && !!category; + const shouldShowTagDisabledAlert = (!policy?.areTagsEnabled || !hasEnabledTags(policyTagLists)) && !!transactionTag; + const shouldShowBillable = (isPolicyExpenseChat || isExpenseUnreported) && (!!transactionBillable || isBillableEnabledOnPolicy(policy) || !!updatedTransaction?.billable); + const isCurrentTransactionReimbursableDifferentFromPolicyDefault = + policy?.defaultReimbursable !== undefined && !!(updatedTransaction?.reimbursable ?? transactionReimbursable) !== policy.defaultReimbursable; + const shouldShowReimbursable = + (isPolicyExpenseChat || (isExpenseUnreported && !!policy)) && + (policy?.disabledFields?.reimbursable !== true || isCurrentTransactionReimbursableDifferentFromPolicyDefault) && + !isManagedCardTransactionTransactionUtils(transaction) && + !isInvoice; + const canEditReimbursable = + isEditable && + canEditFieldOfMoneyRequest({ + reportAction: parentReportAction, + fieldToEdit: CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, + isChatReportArchived, + reportNameValuePairs, + transaction, + report: moneyRequestReport, + policy, + }); + const shouldShowAttendees = shouldShowAttendeesTransactionUtils(iouType, policy); + + const transactionVendor = transaction?.comment?.vendor; + + // The title prefers any matched vendor name (active vendor-matching integration first, then a + // permissive cross-connection lookup), then the display name persisted on the transaction at + // match/assign time. When no connection has the vendor any more and no name was persisted, fall + // back to the raw externalID so admins still see a stable identifier for what was assigned — the + // red INACTIVE_VENDOR violation (from ViolationsUtils, scoped to the active integration) provides + // the "Vendor no longer valid" indicator separately. + const matchedVendorName = findVendorByID(policy, transactionVendor?.externalID)?.name; + let transactionVendorName = ''; + if (matchedVendorName) { + transactionVendorName = matchedVendorName; + } else if (transactionVendor?.name) { + transactionVendorName = transactionVendor.name; + } else if (transactionVendor?.externalID) { + transactionVendorName = transactionVendor.externalID; + } + const shouldShowVendor = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)) && !(updatedTransaction?.reimbursable ?? !!transactionReimbursable) && !isInvoice; + const vendorFieldLabel = isXeroActiveMatchingSource(policy) ? translate('common.supplier') : translate('common.vendor'); + + const tripID = getTripIDFromTransactionParentReportID(parentReport?.parentReportID); + const shouldShowViewTripDetails = hasReservationList(transaction) && !!tripID; + + const transactionTripID = transaction?.comment?.tripID; + + // Trip rooms are the grandparent report, so check that first before scanning the collection. + const grandparentReportID = parentReport?.parentReportID; + const tripRoomReportSelector = (reports: OnyxCollection): OnyxEntry => { + if (!transactionTripID || !reports) { + return undefined; + } + const grandparent = grandparentReportID ? reports[`${ONYXKEYS.COLLECTION.REPORT}${grandparentReportID}`] : undefined; + const match = + grandparent?.tripData?.tripID === transactionTripID ? grandparent : Object.values(reports).find((candidateReport) => candidateReport?.tripData?.tripID === transactionTripID); + if (!match?.reportID) { + return undefined; + } + return match; + }; + const [tripRoomReport] = originalUseOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: tripRoomReportSelector}); + const tripRoomReportID = tripRoomReport?.reportID; + + const derivedReportNames = useDerivedReportNamesByReportIDs([tripRoomReportID, parentReport?.reportID]); + + const tripRoomDerivedName = tripRoomReportID ? derivedReportNames?.[tripRoomReportID] : undefined; + const tripRoomName = tripRoomReport ? getReportName(tripRoomReport, tripRoomDerivedName) : undefined; + const shouldShowTripRoomLink = !!tripRoomReportID && !!tripRoomName; + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + + const {getViolationsForField} = useViolations(transactionViolations ?? [], isTransactionScanning || !isGroupPolicyByType(reportPolicyType)); + const hasViolations = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string): boolean => + getViolationsForField(field, data, policyHasDependentTags, tagValue).length > 0; + const isMarkAsCash = parentReport && currentUserEmailParam ? isMarkAsCashActionForTransaction(currentUserEmailParam, parentReport, transactionViolations, policy) : false; + // Need to return undefined when we have pendingAction to avoid the duplicate pending action + const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => (pendingAction ? undefined : transaction?.pendingFields?.[fieldPath]); + + const isTaxEnabled = isTaxTrackingEnabled(isPolicyExpenseChat || isExpenseUnreported, policy, isDistanceRequest, isPerDiemRequest, isTimeRequest); + const shouldShowTaxDisabledAlert = !isTaxEnabled && !!transaction?.taxCode && !isTimeRequest && !isPerDiemRequest; + const shouldShowTax = isFromMergeTransaction ? !!transaction?.taxName : isTaxEnabled || shouldShowTaxDisabledAlert; + + let amountDescription = `${translate('iou.amount')}`; + let dateDescription = `${translate('common.date')}`; + + const { + unit, + rate, + name: rateName, + } = DistanceRequestUtils.getRate({ + transaction: updatedTransaction ?? transaction, + policy: distanceOriginalPolicy ?? policy, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, + }); + const distance = getDistanceInMeters(transactionBackup ?? updatedTransaction ?? transaction, unit); + const currency = transactionCurrency ?? CONST.CURRENCY.USD; + const hasRequiredCompanyCardViolation = transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.COMPANY_CARD_REQUIRED); + const isCustomUnitOutOfPolicy = + (transactionViolations.some((violation) => violation.name === CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY) || (isDistanceRequest && !rate)) && !isTrackExpense; + const calculateFromTransactionData = isTrackExpense && !rate; + const distanceUnit = calculateFromTransactionData ? transaction?.comment?.customUnit?.distanceUnit : unit; + const distanceRate = calculateFromTransactionData ? (transactionAmount ?? 0) / (transaction?.comment?.customUnit?.quantity ?? 1) : rate; + let rateToDisplay = DistanceRequestUtils.getRateForExpenseDisplay( + rateName, + isCustomUnitOutOfPolicy, + distanceUnit, + distanceRate, + currency, + translate, + toLocaleDigit, + getCurrencySymbol, + isOffline, + ); + + const distanceUnitValue = transaction?.comment?.customUnit?.distanceUnit ?? unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + // Commuter exclusions are a workspace control, so they are never surfaced on a personal (self-DM or P2P) expense, + // even if one carries the fields from a workspace rate it was created with. + const commuterExclusionData = + transaction?.comment?.originalTransactionID || !isPolicyExpenseChat + ? null + : DistanceRequestUtils.getCommuterExclusionDisplayData(transaction?.comment?.customUnit, distanceUnitValue); + const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, translate, false, isManualDistanceRequest, commuterExclusionData); + const {distanceToDisplayDescription, distanceToDisplayHintText} = DistanceRequestUtils.getDistanceDisplayDetailsWithCommuter(commuterExclusionData, distanceUnitValue, translate); + + let merchantTitle = isEmptyMerchant ? '' : transactionMerchant; + let amountTitle = formattedTransactionAmount?.toString() || ''; + if (isTransactionScanning) { + merchantTitle = translate('iou.receiptStatusTitle'); + amountTitle = translate('iou.receiptStatusTitle'); + } + + const updatedTransactionDescription = getDescription(updatedTransaction) || undefined; + const shouldHideEmptyDescription = (isFromReviewDuplicates || isFromMergeTransaction) && !(updatedTransactionDescription ?? transactionDescription); + const isEmptyUpdatedMerchant = isInvalidMerchantValue(updatedTransaction?.modifiedMerchant); + const updatedMerchantTitle = isEmptyUpdatedMerchant ? '' : (updatedTransaction?.modifiedMerchant ?? merchantTitle); + const originalMerchantForGoogleSearch = isInvalidMerchantValue(onyxTransaction?.merchant) ? '' : Str.recapitalize(onyxTransaction?.merchant ?? ''); + const shouldShowGoogleMerchantSearchLink = !!originalMerchantForGoogleSearch && !isTransactionScanning && isFromCardImport; + + const shouldShowConvertedAmount = + transactionConvertedAmount && + currency !== moneyRequestReport?.currency && + !isFromCardImport && + transaction?.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID && + !isFromMergeTransaction && + !isFromReviewDuplicates && + !getPendingFieldAction('amount') && + !pendingAction; + + if (isFromCardImport) { + if (transactionPostedDate) { + dateDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.posted')} ${transactionPostedDate}`; + } + if (formattedOriginalAmount) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.purchase')} ${formattedOriginalAmount}`; + } + if (isCancelled) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; + } + } else if (isCancelled) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.canceled')}`; + } else if (isApproved) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.approved')}`; + } else if (shouldShowPaid) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.settledExpensify')}`; + } + if (shouldShowSplitIndicator) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('iou.split')}`; + } + if (shouldShowConvertedAmount) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${convertToDisplayString(transactionConvertedAmount, moneyRequestReport?.currency)}`; + } + const isCurrentTransactionReimbursable = updatedTransaction?.reimbursable ?? !!transactionReimbursable; + if (!isCurrentTransactionReimbursable && isSingleTransactionReport(moneyRequestReport, visibleParentReportTransactions)) { + amountDescription += ` ${CONST.DOT_SEPARATOR} ${Str.UCFirst(translate('iou.nonReimbursable'))}`; + } + + // Show the converted tax here since the redundant report-level Tax total is hidden for a single-expense report. + let taxAmountDescription = translate('iou.taxAmount'); + if (shouldShowConvertedAmount && formattedConvertedTaxAmount) { + taxAmountDescription += ` ${CONST.DOT_SEPARATOR} ${translate('common.converted')} ${formattedConvertedTaxAmount}`; + } + + if (isFromMergeTransaction && !rateName) { + // Because we lack the necessary data in policy.customUnits to determine the rate in merge flow, + // We need to extract the rate from the merchant string + // See https://github.com/Expensify/App/pull/71675#issuecomment-3425488228 for more information + rateToDisplay = getRateFromMerchant(updatedMerchantTitle); + } + + const hasErrors = hasMissingSmartscanFields(transaction, transactionReport); + const isMissingAttendeesViolation = getIsMissingAttendeesViolation( + policyCategories, + updatedTransaction?.category ?? categoryForDisplay, + actualAttendees, + currentUserPersonalDetails, + isAttendeeTrackingEnabled(policy), + policy?.type === CONST.POLICY.TYPE.CORPORATE, + ); + + const getErrorForField = (field: ViolationField, data?: OnyxTypes.TransactionViolation['data'], policyHasDependentTags = false, tagValue?: string) => { + // Checks applied when creating a new expense + // NOTE: receipt field can return multiple violations, so we need to handle it separately + const fieldChecks: Partial> = { + merchant: { + isError: !isSettled && !isCancelled && isPolicyExpenseChat && isEmptyMerchant, + translationPath: canEditMerchant ? 'common.error.enterMerchant' : 'common.error.missingMerchantName', + }, + date: { + isError: transactionDate === '', + translationPath: canEditDate ? 'common.error.enterDate' : 'common.error.missingDate', + }, + }; + + const {isError, translationPath} = fieldChecks[field] ?? {}; + + if (readonly) { + return ''; + } + + // Return form errors if there are any + if (hasErrors && isError && translationPath) { + return translate(translationPath); + } + + if (isCustomUnitOutOfPolicy && field === 'customUnitRateID') { + return translate('violations.customUnitOutOfPolicy'); + } + + // Return violations if there are any + if (field !== 'merchant' && hasViolations(field, data, policyHasDependentTags, tagValue)) { + const violations = getViolationsForField(field, data, policyHasDependentTags, tagValue); + return `${violations + .map((violation) => { + const cardID = violation.data?.cardID; + const card = cardID ? cardList?.[cardID] : undefined; + return ViolationsUtils.getViolationTranslation({ + dateFnsLocale, + violation, + translate, + convertToDisplayString, + canEdit, + companyCardPageURL, + connectionLink, + card, + isMarkAsCash, + routeDistanceMeters: transaction?.comment?.customUnit?.routeDistanceMeters, + distanceUnit: transaction?.comment?.customUnit?.distanceUnit, + }); + }) + .join('. ')}.`; + } + + if (field === 'attendees' && isMissingAttendeesViolation) { + return translate('violations.missingAttendees'); + } + + return ''; + }; + + const saveBillable = (newBillable: boolean) => { + // If the value hasn't changed, don't request to save changes on the server and just close the modal + if (newBillable === getBillable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + updateMoneyRequestBillable({ + transactionID: transaction.transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + value: newBillable, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + isOffline, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }; + + const saveReimbursable = (newReimbursable: boolean) => { + // If the value hasn't changed, don't request to save changes on the server and just close the modal + if (newReimbursable === getReimbursable(transaction) || !transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + updateMoneyRequestReimbursable({ + transactionID: transaction.transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + value: newReimbursable, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + isOffline, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }; + + const showTaxDisabledAlert = () => { + showConfirmModal({ + title: translate('iou.taxDisabledAlert.title'), + prompt: translate('iou.taxDisabledAlert.prompt'), + confirmText: translate('iou.taxDisabledAlert.confirmText'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !canEditTaxFields) { + return; + } + + updateMoneyRequestTaxRate({ + transactionID: transaction?.transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + taxCode: '', + taxValue: '', + taxAmount: 0, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }); + }; + + const showCategoryDisabledAlert = () => { + const transactionID = transaction?.transactionID; + if (!transactionID) { + return; + } + showConfirmModal({ + title: translate('iou.categoryDisabledAlert.title'), + prompt: translate('iou.categoryDisabledAlert.prompt'), + confirmText: translate('iou.categoryDisabledAlert.confirmText'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !canEdit) { + return; + } + + updateMoneyRequestCategory({ + transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + category: '', + policy, + policyTagList, + policyCategories, + policyRecentlyUsedCategories: undefined, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }); + }; + + const showTagDisabledAlert = (tagListIndex: number) => { + const transactionID = transaction?.transactionID; + if (!transactionID) { + return; + } + showConfirmModal({ + title: translate('iou.tagDisabledAlert.title'), + prompt: translate('iou.tagDisabledAlert.prompt'), + confirmText: translate('iou.tagDisabledAlert.confirmText'), + cancelText: translate('common.cancel'), + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !canEdit) { + return; + } + + // Clear only the pressed level so the other levels of a multi-level tag are kept. + const updatedTag = insertTagIntoTransactionTagsString(transactionTag ?? '', '', tagListIndex, policy?.hasMultipleTagLists ?? false); + updateMoneyRequestTag({ + transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + tag: updatedTag, + policy, + policyTagList, + policyRecentlyUsedTags: undefined, + policyCategories, + currentUserAccountIDParam, + currentUserEmailParam, + isASAPSubmitBetaEnabled, + isOffline, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + }); + }); + }; + + const distanceCopyValue = !canEditDistance ? distanceToDisplay : undefined; + const distanceRateCopyValue = !canEditDistanceRate ? rateToDisplay : undefined; + const amountCopyValue = !canEditAmount ? amountTitle : undefined; + const descriptionHTML = updatedTransactionDescription ?? transactionDescription; + const descriptionCopyValue = !canEdit && descriptionHTML ? Parser.htmlToText(descriptionHTML) : undefined; + const merchantCopyValue = !canEditMerchant ? updatedMerchantTitle : undefined; + const dateCopyValue = !canEditDate ? transactionDate : undefined; + const categoryValue = updatedTransaction?.category ?? categoryForDisplay; + const decodedCategoryName = getDecodedLeafCategoryName(categoryValue); + const categoryCopyValue = !canEdit ? decodedCategoryName : undefined; + const cardCopyValue = cardProgramName; + const taxRateValue = hasTaxValueChanged ? taxValue : (transaction?.taxName ?? taxRateTitle ?? fallbackTaxRateTitle ?? ''); + const taxRateCopyValue = !canEditTaxFields ? taxRateValue : undefined; + const taxAmountTitle = formattedTaxAmount ? formattedTaxAmount.toString() : ''; + const taxAmountCopyValue = !canEditTaxFields ? taxAmountTitle : undefined; + + const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTagList); + + const [previousTransactionTag, setPreviousTransactionTag] = useState(transactionTag); + const [previousTag, setPreviousTag] = useState(undefined); + const [currentTransactionTag, setCurrentTransactionTag] = useState(undefined); + if (transactionTag !== previousTransactionTag) { + setPreviousTransactionTag(transactionTag); + setPreviousTag(previousTransactionTag); + setCurrentTransactionTag(transactionTag); + } + const previousTagLength = getLengthOfTag(previousTag ?? ''); + const currentTagLength = getLengthOfTag(currentTransactionTag ?? ''); + + // actualAttendees is already sorted by enrichAndSortAttendees above; pass without localeCompare to preserve that order while stripping the SMS domain. + const getAttendeesTitle = Array.isArray(actualAttendees) ? getAttendeesListDisplayString(actualAttendees) : ''; + const attendeesCopyValue = !canEdit ? getAttendeesTitle : undefined; + + const parentReportDerivedName = parentReport?.reportID ? derivedReportNames?.[parentReport.reportID] : undefined; + const reportNameToDisplay = isFromMergeTransaction ? (updatedTransaction?.reportName ?? translate('common.none')) : getReportName(parentReport, parentReportDerivedName); + const shouldShowReport = !!parentReportID || (isFromMergeTransaction && !!reportNameToDisplay); + const reportCopyValue = !canEditReport && reportNameToDisplay !== translate('common.none') ? reportNameToDisplay : undefined; + const shouldShowCategoryAnalyzing = isCategoryBeingAnalyzed(updatedTransaction ?? transaction); + + // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const {wideRHPRouteKeys} = useWideRHPState(); + const route = useRoute(); + // The receipt is hidden only for the MoneyRequestView rendered inside a wide RHP, where the wide RHP's left + // receipt panel already shows it. Instances mounted on other screens (e.g. the central report pane behind + // the RHP) must keep their inline receipt, so the check is scoped to this view's own route key. + const isInWideRHP = wideRHPRouteKeys.includes(route.key); + + const isLoading = (!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID; + + const onAmountPress = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (shouldShowSplitIndicator && isSplitAvailable) { + initSplitExpense( + transaction, + transactionThreadReport, + splitEffectivePolicy, + selfDMReportID, + restrictedActionPolicyID, + personalPolicy?.outputCurrency, + getCurrencyDecimals, + getCurrencySymbol, + ); + return; + } + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID, '', '', getReportRHPActiveRoute()), + ); + }; + + const onDescriptionPress = () => { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onMerchantPress = () => { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onDatePress = () => { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onCategoryPress = () => { + if (shouldShowCategoryDisabledAlert) { + showCategoryDisabledAlert(); + return; + } + + if (!transaction?.transactionID) { + return; + } + + if (shouldNavigateToUpgradePath && transactionThreadReport) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, + upgradeBackTo: createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + }), + ), + ); + } else if (!policy && shouldSelectPolicy) { + Navigation.navigate( + ROUTES.SET_DEFAULT_WORKSPACE.getRoute( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + ), + ); + } else { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + ); + } + }; + + const onDistancePress = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (isOdometerDistanceRequest) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)); + return; + } + + if (isManualDistanceRequest) { + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + }; + + const onDistanceRatePress = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (isTrackExpense) { + if (shouldNavigateToUpgradePath && transactionThreadReport) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + upgradePath: CONST.UPGRADE_PATHS.DISTANCE_RATES, + }), + ), + ); + return; + } + if (!policy && shouldSelectPolicy) { + Navigation.navigate( + ROUTES.SET_DEFAULT_WORKSPACE.getRoute( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), + ), + ), + ); + return; + } + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + }; + + const onTaxRatePress = () => { + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } + + if (!transaction?.transactionID) { + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onTaxAmountPress = () => { + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } + + if (!transaction?.transactionID) { + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onReportPress = () => { + if (!canEditReport || !transactionThreadReport || !transaction?.transactionID) { + return; + } + if (shouldNavigateToUpgradePath) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + iouType, + action: CONST.IOU.ACTION.EDIT, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + upgradePath: CONST.UPGRADE_PATHS.REPORTS, + }), + ), + ); + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_REPORT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const onTripRoomPress = () => { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(tripRoomReportID, undefined, undefined, Navigation.getActiveRoute())); + }; + + const onViewTripDetailsPress = () => { + if (!transaction?.transactionID) { + return; + } + const reservations = transaction?.receipt?.reservationList?.length ?? 0; + if (reservations > 1) { + Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(transactionThreadReport?.reportID, transaction.transactionID, getReportRHPActiveRoute())); + } + Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(transactionThreadReport?.reportID, transaction.transactionID, '0', 0, getReportRHPActiveRoute())); + }; + + const onAttendeesPress = () => { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID))); + }; + + const onVendorPress = () => { + if (!transactionThreadReport?.reportID || !transaction?.transactionID) { + return; + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID, getReportRHPActiveRoute()), + ); + }; + + return { + // Loading / guard + isLoading, + + // Styles / localize + styles, + theme, + StyleUtils, + icons, + translate, + + // Core data + transaction, + transactionThreadReport, + parentReport, + policy, + policyID, + iouType, + transactionReimbursable, + transactionBillable, + transactionDescription, + updatedTransactionDescription, + readonly, + updatedTransaction, + mergeTransactionID, + isFromReviewDuplicates, + shouldShowAnimatedBackground, + + // Derived booleans + isDistanceRequest, + isManualDistanceRequest, + isGPSDistanceRequest, + isOdometerDistanceRequest, + isMapDistanceRequest, + isPerDiemRequest, + isFromCardImport, + isFromMergeTransaction, + isTransactionScanning, + isCustomUnitOutOfPolicy, + hasRequiredCompanyCardViolation, + shouldShowPaid, + isInWideRHP, + isSmallScreenWidth, + shouldHideEmptyDescription, + shouldShowCategoryAnalyzing, + + // Editing permissions + canEdit, + canEditAmount, + canEditMerchant, + canEditDate, + canEditDistance, + canEditDistanceRate, + canEditTaxFields, + canEditReport, + canEditReimbursable, + + // Display values + amountTitle, + amountDescription, + actualTransactionDate, + transactionDate, + dateDescription, + updatedMerchantTitle, + merchantTitle, + distanceToDisplay, + distanceToDisplayDescription, + distanceToDisplayHintText, + rateToDisplay, + decodedCategoryName, + taxRateValue, + taxAmountTitle, + taxAmountDescription, + taxRatesDescription, + reportNameToDisplay, + reportCopyValue, + tripRoomName, + tripRoomReportID, + transactionVendorName, + vendorFieldLabel, + cardProgramName, + cardCopyValue, + getAttendeesTitle, + formattedPerAttendeeAmount, + actualAttendees, + + // Visibility flags + shouldShowCategory, + shouldShowTag, + shouldShowBillable, + shouldShowReimbursable, + shouldShowTax, + shouldShowTaxDisabledAlert, + shouldShowAttendees, + shouldShowVendor, + shouldShowReport, + shouldShowTripRoomLink, + shouldShowViewTripDetails, + shouldShowCard, + shouldShowGoogleMerchantSearchLink, + shouldShowTagDisabledAlert, + originalMerchantForGoogleSearch, + + // Violation / error helpers + getViolationsForField, + companyCardPageURL, + connectionLink, + isMarkAsCash, + + // Copy values + amountCopyValue, + descriptionCopyValue, + merchantCopyValue, + dateCopyValue, + categoryCopyValue, + distanceCopyValue, + distanceRateCopyValue, + taxRateCopyValue, + taxAmountCopyValue, + attendeesCopyValue, + + // Callbacks / actions + saveBillable, + saveReimbursable, + showTagDisabledAlert, + getErrorForField, + getPendingFieldAction, + onAmountPress, + onDescriptionPress, + onMerchantPress, + onDatePress, + onCategoryPress, + onDistancePress, + onDistanceRatePress, + onTaxRatePress, + onTaxAmountPress, + onReportPress, + onTripRoomPress, + onViewTripDetailsPress, + onAttendeesPress, + onVendorPress, + + // Tag rendering data + hasDependentTags, + policyTagLists, + transactionTag, + previousTagLength, + currentTagLength, + }; +} + +export default useMoneyRequestViewData; +export type {MoneyRequestViewData}; From ab10be23ec160958d00b39bad3da8c85639f0559 Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:32:54 +0530 Subject: [PATCH 447/466] Update SearchQueryUtils.ts --- src/libs/SearchQueryUtils.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 322037940e7c..f10e1a36f94a 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -600,10 +600,9 @@ function getQueryHashes(query: SearchQueryJSON) { // Only filters that are part of a suggested search's base query should affect the // similarSearchHash (i.e. the tab identity). All other flat filters are user-applied // refinements within the same tab and must not change the tab identity or the - // backend searchKey derived from it. FEED is intentionally excluded because it is - // a user-applied refinement in reconciliation even though it is part of the - // UNAPPROVED_CARD base query — UNAPPROVED_CARD is still uniquely identified by its - // groupBy and status. + // backend searchKey derived from it. Some base-query filters (e.g. FEED, POSTED) + // are intentionally excluded because their tabs are still uniquely identified by + // other base-query filters (groupBy + status). const similarSearchIdentityFilters = new Set([ CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE, CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN, From 93f38933f530d81284f31e0960f1810cd9287eae Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 27 Aug 2026 13:11:20 +0000 Subject: [PATCH 448/466] Update version to 9.4.62-2 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index daa19e7588b8..fca415246f2a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046201 - versionName "9.4.62-1" + versionCode 1009046202 + versionName "9.4.62-2" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index b4823630715e..0362425dfe68 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.62.1 + 9.4.62.2 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 7533e7914478..06f220dd08c1 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.62 CFBundleVersion - 9.4.62.1 + 9.4.62.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index ac3e6d82262c..667e9fa201c4 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.62 CFBundleVersion - 9.4.62.1 + 9.4.62.2 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 412065ca3901..96e9d11e1d12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.62-1", + "version": "9.4.62-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.62-1", + "version": "9.4.62-2", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 611ae426c563..9ef886468cef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.62-1", + "version": "9.4.62-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 0b926687d52a221a5f7fb342ebd2facad6f8e798 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 27 Aug 2026 13:11:20 +0000 Subject: [PATCH 449/466] Update Mobile-Expensify submodule version to 9.4.62-2 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index fc7a42bec8fa..8f8b9b5cc4dd 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit fc7a42bec8fac5a4243551d52f8f37f7621fefba +Subproject commit 8f8b9b5cc4dd5d87ed6e5e8ace365432a3b4a702 From c6eedd7b95526a6bdec80040cd1b06f5b1cb3ab0 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 27 Aug 2026 15:38:05 +0200 Subject: [PATCH 450/466] Fix after merge --- src/pages/home/ForYouSection/ConciergePromptBox.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index e789dafbd9aa..6eadb249838a 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -102,6 +102,8 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo setLastSyncedDraft(draft); setValue(draft ?? ''); setSelection({start: draft?.length ?? 0, end: draft?.length ?? 0}); + debouncedCommentMaxLengthValidation(draft ?? ''); + debouncedCommentMaxLengthValidation.flush(); } const [isFocused, setIsFocused] = useState(false); @@ -130,6 +132,14 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo saveConciergePromptDraft(null); }; + // The native input stays expanded when the value is emptied programmatically, the same way it does after sending. + useEffect(() => { + if (draft) { + return; + } + scheduleOnUI(forceClearInput, animatedRef); + }, [draft, animatedRef]); + const sendAttachment = (attachments: FileObject | FileObject[]) => { interceptAnonymousUser(() => { askConciergeWithAttachment(attachments, value); @@ -285,7 +295,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo onChangeText={(text) => { setValue(text); debouncedCommentMaxLengthValidation(text); - debouncedSaveDraft(nextValue); + debouncedSaveDraft(text); }} selection={selection} onSelectionChange={(event) => setSelection(event.nativeEvent.selection)} From 35efce9ee0b8a01cfaa397b03eef552101757072 Mon Sep 17 00:00:00 2001 From: mhawryluk Date: Thu, 27 Aug 2026 16:09:49 +0200 Subject: [PATCH 451/466] Remove the additional forceClearInput effect --- src/pages/home/ForYouSection/ConciergePromptBox.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 6eadb249838a..9ba2b9648dcb 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -132,14 +132,6 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible}: ConciergePromptBo saveConciergePromptDraft(null); }; - // The native input stays expanded when the value is emptied programmatically, the same way it does after sending. - useEffect(() => { - if (draft) { - return; - } - scheduleOnUI(forceClearInput, animatedRef); - }, [draft, animatedRef]); - const sendAttachment = (attachments: FileObject | FileObject[]) => { interceptAnonymousUser(() => { askConciergeWithAttachment(attachments, value); From 677df03ce62c111d473f2a2162d98f9ba86a96c6 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:41:47 +0530 Subject: [PATCH 452/466] Revert "Derive similarSearchHash from suggested searches in SearchUpdateTest" This reverts commit f7fe2c8bdea469a631a4033292cfa148e5f32a56. --- tests/actions/IOU/SearchUpdateTest.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/actions/IOU/SearchUpdateTest.ts b/tests/actions/IOU/SearchUpdateTest.ts index e82ad293e423..16915c2d8957 100644 --- a/tests/actions/IOU/SearchUpdateTest.ts +++ b/tests/actions/IOU/SearchUpdateTest.ts @@ -10,7 +10,6 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import {buildCannedSearchQuery} from '@src/libs/SearchQueryUtils'; -import {getSuggestedSearches} from '@src/libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report} from '@src/types/onyx'; @@ -111,11 +110,6 @@ jest.mock('@libs/PolicyUtils', () => ({ const RORY_EMAIL = 'rory@expensifail.com'; const RORY_ACCOUNT_ID = 3; -const suggestedSearches = getSuggestedSearches(RORY_ACCOUNT_ID); -const submitSimilarSearchHash = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.SUBMIT].similarSearchHash; -const approveSimilarSearchHash = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.APPROVE].similarSearchHash; -const unapprovedCashRuntimeSimilarSearchHash = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH].similarSearchHash; - OnyxUpdateManager(); describe('actions/IOU', () => { beforeAll(() => { @@ -189,7 +183,7 @@ describe('actions/IOU', () => { ], hash: 939629734, recentSearchHash: 1023339253, - similarSearchHash: submitSimilarSearchHash, + similarSearchHash: 1855682507, }); const iouReport: Report = { ...createRandomReport(2, undefined), @@ -251,7 +245,7 @@ describe('actions/IOU', () => { hash: 1685631874, inputQuery: 'sortBy:date sortOrder:desc type:expense-report action:approve to:20671314', recentSearchHash: 244251677, - similarSearchHash: approveSimilarSearchHash, + similarSearchHash: 1539858783, }); const iouReport: Report = { ...createRandomReport(2, undefined), @@ -319,7 +313,7 @@ describe('actions/IOU', () => { hash: 1967417738, inputQuery: 'sortBy:date sortOrder:desc type:expense groupBy:from status:drafts,outstanding reimbursable:yes', recentSearchHash: 1043581824, - similarSearchHash: unapprovedCashRuntimeSimilarSearchHash, + similarSearchHash: 1832274510, }); const iouReport: Report = { From b86864d4fd3fdb1cca4fac537d91ca16467240de Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:42:02 +0530 Subject: [PATCH 453/466] Switch to narrow blocklist for similarSearchHash ignored filters --- src/libs/SearchQueryUtils.ts | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index f10e1a36f94a..1fb7a3714c07 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -597,22 +597,14 @@ function getQueryHashes(query: SearchQueryJSON) { filterSet.add(exactMatchIdentity); } - // Only filters that are part of a suggested search's base query should affect the - // similarSearchHash (i.e. the tab identity). All other flat filters are user-applied - // refinements within the same tab and must not change the tab identity or the - // backend searchKey derived from it. Some base-query filters (e.g. FEED, POSTED) - // are intentionally excluded because their tabs are still uniquely identified by - // other base-query filters (groupBy + status). - const similarSearchIdentityFilters = new Set([ - CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_TYPE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWN, - CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, - CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, - CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER, - CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER, - CONST.SEARCH.SYNTAX_FILTER_KEYS.REIMBURSABLE, - CONST.SEARCH.SYNTAX_FILTER_KEYS.BILLABLE, + // Certain filters shouldn't affect whether two searchers are similar or not, since they dont + // actually filter out results + const similarSearchIgnoredFilters = new Set([ + CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY, + CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, + CONST.SEARCH.SYNTAX_FILTER_KEYS.BANK_ACCOUNT, + CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_STATUS, + CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID, ]); // Certain filters' values are significant in deciding which search we are on, so we want to include @@ -634,7 +626,7 @@ function getQueryHashes(query: SearchQueryJSON) { continue; } - if (similarSearchIdentityFilters.has(filterKey)) { + if (!similarSearchIgnoredFilters.has(filterKey)) { filterSet.add(filterKey); } From 930e8c1c804fb59849f6ee2fa5039bd4f7df55dc Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 27 Aug 2026 14:40:45 +0000 Subject: [PATCH 454/466] Update version to 9.4.62-3 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index fca415246f2a..694cff1b3b31 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046202 - versionName "9.4.62-2" + versionCode 1009046203 + versionName "9.4.62-3" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 0362425dfe68..fccc1f7f2c89 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.62.2 + 9.4.62.3 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 06f220dd08c1..34a2016f5592 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.62 CFBundleVersion - 9.4.62.2 + 9.4.62.3 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 667e9fa201c4..94a91731205b 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.62 CFBundleVersion - 9.4.62.2 + 9.4.62.3 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 96e9d11e1d12..bd2b22c852bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.62-2", + "version": "9.4.62-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.62-2", + "version": "9.4.62-3", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 9ef886468cef..dd8bd5513d46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.62-2", + "version": "9.4.62-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 6cf4938c7c9ff63952c38c4c041bc1d8219ce136 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 27 Aug 2026 14:40:45 +0000 Subject: [PATCH 455/466] Update Mobile-Expensify submodule version to 9.4.62-3 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 8f8b9b5cc4dd..fa97ddaed4f2 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 8f8b9b5cc4dd5d87ed6e5e8ace365432a3b4a702 +Subproject commit fa97ddaed4f2e5b8d8a915c84453ef667960a48a From bac6c8af8d5a3e3521f5a880c3edd42584b990af Mon Sep 17 00:00:00 2001 From: Jack Senyitko Date: Thu, 27 Aug 2026 11:02:11 -0400 Subject: [PATCH 456/466] Revert "Merge pull request #98611 from thelullabyy/fix-03/90366" This reverts commit a29eb30142d79be6251894dbfd8d4443ecee1f31, reversing changes made to e49e54701dfb16095635da25e692ddd5bdb94e15. --- src/ONYXKEYS.ts | 5 - src/components/MoneyReportHeader.tsx | 74 +-- .../MoneyReportHeaderMoreContent.tsx | 70 +-- src/components/MoneyRequestHeader.tsx | 22 +- .../MoneyRequestReportNavigation.tsx | 14 +- .../MoneyRequestReportTransactionList.tsx | 32 +- ...neyRequestReportTransactionsNavigation.tsx | 381 ++++++--------- src/components/PopoverMenu/index.tsx | 32 +- .../ListItem/TransactionGroupListExpanded.tsx | 4 +- src/components/Search/index.tsx | 77 ++- ...eMoneyReportHeaderMoreContentVisibility.ts | 46 -- src/hooks/useNavigateToTransactionThread.ts | 15 +- src/languages/de.ts | 1 - src/languages/el.ts | 1 - src/languages/en.ts | 2 - src/languages/es.ts | 1 - src/languages/fr.ts | 1 - src/languages/it.ts | 1 - src/languages/ja.ts | 1 - src/languages/nl.ts | 1 - src/languages/pl.ts | 1 - src/languages/pt-BR.ts | 1 - src/languages/zh-hans.ts | 1 - src/libs/ExportOnyxState/common.ts | 1 - src/libs/Navigation/types.ts | 1 - .../actions/TransactionThreadNavigation.ts | 53 +- src/pages/home/RecentlyAddedSection/index.tsx | 2 +- src/pages/inbox/ReportNavigateAwayHandler.tsx | 12 +- src/styles/utils/sizing.ts | 4 - .../MoneyReportHeaderActionsPlacementTest.tsx | 315 ------------ .../MoneyReportHeaderMoreContentTest.tsx | 120 +++++ ...yReportHeaderMoreContentVisibilityTest.tsx | 120 ----- ...questReportTransactionsNavigation.test.tsx | 458 ------------------ .../useNavigateToTransactionThread.test.ts | 110 ----- .../TransactionThreadNavigationTest.ts | 135 ------ 35 files changed, 374 insertions(+), 1741 deletions(-) delete mode 100644 src/hooks/useMoneyReportHeaderMoreContentVisibility.ts delete mode 100644 tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx create mode 100644 tests/ui/components/MoneyReportHeaderMoreContentTest.tsx delete mode 100644 tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx delete mode 100644 tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx delete mode 100644 tests/unit/hooks/useNavigateToTransactionThread.test.ts delete mode 100644 tests/unit/libs/actions/TransactionThreadNavigationTest.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 4d8bdbdbeb51..63413dfe3931 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -830,10 +830,6 @@ const ONYXKEYS = { /** List of transaction IDs used when navigating to prev/next transaction when viewing it in RHP */ TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS: 'transactionThreadNavigationTransactionIDs', - /** Hash of the search snapshot that holds the transactions referenced by TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS. - * Used to fall back to snapshot data when the live transaction collection hasn't loaded those transactions yet (e.g. opening an expense from the Spend page as an approver). */ - TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH: 'transactionThreadNavigationSnapshotHash', - /** Optional map of transactionID -> sibling descriptor for prev/next navigation in snapshot-backed flows (e.g. Home "Recently added"), where siblings may be absent from the main Onyx collections. When set, navigation resolves (and lazily creates) each sibling's thread on demand from its descriptor. */ TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS: 'transactionThreadNavigationThreadReportIDs', @@ -1839,7 +1835,6 @@ type OnyxValuesMapping = { [ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY]: OnyxTypes.LastSearchParams; [ONYXKEYS.NVP_LAST_ANDROID_LOGIN]: string; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS]: string[]; - [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH]: number; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS]: Record; [ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES]: OnyxTypes.ExportTemplate[]; [ONYXKEYS.ONBOARDING_USER_REPORTED_INTEGRATION]: OnboardingAccounting; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 205ca942b54d..92f168746b29 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1,6 +1,5 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; -import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; @@ -10,21 +9,16 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; -import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect} from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import HeaderLoadingBar from './HeaderLoadingBar'; @@ -33,8 +27,6 @@ import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import {ExportDownloadStatusProvider} from './MoneyReportHeaderActions/ExportDownloadStatusProvider'; import MoneyReportHeaderModals from './MoneyReportHeaderModals'; import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent'; -import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; -import MoneyRequestReportTransactionsNavigation from './MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; import {PaymentAnimationsProvider} from './PaymentAnimationsContext'; import {useSearchSelectionActions} from './Search/SearchContext'; @@ -87,38 +79,15 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const transactions = Object.values(reportTransactions); - const [activeTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); - - const singleTransactionID = transactions.length === 1 ? transactions.at(0)?.transactionID : undefined; - - const threadParentReportActionID = moneyRequestReport?.parentReportActionID; - const threadTransactionIDSelector = useCallback( - (parentReportActions: OnyxEntry) => { - const parentReportAction = threadParentReportActionID ? parentReportActions?.[threadParentReportActionID] : undefined; - return isMoneyRequestAction(parentReportAction) ? getOriginalMessage(parentReportAction)?.IOUTransactionID : undefined; - }, - [threadParentReportActionID], - ); - const [threadTransactionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(moneyRequestReport?.parentReportID)}`, {selector: threadTransactionIDSelector}); - - const anchorTransactionIDFromRoute = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT ? route.params.anchorTransactionID : undefined; - const multiTxAnchorTransactionID = anchorTransactionIDFromRoute && activeTransactionIDs?.includes(anchorTransactionIDFromRoute) ? anchorTransactionIDFromRoute : undefined; - const carouselAnchorTransactionID = singleTransactionID ?? threadTransactionID ?? multiTxAnchorTransactionID; - const shouldShowTransactionNavigation = !!carouselAnchorTransactionID && !!activeTransactionIDs?.includes(carouselAnchorTransactionID); - const styles = useThemeStyles(); const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); const shouldShowHeaderButtonsInHeaderRow = isInLandscapeMode || !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; - const isReportInRHP = route.name !== SCREENS.REPORT; + const shouldDisplaySearchRouter = !isReportInRHP || isSmallScreenWidth; const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; - const {statusBarType, shouldShowNextStep, hasStatusOrNextStep} = useMoneyReportHeaderMoreContentVisibility(reportIDProp); - const shouldRenderActionsInHeaderRow = shouldShowHeaderButtonsInHeaderRow && !hasStatusOrNextStep; - const shouldDisplaySearchRouter = !isReportInRHP || (isSmallScreenWidth && !isReportInSearch); - const backTo = (route.params as {backTo?: Route} | undefined)?.backTo; const primaryAction = useReportPrimaryAction(reportIDProp); @@ -166,28 +135,14 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt shouldEnableDetailPageNavigation openParentReportInCurrentTab > - - {shouldRenderActionsInHeaderRow && ( - - )} - {isReportInSearch && - (shouldShowTransactionNavigation && carouselAnchorTransactionID ? ( - - ) : ( - - ))} - + {shouldShowHeaderButtonsInHeaderRow && ( + + )} {!shouldShowHeaderButtonsInHeaderRow && ( )} - + ); diff --git a/src/components/MoneyReportHeaderMoreContent.tsx b/src/components/MoneyReportHeaderMoreContent.tsx index 168961dc0903..b8a5494fa99e 100644 --- a/src/components/MoneyReportHeaderMoreContent.tsx +++ b/src/components/MoneyReportHeaderMoreContent.tsx @@ -1,12 +1,17 @@ +import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; +import {isGroupPolicy} from '@libs/PolicyUtils'; +import {isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -17,37 +22,20 @@ import {useRoute} from '@react-navigation/native'; import React from 'react'; import {View} from 'react-native'; -import type {MoneyReportHeaderActionsProps} from './MoneyReportHeaderActions/types'; - -import MoneyReportHeaderActions from './MoneyReportHeaderActions'; import MoneyReportHeaderNextStep from './MoneyReportHeaderNextStep'; import MoneyReportHeaderStatusBarSection from './MoneyReportHeaderStatusBarSection'; import {useMoneyReportTransactionThread} from './MoneyReportTransactionThreadContext'; +import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; type MoneyReportHeaderMoreContentProps = { reportID: string | undefined; - - /** The report's primary action, forwarded to the actions row */ - primaryAction: MoneyReportHeaderActionsProps['primaryAction']; - - /** Route to navigate back to */ - backTo: Route | undefined; - - /** Which status bar to render, resolved by the header via useMoneyReportHeaderMoreContentVisibility */ - statusBarType: ValueOf | undefined; - - /** Whether the next step bar should be rendered, resolved alongside `statusBarType` */ - shouldShowNextStep: boolean; - - /** Whether the report actions belong at the end of this row. The header renders them itself when this row is empty. */ - shouldRenderActionsInRow: boolean; }; /** - * Cheap visibility gate — decides whether the more-content section should render at all, - * avoiding expensive hooks in the body when nothing is shown. + * Cheap visibility gate — fetches minimal data to decide whether the more-content section + * should render at all, avoiding expensive hooks in the body when nothing is shown. */ -function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, statusBarType, shouldShowNextStep, shouldRenderActionsInRow}: MoneyReportHeaderMoreContentProps) { +function MoneyReportHeaderMoreContent({reportID}: MoneyReportHeaderMoreContentProps) { const route = useRoute< | PlatformStackRouteProp | PlatformStackRouteProp @@ -57,9 +45,13 @@ function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, statusBa const isReportInSearch = route.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || route.name === SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT; const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const {shouldShowStatusBar, statusBarType} = useMoneyReportHeaderStatusBar(reportID, moneyRequestReport?.chatReportID); + + const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); + const shouldShowNextStep = isGroupPolicy(policy) && !isInvoiceReport && !shouldShowStatusBar; - const hasStatusOrNextStep = shouldShowNextStep || !!statusBarType; - const shouldShowMoreContent = hasStatusOrNextStep || shouldRenderActionsInRow; + const shouldShowMoreContent = shouldShowNextStep || !!statusBarType || isReportInSearch; if (!shouldShowMoreContent) { return null; @@ -71,9 +63,6 @@ function MoneyReportHeaderMoreContent({reportID, primaryAction, backTo, statusBa statusBarType={statusBarType} isReportInSearch={isReportInSearch} shouldShowNextStep={shouldShowNextStep} - primaryAction={primaryAction} - backTo={backTo} - shouldRenderActionsInRow={shouldRenderActionsInRow} /> ); } @@ -83,27 +72,20 @@ type MoneyReportHeaderMoreContentBodyProps = { statusBarType: ValueOf | undefined; isReportInSearch: boolean; shouldShowNextStep: boolean; - primaryAction: MoneyReportHeaderActionsProps['primaryAction']; - backTo: Route | undefined; - shouldRenderActionsInRow: boolean; }; -function MoneyReportHeaderMoreContentBody({ - moneyRequestReport, - statusBarType, - isReportInSearch, - shouldShowNextStep, - primaryAction, - backTo, - shouldRenderActionsInRow, -}: MoneyReportHeaderMoreContentBodyProps) { +function MoneyReportHeaderMoreContentBody({moneyRequestReport, statusBarType, isReportInSearch, shouldShowNextStep}: MoneyReportHeaderMoreContentBodyProps) { const styles = useThemeStyles(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); + const shouldDisplayNarrowVersion = shouldUseNarrowLayout || isMediumScreenWidth; + const {isWideRHPDisplayedOnWideLayout, isSuperWideRHPDisplayedOnWideLayout} = useResponsiveLayoutOnWideRHP(); + const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; const reportID = moneyRequestReport?.reportID; const {iouTransactionID} = useMoneyReportTransactionThread(); return ( - + {shouldShowNextStep && } - {shouldRenderActionsInRow && ( - )} diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index b0830aedddbe..915ae005b74c 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -103,7 +103,6 @@ function MoneyRequestHeader({reportID: reportIDProp, onBackButtonPress}: MoneyRe const shouldDisplayTransactionNavigation = !!(reportID && isReportInRHP); const shouldOpenParentReportInCurrentTab = !isSelfDM(parentReport); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine() && (wideRHPRouteKeys.length === 0 || isSmallScreenWidth); - const shouldDisplayNarrowVersion = shouldDisplayButtonsInSeparateLine; const getStatusIcon: (src: IconAsset) => ReactNode = (src) => ( - {!shouldDisplayButtonsInSeparateLine && !statusBarProps && ( + {!shouldDisplayButtonsInSeparateLine && ( )} @@ -204,19 +202,11 @@ function MoneyRequestHeader({reportID: reportIDProp, onBackButtonPress}: MoneyRe /> )} {!!statusBarProps && ( - - - - - {!shouldDisplayButtonsInSeparateLine && ( - - )} + + )} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index 4299d1b9890d..d3506a09dd0f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -3,7 +3,6 @@ import {useSearchResultsContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import useFilterPendingDeleteReports from '@hooks/useFilterPendingDeleteReports'; -import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchSections from '@hooks/useSearchSections'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -22,7 +21,6 @@ import type LastSearchParams from '@src/types/onyx/ReportNavigation'; import type {OnyxEntry} from 'react-native-onyx'; -import {useIsFocused} from '@react-navigation/native'; import React, {startTransition, useEffect, useState} from 'react'; import {View} from 'react-native'; @@ -115,8 +113,6 @@ function MoneyRequestReportNavigationStandalone({onReportsChange}: MoneyRequestR function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersion, contextReports}: MoneyRequestReportNavigationContentProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); - const isFocused = useIsFocused(); // Lightweight subscriptions only: the current search query and its loading flag. These never mount // the heavy useSearchSections subscription set, so the fast context path stays cheap. @@ -155,7 +151,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi const shouldDisplayNavigationArrows = effectiveAllReports.length > 1 && currentIndex !== -1 && !!lastSearchQuery?.queryJSON; useEffect(() => { - if (!isFocused || !lastSearchQuery?.queryJSON) { + if (!lastSearchQuery?.queryJSON) { return; } @@ -185,7 +181,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi ...lastSearchQuery, previousLengthOfResults: effectiveAllReports.length, }); - }, [isFocused, currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); + }, [currentIndex, allReportsCount, effectiveAllReports.length, lastSearchQuery?.queryJSON, lastSearchQuery]); const goToReportId = (reportId?: string) => { if (!reportId) { @@ -246,11 +242,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi {!shouldUseContextReports && } {shouldDisplayNavigationArrows && ( - {!shouldDisplayNarrowVersion && ( - - {translate('common.currentOfTotal', {current: currentIndex + 1, total: allReportsCount})} - - )} + {!shouldDisplayNarrowVersion && {`${currentIndex + 1} of ${allReportsCount}`}} group.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)).map((transaction) => transaction.transactionID)); }, [groupedTransactions, sortedTransactions, shouldGroupTransactions]); - // Order-sensitive proxy for visualOrderTransactionIDs used as the effect dependency below. It must stay - // order-sensitive: changing the report's sorting/grouping (without changing which transactions are present) - // reorders the list, and the carousel needs to be re-seeded so its counter and prev/next buttons match the - // new visual order. The active-list checks in the effect still prevent unrelated carousels from being overwritten. + // Primitive proxy for visualOrderTransactionIDs used as the effect dependency below. + // Other callers (e.g. TransactionDuplicateReview.onPreviewPressed) can write to the same + // Onyx key with a different ordering. Using the raw array reference would cause the effect + // to re-fire on every referential change and overwrite those IDs. The joined string ensures + // the effect only re-fires when the actual content changes. const visualOrderTransactionIDsKey = useMemo(() => visualOrderTransactionIDs.join(','), [visualOrderTransactionIDs]); - const [latestActiveTransactionIDs = getEmptyArray()] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); - useEffect(() => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); if (focusedRoute?.name !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { return; } - - const anchorTransactionID = (focusedRoute?.params as {anchorTransactionID?: string} | undefined)?.anchorTransactionID; - if (anchorTransactionID && latestActiveTransactionIDs.includes(anchorTransactionID)) { - return; - } // Don't take over a snapshot-backed carousel (identified by its sibling descriptors, e.g. the Home // "Recently added" flow) that belongs to the transaction thread sitting underneath this report. // Overwriting and then clearing it would drop that carousel when the user navigates back. Row presses @@ -612,23 +605,11 @@ function MoneyRequestReportTransactionList({ if (getActiveTransactionIDs().descriptors) { return; } - - // This report can't drive a carousel on its own: the carousel needs at least two transactions to page - // between. Writing a 0/1-entry list would clobber a broader carousel the user drilled in from (e.g. the - // Spend page's full transaction list) and would also make the header render the empty transaction - // carousel instead of the report-level prev/next buttons. - if (visualOrderTransactionIDs.length < 2) { - return; - } - - if (latestActiveTransactionIDs.length >= visualOrderTransactionIDs.length && visualOrderTransactionIDs.every((id) => latestActiveTransactionIDs.includes(id))) { - return; - } setActiveTransactionIDs(visualOrderTransactionIDs); return () => { clearActiveTransactionIDs(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is an order-sensitive proxy for the array, and we intentionally don't depend on latestActiveTransactionIDs to avoid re-firing when the carousel changes elsewhere + // eslint-disable-next-line react-hooks/exhaustive-deps -- visualOrderTransactionIDsKey is a primitive proxy for the array to avoid re-firing on referential-only changes }, [visualOrderTransactionIDsKey]); const groupSelectionState = useMemo(() => { @@ -686,7 +667,6 @@ function MoneyRequestReportTransactionList({ report, transaction: sortedTransactions.find((t) => t.transactionID === activeTransactionID), siblingTransactionIDs: visualOrderTransactionIDs, - shouldPreserveBroaderCarousel: true, }); }, [navigateToTransactionThread, reportActions, sortedTransactions, report, visualOrderTransactionIDs], diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx index 3afdfd942a9e..0d55e35a9250 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation.tsx @@ -1,116 +1,54 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PrevNextButtons from '@components/PrevNextButtons'; -import Text from '@components/Text'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; import {clearActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {isOneTransactionReport} from '@libs/ReportUtils'; import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; import Navigation from '@navigation/Navigation'; import navigationRef from '@navigation/navigationRef'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; -import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@src/selectors/Onboarding'; import type * as OnyxTypes from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import type {GestureResponderEvent} from 'react-native'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import {findFocusedRoute} from '@react-navigation/native'; import React, {startTransition, useCallback, useEffect, useMemo} from 'react'; -import {View} from 'react-native'; - -const CAROUSEL_PRESERVING_SCREENS = [ - SCREENS.RIGHT_MODAL.SEARCH_REPORT, - SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, - SCREENS.RIGHT_MODAL.EXPENSE_REPORT, - SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW, -] as const; type MoneyRequestReportRHPNavigationButtonsProps = { currentTransactionID: string; isFromReviewDuplicates?: boolean; - shouldDisplayNarrowVersion?: boolean; -}; - -type PrevNextParentReportActions = { - prevParentReportAction: OnyxTypes.ReportAction | undefined; - nextParentReportAction: OnyxTypes.ReportAction | undefined; }; -/** - * Only the prev/next parent actions are ever read, so resolve them while scanning instead of collecting every money - * request action on the parent reports (fast-equals compares the resulting collection on every Onyx update). - */ -const collectParentReportActions = ( - reportActions: OnyxEntry, - prevTransactionID: string | undefined, - nextTransactionID: string | undefined, - parentActions: PrevNextParentReportActions, -) => { - for (const action of Object.values(reportActions ?? {})) { - const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; - if (!transactionID) { - continue; - } - if (transactionID === prevTransactionID) { - // eslint-disable-next-line no-param-reassign -- intentionally mutates the shared accumulator so callers can resolve both actions in a single pass across multiple report-action sources - parentActions.prevParentReportAction = action; - } - if (transactionID === nextTransactionID) { - // eslint-disable-next-line no-param-reassign -- intentionally mutates the shared accumulator so callers can resolve both actions in a single pass across multiple report-action sources - parentActions.nextParentReportAction = action; - } - } -}; - -function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromReviewDuplicates, shouldDisplayNarrowVersion}: MoneyRequestReportRHPNavigationButtonsProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); +function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromReviewDuplicates}: MoneyRequestReportRHPNavigationButtonsProps) { const [transactionIDsList = getEmptyArray()] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); - // When the carousel is opened from a search (e.g. the Spend page), the sibling transactions may only exist - // in the search snapshot and not in the live collection yet. We keep the snapshot around to fall back to it - // so prev/next navigation resolves the correct report instead of breaking. - // `useOnyx`'s automatic snapshot redirection doesn't cover this: it only kicks in inside `SearchScopeProvider` - // (which wraps the search list, not the RHP this header renders in), it reads the *currently displayed* - // search hash rather than the one the carousel was seeded from, and it returns snapshot data *instead of* - // live data — whereas here live data has to win over the snapshot (see the merge below). - const [snapshotHash] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH); - const [snapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${snapshotHash}`); - // Snapshot-backed flows (e.g. Home "Recently added") seed a descriptor per sibling so the carousel can - // resolve (and lazily create) each sibling's thread on demand even when the sibling isn't in the live collection. const [siblingDescriptorsByTransactionID] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS); - const {markReportRHPWidth} = useWideRHPActions(); - // Values required to create a transaction thread on the fly when paging onto a multi-transaction - // (batched) parent report that has no existing thread yet (see onNext/onPrevious fallbacks). - const {accountID, email} = useCurrentUserPersonalDetails(); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const personalDetails = usePersonalDetails(); - const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); + const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const {markReportRHPWidth} = useWideRHPActions(); const {prevTransactionID, nextTransactionID} = useMemo(() => { if (!transactionIDsList || transactionIDsList.length < 2) { return {prevTransactionID: undefined, nextTransactionID: undefined}; } + const currentTransactionIndex = transactionIDsList.findIndex((id) => id === currentTransactionID); + const prevID = currentTransactionIndex > 0 ? transactionIDsList.at(currentTransactionIndex - 1) : undefined; const nextID = transactionIDsList.at(currentTransactionIndex + 1); @@ -118,15 +56,12 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR prevTransactionID: prevID, nextTransactionID: nextID, }; - }, [currentTransactionIndex, transactionIDsList]); + }, [currentTransactionID, transactionIDsList]); const prevNextTransactionsSelector = useCallback( (allTransactions: OnyxCollection) => - [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => { - const key = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const; - return allTransactions?.[key] ?? snapshot?.data?.[key]; - }), - [currentTransactionID, nextTransactionID, prevTransactionID, snapshot], + [currentTransactionID, prevTransactionID, nextTransactionID].map((transactionID) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]), + [currentTransactionID, nextTransactionID, prevTransactionID], ); const [[currentTransaction, prevTransaction, nextTransaction] = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { @@ -137,61 +72,42 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // a Map of every money request action on the three parent reports (fast-equals compares Maps in O(n^2)). const parentReportActionsSelector = useCallback( (allReportActions: OnyxCollection) => { - const parentActions: PrevNextParentReportActions = {prevParentReportAction: undefined, nextParentReportAction: undefined}; + let prevParentReportAction: OnyxTypes.ReportAction | undefined; + let nextParentReportAction: OnyxTypes.ReportAction | undefined; if (!prevTransactionID && !nextTransactionID) { - return parentActions; + return {prevParentReportAction, nextParentReportAction}; } const parentReportIDs = new Set([currentTransaction?.reportID, prevTransaction?.reportID, nextTransaction?.reportID]); for (const parentReportID of parentReportIDs) { - const key = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}` as const; - collectParentReportActions(allReportActions?.[key], prevTransactionID, nextTransactionID, parentActions); + for (const action of Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`] ?? {})) { + const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; + if (!transactionID) { + continue; + } + if (transactionID === prevTransactionID) { + prevParentReportAction = action; + } + if (transactionID === nextTransactionID) { + nextParentReportAction = action; + } + } } - return parentActions; + return {prevParentReportAction, nextParentReportAction}; }, [currentTransaction?.reportID, nextTransaction?.reportID, nextTransactionID, prevTransaction?.reportID, prevTransactionID], ); - const [reportedParentReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + const [parentReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { selector: parentReportActionsSelector, }); - // The live pass above can only look up `report_actions_{transaction.reportID}`, which never resolves an - // unreported (self-DM) sibling — its IOU action lives in the self-DM's report actions, not under reportID "0". - // Scanning the snapshot's report actions is how those siblings get a parent action at all. - const snapshotData = snapshot?.data; - const snapshotParentReportActions = useMemo(() => { - const parentActions: PrevNextParentReportActions = {prevParentReportAction: undefined, nextParentReportAction: undefined}; - if (snapshotData && (prevTransactionID ?? nextTransactionID)) { - for (const [key, reportActionsForReport] of Object.entries(snapshotData)) { - if (key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS)) { - collectParentReportActions(reportActionsForReport as OnyxTypes.ReportActions, prevTransactionID, nextTransactionID, parentActions); - } - } - } - return parentActions; - }, [nextTransactionID, prevTransactionID, snapshotData]); - - // Live report actions win over the snapshot: the snapshot only fills in siblings the live pass couldn't resolve - // (i.e. unreported ones). A search snapshot is a point-in-time copy, so for a reported transaction it can hold an - // older copy of the same IOU action — e.g. one still missing the childReportID of a thread that has since been - // created. Letting that stale copy win would make prev/next believe the sibling has no thread and create a - // duplicate one instead of navigating to the existing thread. - const prevParentReportAction = reportedParentReportActions?.prevParentReportAction ?? snapshotParentReportActions.prevParentReportAction; - const nextParentReportAction = reportedParentReportActions?.nextParentReportAction ?? snapshotParentReportActions.nextParentReportAction; - - const prevParentReportID = prevParentReportAction?.reportID ?? prevTransaction?.reportID; - const nextParentReportID = nextParentReportAction?.reportID ?? nextTransaction?.reportID; - - const [livePrevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); - const [liveNextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); - const [livePrevTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportID}`); - const [liveNextTransactionParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportID}`); - - // Fall back to the search snapshot for reports that aren't in the live collection yet. - const prevThreadReport = livePrevThreadReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`]; - const nextThreadReport = liveNextThreadReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`]; - const prevTransactionParentReport = livePrevTransactionParentReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportID}`]; - const nextTransactionParentReport = liveNextTransactionParentReport ?? snapshot?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportID}`]; + const prevParentReportAction = parentReportActions?.prevParentReportAction; + const nextParentReportAction = parentReportActions?.nextParentReportAction; + + const [prevParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevTransaction?.reportID}`); + const [nextParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextTransaction?.reportID}`); + const [prevThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${prevParentReportAction?.childReportID}`); + const [nextThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nextParentReportAction?.childReportID}`); /** * We clear the sibling transactionThreadIDs when unmounting this component @@ -200,7 +116,7 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR useEffect(() => { return () => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); - if (focusedRoute?.name && (CAROUSEL_PRESERVING_SCREENS as readonly string[]).includes(focusedRoute.name)) { + if (focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT || focusedRoute?.name === SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW) { return; } clearActiveTransactionIDs(); @@ -211,26 +127,15 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR return; } - const getBackTo = () => { + const onNext = (e: GestureResponderEvent | KeyboardEvent | undefined) => { + e?.preventDefault(); + let backTo = Navigation.getActiveRoute(); if (isFromReviewDuplicates) { const currentRoute = navigationRef.getCurrentRoute(); const params = currentRoute?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined; backTo = params?.backTo ?? backTo; } - return backTo; - }; - - const onNext = (e: GestureResponderEvent | KeyboardEvent | undefined) => { - e?.preventDefault(); - const backTo = getBackTo(); - - if (isOneTransactionReport(nextTransactionParentReport) && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { - const targetReportID = nextTransaction.reportID; - markReportRHPWidth(targetReportID, 'wide'); - requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}))); - return; - } // Snapshot-backed flows (e.g. Home "Recently added") seed a descriptor per sibling because the sibling // transactions may be absent from the main Onyx collections. Resolve the target sibling lazily here so @@ -238,132 +143,142 @@ function MoneyRequestReportTransactionsNavigation({currentTransactionID, isFromR // hydrate it on arrival. const nextDescriptor = nextTransactionID ? siblingDescriptorsByTransactionID?.[nextTransactionID] : undefined; if (nextDescriptor) { - const nextReportID = getReportIDToOpenForExpense(nextDescriptor, { - introSelected, - conciergeChat, - betas, - currentUserEmail: email, - currentUserAccountID: accountID, - personalDetails, + requestAnimationFrame(() => { + const nextReportID = getReportIDToOpenForExpense(nextDescriptor, { + introSelected, + conciergeChat, + betas, + currentUserEmail, + currentUserAccountID, + personalDetails, + }); + markReportRHPWidth(nextReportID, 'wide'); + requestAnimationFrame(() => + startTransition(() => + Navigation.setParams({ + reportID: nextReportID, + reportActionID: undefined, + backTo, + }), + ), + ); }); - markReportRHPWidth(nextReportID, 'wide'); - requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: nextReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}))); return; } const nextThreadReportID = nextParentReportAction?.childReportID; - const navigationParams = {reportID: nextThreadReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}; - - if (!nextThreadReportID && nextTransaction?.reportID && nextTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { - const optimisticThread = createTransactionThreadReport({ - introSelected, - conciergeChat, - currentUserLogin: email ?? '', - currentUserAccountID: accountID, - betas, - iouReport: nextTransactionParentReport, - iouReportAction: nextParentReportAction, - transaction: nextTransaction, - personalDetails, - isSelfTourViewed, - hasCompletedGuidedSetupFlow, - }); - const targetReportID = optimisticThread?.reportID ?? nextTransaction.reportID; - markReportRHPWidth(targetReportID, 'wide'); - requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: nextTransactionID, backTo}))); - return; - } - - if (nextThreadReportID) { - markReportRHPWidth(nextThreadReportID, 'wide'); - } + const navigationParams = { + reportID: nextThreadReportID, + reportActionID: undefined, + backTo, + }; - if (!nextThreadReport && nextThreadReportID) { - setOptimisticTransactionThread(nextThreadReportID, nextTransactionParentReport?.reportID, nextParentReportAction?.reportActionID, nextTransactionParentReport?.policyID); - } - // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread before navigating - requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); + requestAnimationFrame(() => { + if (nextThreadReportID) { + markReportRHPWidth(nextThreadReportID, 'wide'); + } + // We know that the next thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. + if (!nextThreadReport && nextThreadReportID) { + setOptimisticTransactionThread(nextThreadReportID, nextParentReport?.reportID, nextParentReportAction?.reportActionID, nextParentReport?.policyID); + } + // The transaction thread doesn't exist yet, so we should create it + if (!nextThreadReportID) { + const transactionThreadReport = createTransactionThreadReport({ + introSelected, + conciergeChat, + currentUserLogin: currentUserEmail ?? '', + currentUserAccountID, + betas, + iouReport: nextParentReport, + iouReportAction: nextParentReportAction, + transaction: nextTransaction, + personalDetails, + }); + navigationParams.reportID = transactionThreadReport?.reportID; + } + // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread or createTransactionThreadReport before navigating + requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); + }); }; const onPrevious = (e: GestureResponderEvent | KeyboardEvent | undefined) => { e?.preventDefault(); - const backTo = getBackTo(); - // See onNext for the rationale behind the one-transaction-parent branch (and the unreported skip). - if (isOneTransactionReport(prevTransactionParentReport) && prevTransaction?.reportID && prevTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { - const targetReportID = prevTransaction.reportID; - markReportRHPWidth(targetReportID, 'wide'); - requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}))); - return; + let backTo = Navigation.getActiveRoute(); + if (isFromReviewDuplicates) { + const currentRoute = navigationRef.getCurrentRoute(); + const params = currentRoute?.params as RightModalNavigatorParamList[typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT] | undefined; + backTo = params?.backTo ?? backTo; } // See onNext: resolve the target sibling lazily from its descriptor when present. const prevDescriptor = prevTransactionID ? siblingDescriptorsByTransactionID?.[prevTransactionID] : undefined; if (prevDescriptor) { - const prevReportID = getReportIDToOpenForExpense(prevDescriptor, { - introSelected, - conciergeChat, - betas, - currentUserEmail: email, - currentUserAccountID: accountID, - personalDetails, + requestAnimationFrame(() => { + const prevReportID = getReportIDToOpenForExpense(prevDescriptor, { + introSelected, + conciergeChat, + betas, + currentUserEmail, + currentUserAccountID, + personalDetails, + }); + markReportRHPWidth(prevReportID, 'wide'); + requestAnimationFrame(() => + startTransition(() => + Navigation.setParams({ + reportID: prevReportID, + reportActionID: undefined, + backTo, + }), + ), + ); }); - markReportRHPWidth(prevReportID, 'wide'); - requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: prevReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}))); return; } const prevThreadReportID = prevParentReportAction?.childReportID; - const navigationParams = {reportID: prevThreadReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}; - - // See onNext for the rationale: the parent here is a MULTI-transaction (batched) report, so create the - // transaction thread to land on a single-expense view instead of navigating to the whole parent report. - if (!prevThreadReportID && prevTransaction?.reportID && prevTransaction.reportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { - const optimisticThread = createTransactionThreadReport({ - introSelected, - conciergeChat, - currentUserLogin: email ?? '', - currentUserAccountID: accountID, - betas, - iouReport: prevTransactionParentReport, - iouReportAction: prevParentReportAction, - transaction: prevTransaction, - isSelfTourViewed, - hasCompletedGuidedSetupFlow, - personalDetails, - }); - const targetReportID = optimisticThread?.reportID ?? prevTransaction.reportID; - markReportRHPWidth(targetReportID, 'wide'); - requestAnimationFrame(() => startTransition(() => Navigation.setParams({reportID: targetReportID, reportActionID: undefined, anchorTransactionID: prevTransactionID, backTo}))); - return; - } + const navigationParams = { + reportID: prevThreadReportID, + reportActionID: undefined, + backTo, + }; - if (prevThreadReportID) { - markReportRHPWidth(prevThreadReportID, 'wide'); - } - // See onNext for the rationale: use prevTransactionParentReport (the PREV transaction's own parent) - // instead of parentReport (the CURRENT transaction's parent) so the optimistic linkage matches the server. - if (!prevThreadReport && prevThreadReportID) { - setOptimisticTransactionThread(prevThreadReportID, prevTransactionParentReport?.reportID, prevParentReportAction?.reportActionID, prevTransactionParentReport?.policyID); - } - // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread before navigating - requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); + requestAnimationFrame(() => { + if (prevThreadReportID) { + markReportRHPWidth(prevThreadReportID, 'wide'); + } + // We know that the previous thread report exists, it just wasn't fetched to Onyx yet, so we set it optimistically. + if (!prevThreadReport && prevThreadReportID) { + setOptimisticTransactionThread(prevThreadReportID, prevParentReport?.reportID, prevParentReportAction?.reportActionID, prevParentReport?.policyID); + } + // The transaction thread doesn't exist yet, so we should create it + if (!prevThreadReportID) { + const transactionThreadReport = createTransactionThreadReport({ + introSelected, + conciergeChat, + currentUserLogin: currentUserEmail ?? '', + currentUserAccountID, + betas, + iouReport: prevParentReport, + iouReportAction: prevParentReportAction, + transaction: prevTransaction, + personalDetails, + }); + navigationParams.reportID = transactionThreadReport?.reportID; + } + // Wait for the next frame to ensure Onyx has processed the optimistic data updates from setOptimisticTransactionThread or createTransactionThreadReport before navigating + requestAnimationFrame(() => startTransition(() => Navigation.setParams(navigationParams))); + }); }; return ( - - {!shouldDisplayNarrowVersion && currentTransactionIndex !== -1 && ( - - {translate('common.currentOfTotal', {current: currentTransactionIndex + 1, total: transactionIDsList.length})} - - )} - - + ); } diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index 1408ffd5457e..8e2a8336a339 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -247,22 +247,6 @@ function getSelectedItemIndex(menuItems: PopoverMenuItem[]) { return menuItems.findIndex((option) => option.isSelected); } -/** - * How much room a scrollable popover actually has, given where it is anchored. Bounding the popover by the - * full window height instead lets it overflow the screen edge when the anchor sits away from the top, which is - * what happens to the header's "More" menu once the header buttons move down into the status/next-step row. - */ -function getAvailableHeightForAnchor(anchorVertical: number, verticalAlignment: AnchorAlignment['vertical'], windowHeight: number): number { - if (verticalAlignment === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP) { - return windowHeight - anchorVertical; - } - if (verticalAlignment === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM) { - return anchorVertical; - } - // CENTER alignment grows in both directions from the anchor, so the closer window edge bounds it. - return Math.min(anchorVertical, windowHeight - anchorVertical) * 2; -} - /** * Return a stable string key for a menu item. * Prefers explicit `key` property on the item. If missing, falls back to `text`. @@ -673,23 +657,11 @@ function BasePopoverMenu({ const stylesArray: ViewStyle[] = [StyleSheet.flatten(styles.createMenuContainer), {width: variables.compactPopoverMenuWidth}, styles.pv2]; if (shouldUseScrollView && shouldEnableMaxHeight && !isInLandscapeMode) { - const availableHeight = getAvailableHeightForAnchor(anchorPosition.vertical, anchorAlignment.vertical, windowHeight) - variables.compactPopoverMenuVerticalMargin; - const minHeight = Math.min(CONST.POPOVER_MENU_MAX_HEIGHT, windowHeight - variables.compactPopoverMenuVerticalMargin); - stylesArray.push({maxHeight: Math.max(availableHeight, minHeight)}); + stylesArray.push({maxHeight: Math.max(windowHeight - variables.compactPopoverMenuVerticalMargin, CONST.POPOVER_MENU_MAX_HEIGHT)}); } return stylesArray; - }, [ - isSmallScreenWidth, - shouldEnableMaxHeight, - styles.createMenuContainer, - styles.pv2, - shouldUseScrollView, - windowHeight, - isInLandscapeMode, - anchorPosition.vertical, - anchorAlignment.vertical, - ]); + }, [isSmallScreenWidth, shouldEnableMaxHeight, styles.createMenuContainer, styles.pv2, shouldUseScrollView, windowHeight, isInLandscapeMode]); const {paddingTop, paddingBottom, paddingVertical, ...restScrollContainerStyle} = (StyleSheet.flatten([isSmallScreenWidth ? styles.pv4 : styles.pv2, scrollContainerStyle]) as ViewStyle) ?? {}; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 122d83dedeee..b68c6466e5e3 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -260,12 +260,12 @@ function TransactionGroupListExpandedImpl({ // When opening the transaction thread in RHP we need to find every other ID for the rest of transactions // to display prev/next arrows in RHP for navigation if (isModifiedMousePress(event)) { - setActiveTransactionIDs(siblingTransactionIDs, transactionsQueryJSON?.hash); + setActiveTransactionIDs(siblingTransactionIDs); navigateToTransactionThread(); return; } - setActiveTransactionIDs(siblingTransactionIDs, transactionsQueryJSON?.hash).then(navigateToTransactionThread); + setActiveTransactionIDs(siblingTransactionIDs).then(navigateToTransactionThread); }; const onShowMoreButtonPress = () => { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 39aaa9d22f86..13e67b10632b 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -25,7 +25,6 @@ import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {saveLastSearchParams} from '@libs/actions/ReportNavigation'; import type {TransactionPreviewData} from '@libs/actions/Search'; import {setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; -import {clearActiveTransactionIDs, setActiveTransactionIDs, shouldWriteActiveTransactionIDsForSearch} from '@libs/actions/TransactionThreadNavigation'; import {flushDeferredWrite, hasDeferredWrite} from '@libs/deferredLayoutWrite'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; @@ -63,7 +62,7 @@ import { getNavigateToReportsSpans, } from '@libs/telemetry/navigateToReportsSpans'; import {cancelSubmitFollowUpActionSpan, getPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; -import {isDeletedTransaction, isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; +import {isTransactionPendingDelete, shouldShowAttendees} from '@libs/TransactionUtils'; import Navigation, {navigationRef} from '@navigation/Navigation'; import type {SearchFullscreenNavigatorParamList} from '@navigation/types'; @@ -385,7 +384,14 @@ function Search({ return; } - Log.info('[Search] Showing skeleton', false, {isOffline, isDataLoaded, isCardFeedsLoading, isSearchLoading: !!searchResults?.search?.isLoading, hasErrors, shouldUseLiveData}); + Log.info('[Search] Showing skeleton', false, { + isOffline, + isDataLoaded, + isCardFeedsLoading, + isSearchLoading: !!searchResults?.search?.isLoading, + hasErrors, + shouldUseLiveData, + }); }, [hasErrors, isCardFeedsLoading, isDataLoaded, isOffline, searchResults?.search?.isLoading, shouldShowLoadingState, shouldUseLiveData]); useEffect(() => { @@ -560,16 +566,6 @@ function Search({ }, 0); }, [areItemsGrouped, filteredData]); - const carouselSiblingTransactionIDs = useMemo( - () => - (filteredData as SearchListItem[]) - .filter( - (t): t is TransactionListItemType => !!t && isTransactionListItemType(t) && t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isDeletedTransaction(t), - ) - .map((t) => t.transactionID), - [filteredData], - ); - const onSelectRow = useCallback( (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { @@ -578,17 +574,6 @@ function Search({ const isTransactionItem = isTransactionListItemType(item); const backTo = Navigation.getActiveRoute(); - - // When opening an expense from the Spend page (flat transaction list), populate the carousel - // with all sibling transactions so prev/next navigation works in the RHP transaction view. - if (isTransactionItem) { - if (carouselSiblingTransactionIDs.length > 1) { - setActiveTransactionIDs(carouselSiblingTransactionIDs, hash); - } else { - clearActiveTransactionIDs(); - } - } - // If we're trying to open a transaction without a transaction thread, let's create the thread and navigate the user if (isTransactionItem && !item?.reportAction?.childReportID) { // If the report is unreported (self DM), we want to open the track expense thread instead of a report with an ID of 0 @@ -695,7 +680,10 @@ function Search({ allowPostSearchRecount: true, }); - const route = ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo}); + const route = ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({ + reportID, + backTo, + }); if (openInternalRouteInNewTab(route, event)) { return; } @@ -710,7 +698,11 @@ function Search({ isCreatedTaskReportAction(reportActionItem) && (isOptimisticCreatedTaskAction || reportActionItem.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD); const reportActionID = shouldSkipReportActionID ? undefined : reportActionItem.reportActionID; - const route = ROUTES.SEARCH_REPORT.getRoute({reportID, reportActionID, backTo}); + const route = ROUTES.SEARCH_REPORT.getRoute({ + reportID, + reportActionID, + backTo, + }); if (openInternalRouteInNewTab(route, event)) { return; } @@ -751,31 +743,14 @@ function Search({ email, accountID, queryJSON, - hash, offset, searchResults?.search?.hasMoreResults, currentSearchKey, - carouselSiblingTransactionIDs, getCurrencyDecimals, conciergeChat, ], ); - const carouselSiblingsKey = carouselSiblingTransactionIDs.join(','); - const [activeCarouselSnapshotHash] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH); - const [activeCarouselTransactionIDs] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS); - - useEffect(() => { - if (shouldShowLoadingState) { - return; - } - if (!shouldWriteActiveTransactionIDsForSearch(activeCarouselTransactionIDs, activeCarouselSnapshotHash, hash, carouselSiblingTransactionIDs)) { - return; - } - setActiveTransactionIDs(carouselSiblingTransactionIDs, hash); - // eslint-disable-next-line react-hooks/exhaustive-deps -- carouselSiblingsKey is an order-sensitive proxy for the array, which is rebuilt on every search data change - }, [carouselSiblingsKey, activeCarouselSnapshotHash, activeCarouselTransactionIDs, hash, shouldShowLoadingState]); - // getColumnsToShow allocates a fresh array on every call; preserve the previous reference // when contents are equal so downstream consumers don't re-render on Onyx snapshot churn // (e.g. opening a report bumps searchResults.data) that doesn't actually change the columns. @@ -839,7 +814,9 @@ function Search({ const onLayoutBase = useCallback(() => { hasHadFirstLayout.current = true; onDestinationVisible?.(isSearchResultsEmptyRef.current, 'layout'); - endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { + [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, + }); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.WARM_FIRST); endNavigateToReportsContentLoad(); TransitionTracker.runAfterTransitions({ @@ -894,7 +871,9 @@ function Search({ const onLayoutChart = useCallback(() => { hasHadFirstLayout.current = true; - endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); + endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, { + [CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true, + }); endNavigateToReportsFirstPaint(CONST.TELEMETRY.NAVIGATE_TO_REPORTS_START_TYPE.WARM_FIRST); endNavigateToReportsContentLoad(); }, []); @@ -983,7 +962,13 @@ function Search({ ); const amountIndicators = useMemo( - () => (searchResults?.data ? getWideAmountIndicators(searchResults.data) : {shouldShowAmountInWideColumn: false, shouldShowTaxAmountInWideColumn: false}), + () => + searchResults?.data + ? getWideAmountIndicators(searchResults.data) + : { + shouldShowAmountInWideColumn: false, + shouldShowTaxAmountInWideColumn: false, + }, [searchResults?.data], ); diff --git a/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts b/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts deleted file mode 100644 index 0bd91309b9d8..000000000000 --- a/src/hooks/useMoneyReportHeaderMoreContentVisibility.ts +++ /dev/null @@ -1,46 +0,0 @@ -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {isGroupPolicy} from '@libs/PolicyUtils'; -import {isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; - -import type CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import type {ValueOf} from 'type-fest'; - -import useMoneyReportHeaderStatusBar from './useMoneyReportHeaderStatusBar'; -import useOnyx from './useOnyx'; - -type MoneyReportHeaderMoreContentVisibility = { - /** Which status bar to render below the header, if any */ - statusBarType: ValueOf | undefined; - - /** Whether the next step bar should be rendered below the header */ - shouldShowNextStep: boolean; - - /** Whether the more-content row has anything to show on its own */ - hasStatusOrNextStep: boolean; -}; - -/** - * Resolves what the money report header's more-content row (status bar / next step) will display. - * - * The header needs this before it renders, because it decides where the report actions go: they normally sit at - * the end of the more-content row so they line up with its text, but when that row has nothing to show they - * would be stranded alone under the title, and belong in the header row instead. - */ -function useMoneyReportHeaderMoreContentVisibility(reportID: string | undefined): MoneyReportHeaderMoreContentVisibility { - const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); - const {shouldShowStatusBar, statusBarType} = useMoneyReportHeaderStatusBar(reportID, moneyRequestReport?.chatReportID); - - const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); - const shouldShowNextStep = isGroupPolicy(policy) && !isInvoiceReport && !shouldShowStatusBar; - - return { - statusBarType, - shouldShowNextStep, - hasStatusOrNextStep: shouldShowNextStep || !!statusBarType, - }; -} - -export default useMoneyReportHeaderMoreContentVisibility; diff --git a/src/hooks/useNavigateToTransactionThread.ts b/src/hooks/useNavigateToTransactionThread.ts index f4e3e1637980..f3f59199b9be 100644 --- a/src/hooks/useNavigateToTransactionThread.ts +++ b/src/hooks/useNavigateToTransactionThread.ts @@ -2,7 +2,7 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; -import {setActiveTransactionIDs, shouldPreserveActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; +import {setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; import Navigation from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; @@ -31,9 +31,6 @@ type NavigateToTransactionThreadParams = { /** Ordered list of sibling transaction IDs used to drive the prev/next carousel in the thread RHP */ siblingTransactionIDs: string[]; - /** When true, keep an already-active broader carousel (e.g. the Spend page's list) instead of re-seeding it with just this report's siblings */ - shouldPreserveBroaderCarousel?: boolean; - /** Route to return to when navigating back; defaults to the current active route */ backTo?: string; }; @@ -56,7 +53,7 @@ function useNavigateToTransactionThread() { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); - return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, shouldPreserveBroaderCarousel = false, backTo}: NavigateToTransactionThreadParams) => { + return ({transactionID, reportActions, report, transaction, siblingTransactionIDs, backTo}: NavigateToTransactionThreadParams) => { const iouAction = getIOUActionForTransactionID(reportActions, transactionID); const resolvedBackTo = backTo ?? Navigation.getActiveRoute(); let reportIDToNavigate = iouAction?.childReportID; @@ -87,12 +84,8 @@ function useNavigateToTransactionThread() { } // Single transaction report opens in RHP. We seed every sibling transaction ID so the RHP can - // display prev/next arrows for navigation between expenses. A broader carousel the user drilled in from is - // left untouched, so its list (and the snapshot hash backing prev/next) survive navigating back out to it. - const seedCarousel = - shouldPreserveBroaderCarousel && shouldPreserveActiveTransactionIDs(siblingTransactionIDs, transactionID) ? Promise.resolve() : setActiveTransactionIDs(siblingTransactionIDs); - - seedCarousel.then(() => { + // display prev/next arrows for navigation between expenses. + setActiveTransactionIDs(siblingTransactionIDs).then(() => { if (reportIDToNavigate) { markReportRHPWidth(reportIDToNavigate, 'wide'); } diff --git a/src/languages/de.ts b/src/languages/de.ts index e8d80f914839..88237b4c0d5f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -499,7 +499,6 @@ const translations: TranslationDeepObject = { previousYear: 'Vorheriges Jahr', nextYear: 'Nächstes Jahr', avatar: 'Avatar', - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} von ${total}`, editor: 'Editor', restrictions: 'Beschränkungen', tryAgain: 'Erneut versuchen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 50e04721307a..fa914d069e6e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -330,7 +330,6 @@ const translations: TranslationDeepObject = { automatic: 'Αυτόματο', showing: 'Εμφανίζονται', of: 'του', - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} από ${total}`, default: 'Προεπιλογή', update: 'Ενημέρωση', member: 'Μέλος', diff --git a/src/languages/en.ts b/src/languages/en.ts index 8c413235301f..c38b485abeb6 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -367,8 +367,6 @@ const translations = { automatic: 'Automatic', showing: 'Showing', of: 'of', - // @context Carousel pagination counter showing the current item's position out of the total (e.g. "3 of 50"). - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} of ${total}`, default: 'Default', update: 'Update', member: 'Member', diff --git a/src/languages/es.ts b/src/languages/es.ts index 71a31a3e30b7..6a6652872c24 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -485,7 +485,6 @@ const translations: TranslationDeepObject = { goToConcierge: 'Ir a Concierge', allSet: '¡Todo listo!', enterDigitLabel: ({digitIndex, totalDigits}: {digitIndex: number; totalDigits: number}) => `introducir dígito ${digitIndex} de ${totalDigits}`, - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, apiKey: 'Clave API', editor: 'Editor', restrictions: 'Restricciones', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c9fbaac2dc7c..47a18f0ce344 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -499,7 +499,6 @@ const translations: TranslationDeepObject = { previousYear: 'Année précédente', nextYear: 'L’an prochain', avatar: 'Avatar', - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} sur ${total}`, editor: 'Éditeur', restrictions: 'Restrictions', tryAgain: 'Réessayer', diff --git a/src/languages/it.ts b/src/languages/it.ts index 1ca001d6713f..efc722a148f8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -499,7 +499,6 @@ const translations: TranslationDeepObject = { previousYear: 'Anno precedente', nextYear: "L'anno prossimo", avatar: 'Avatar', - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} di ${total}`, editor: 'Editor', restrictions: 'Restrizioni', tryAgain: 'Riprova', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 742e711618d6..009346f794a2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -498,7 +498,6 @@ const translations: TranslationDeepObject = { previousYear: '前年', nextYear: '来年', avatar: 'アバター', - currentOfTotal: ({current, total}: {current: number; total: number}) => `${total} 件中 ${current} 件目`, editor: '編集者', restrictions: '制限', tryAgain: '再試行', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 8a0bdab327ba..28fd7d9ad85d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -498,7 +498,6 @@ const translations: TranslationDeepObject = { previousYear: 'Vorig jaar', nextYear: 'Volgend jaar', avatar: 'Avatar', - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} van ${total}`, editor: 'Editor', restrictions: 'Beperkingen', tryAgain: 'Probeer het opnieuw', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 56539e2854f3..00287d1685b4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -502,7 +502,6 @@ const translations: TranslationDeepObject = { previousYear: 'Poprzedni rok', nextYear: 'W przyszłym roku', avatar: 'Avatar', - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} z ${total}`, editor: 'Edytor', restrictions: 'Ograniczenia', tryAgain: 'Spróbuj ponownie', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 41d97949cecd..361133ed32fc 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -497,7 +497,6 @@ const translations: TranslationDeepObject = { previousYear: 'Ano anterior', nextYear: 'Ano que vem', avatar: 'Avatar', - currentOfTotal: ({current, total}: {current: number; total: number}) => `${current} de ${total}`, editor: 'Editor', restrictions: 'Restrições', tryAgain: 'Tentar novamente', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2b33be697d52..cb89e0234f2b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -494,7 +494,6 @@ const translations: TranslationDeepObject = { previousYear: '上一年', nextYear: '明年', avatar: '头像', - currentOfTotal: ({current, total}: {current: number; total: number}) => `第 ${current} 项(共 ${total} 项)`, editor: '编辑', restrictions: '限制', tryAgain: '重试', diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index f22385a7c8a4..9722be1c9bf3 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -338,7 +338,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, - ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_BILLING_STATEMENT, ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 48449b383d2a..b4e28a3766e0 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2766,7 +2766,6 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.SEARCH_REPORT]: { reportID: string; reportActionID?: string; - anchorTransactionID?: string; shouldReplaceWithExpenseReportRHP?: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; diff --git a/src/libs/actions/TransactionThreadNavigation.ts b/src/libs/actions/TransactionThreadNavigation.ts index 964956e1c1ed..4e87112d3579 100644 --- a/src/libs/actions/TransactionThreadNavigation.ts +++ b/src/libs/actions/TransactionThreadNavigation.ts @@ -18,7 +18,6 @@ import Onyx from 'react-native-onyx'; */ let lastSetIDs: string[] | null = null; -let lastSetSnapshotHash: number | null = null; let lastSetDescriptors: Record | null = null; function areDescriptorMapsEqual(a: Record | null, b: Record | null) { @@ -46,25 +45,19 @@ function areDescriptorMapsEqual(a: Record) { - const nextSnapshotHash = snapshotHash ?? null; +function setActiveTransactionIDs(ids: string[], siblingDescriptorsByTransactionID?: Record) { const nextDescriptors = siblingDescriptorsByTransactionID ?? null; - const areIDsUnchanged = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); - if (areIDsUnchanged && lastSetSnapshotHash === nextSnapshotHash && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors)) { + const sameIDs = lastSetIDs?.length === ids.length && lastSetIDs.every((id, i) => id === ids.at(i)); + if (sameIDs && areDescriptorMapsEqual(lastSetDescriptors, nextDescriptors)) { return Promise.resolve(); } lastSetIDs = ids; - lastSetSnapshotHash = nextSnapshotHash; lastSetDescriptors = nextDescriptors; - return Promise.all([ - Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ids), - Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, nextSnapshotHash), - Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, nextDescriptors), - ]); + return Promise.all([Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ids), Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, nextDescriptors)]); } /** @@ -76,42 +69,10 @@ function getActiveTransactionIDs(): {ids: string[] | null; descriptors: Record 1; - } - if (activeSnapshotHash !== searchSnapshotHash) { - return false; - } - const isUpToDate = activeIDs.length === searchTransactionIDs.length && activeIDs.every((id, index) => id === searchTransactionIDs.at(index)); - return !isUpToDate; -} - -function shouldPreserveActiveTransactionIDs(candidateIDs: string[], anchorTransactionID: string): boolean { - const activeIDs = lastSetIDs; - if (!activeIDs?.includes(anchorTransactionID)) { - return false; - } - return activeIDs.length > candidateIDs.length && candidateIDs.every((id) => activeIDs.includes(id)); -} - function clearActiveTransactionIDs() { lastSetIDs = null; - lastSetSnapshotHash = null; lastSetDescriptors = null; - return Promise.all([ - Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, null), - Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, null), - Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, null), - ]); + return Promise.all([Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, null), Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS, null)]); } -export {setActiveTransactionIDs, clearActiveTransactionIDs, getActiveTransactionIDs, shouldWriteActiveTransactionIDsForSearch, shouldPreserveActiveTransactionIDs}; +export {setActiveTransactionIDs, clearActiveTransactionIDs, getActiveTransactionIDs}; diff --git a/src/pages/home/RecentlyAddedSection/index.tsx b/src/pages/home/RecentlyAddedSection/index.tsx index 30eb3b1c7dac..3c72599e80ce 100644 --- a/src/pages/home/RecentlyAddedSection/index.tsx +++ b/src/pages/home/RecentlyAddedSection/index.tsx @@ -75,7 +75,7 @@ function RecentlyAddedSection() { // Each row opens a single-expense view that always lands in (Wide) RHP on both layouts so the carousel // arrows are available. Marking the report as an expense lets the RHP open wide immediately, before its // data loads, instead of flickering from narrow to wide. - setActiveTransactionIDs(siblingTransactionIDs, undefined, siblingDescriptorsByTransactionID).then(() => { + setActiveTransactionIDs(siblingTransactionIDs, siblingDescriptorsByTransactionID).then(() => { markReportRHPWidth(reportID, 'wide'); Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo: ROUTES.HOME})); }); diff --git a/src/pages/inbox/ReportNavigateAwayHandler.tsx b/src/pages/inbox/ReportNavigateAwayHandler.tsx index 4ef7605e2519..13247de98645 100644 --- a/src/pages/inbox/ReportNavigateAwayHandler.tsx +++ b/src/pages/inbox/ReportNavigateAwayHandler.tsx @@ -172,11 +172,15 @@ function ReportNavigateAwayHandler() { const didReportClose = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; const isTopLevelPolicyRoomWithNoStatus = !report?.statusNum && !prevReport?.parentReportID && prevReport?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_ROOM; const isClosedTopLevelPolicyRoom = wasReportRemoved && prevReport.statusNum === CONST.REPORT.STATUS_NUM.OPEN && isTopLevelPolicyRoomWithNoStatus; - const userLeavingTriggered = !prevUserLeavingStatus && !!userLeavingStatus; - const deletedParentTriggered = prevDeletedParentAction && !deletedParentAction; - const shouldTrigger = userLeavingTriggered || didReportClose || isRemovalExpectedForReportType || isClosedTopLevelPolicyRoom || deletedParentTriggered; // Navigate to the Concierge chat if the room was removed from another device (e.g. user leaving a room or removed from a room) - if (shouldTrigger) { + if ( + // non-optimistic case + (!prevUserLeavingStatus && !!userLeavingStatus) || + didReportClose || + isRemovalExpectedForReportType || + isClosedTopLevelPolicyRoom || + (prevDeletedParentAction && !deletedParentAction) + ) { navigateAwayFromReport(prevOnyxReportID, prevReport?.parentReportID); } }, [ diff --git a/src/styles/utils/sizing.ts b/src/styles/utils/sizing.ts index 4e8e41c484cc..216a5e90c82d 100644 --- a/src/styles/utils/sizing.ts +++ b/src/styles/utils/sizing.ts @@ -92,10 +92,6 @@ export default { minWidth: 8, }, - mnw8: { - minWidth: 32, - }, - mnw25: { minWidth: '25%', }, diff --git a/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx b/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx deleted file mode 100644 index d5cd4e8ea801..000000000000 --- a/tests/ui/components/MoneyReportHeaderActionsPlacementTest.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import {render} from '@testing-library/react-native'; - -import MoneyReportHeader from '@components/MoneyReportHeader'; -import MoneyReportHeaderActions from '@components/MoneyReportHeaderActions'; -import MoneyReportHeaderMoreContent from '@components/MoneyReportHeaderMoreContent'; - -import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; -import useOnyx from '@hooks/useOnyx'; -import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; -import type {Report} from '@src/types/onyx'; - -import type * as ReactNavigationNative from '@react-navigation/native'; - -import {useRoute} from '@react-navigation/native'; -import React from 'react'; - -const REPORT_ID = '1001'; -const CHAT_REPORT_ID = '2001'; - -const HEADER_ROW_TEST_ID = 'header-row'; -const ACTIONS_TEST_ID = 'header-actions'; -const REPORT_CAROUSEL_TEST_ID = 'report-carousel'; -const TRANSACTIONS_CAROUSEL_TEST_ID = 'transactions-carousel'; - -const PARENT_REPORT_ID = '3001'; -const PARENT_ACTION_ID = 'parentAction1'; -const THREAD_TRANSACTION_ID = 'thread-tx-1'; - -/** A 1:1 DM IOU report: `isDM` is true for its chat report, and there is no next step or status bar to show. */ -const iouReport = {reportID: REPORT_ID, chatReportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.IOU} as Report; -const dmChatReport = {reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT} as Report; - -jest.mock('@hooks/useOnyx', () => jest.fn()); -jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); -jest.mock('@hooks/useReportPrimaryAction', () => jest.fn()); -jest.mock('@hooks/useMoneyReportHeaderMoreContentVisibility', () => jest.fn()); -jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => jest.fn(() => ({isWideRHPDisplayedOnWideLayout: false, isSuperWideRHPDisplayedOnWideLayout: false}))); -jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); -jest.mock('@hooks/useMobileSelectionMode', () => jest.fn(() => false)); -jest.mock('@hooks/useTransactionsAndViolationsForReport', () => jest.fn(() => ({transactions: {}, violations: {}}))); -jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); - -// useThemeStyles throws without a ; return a proxy that yields an empty style object -// for any key so the (mostly-mocked) tree renders without wiring up the full provider stack. -jest.mock('@hooks/useThemeStyles', () => { - const styleProxy = new Proxy({}, {get: () => ({})}); - return jest.fn(() => styleProxy); -}); - -// Only `useRoute` is stubbed: the rest of the module is used by the navigation imports ReportUtils pulls in. -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - __esModule: true, - useRoute: jest.fn(), -})); - -// The providers only supply context to the (mocked) leaves, so pass children straight through. -jest.mock('@components/MoneyReportHeaderModals', () => { - const reactModule = jest.requireActual('react'); - return jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)); -}); -jest.mock('@components/MoneyReportHeaderActions/ExportDownloadStatusProvider', () => { - const reactModule = jest.requireActual('react'); - return { - __esModule: true, - ExportDownloadStatusProvider: jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)), - }; -}); -jest.mock('@components/PaymentAnimationsContext', () => { - const reactModule = jest.requireActual('react'); - return { - __esModule: true, - PaymentAnimationsProvider: jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)), - }; -}); -jest.mock('@components/Search/SearchContext', () => ({ - __esModule: true, - useSearchSelectionActions: jest.fn(() => ({clearSelectedTransactions: jest.fn()})), -})); - -// HeaderWithBackButton stands in for the title row: it renders only its children, tagged so the test can -// assert what landed on the title row and in which order. -jest.mock('@components/HeaderWithBackButton', () => { - const reactModule = jest.requireActual('react'); - const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); - return jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(View, {testID: 'header-row'}, children)); -}); -jest.mock('@components/HeaderLoadingBar', () => jest.fn(() => null)); -jest.mock('@components/MoneyReportHeaderActions', () => { - const reactModule = jest.requireActual('react'); - const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); - return jest.fn(() => reactModule.createElement(View, {testID: 'header-actions'})); -}); -jest.mock('@components/MoneyReportHeaderMoreContent', () => jest.fn(() => null)); -jest.mock('@components/MoneyRequestReportView/MoneyRequestReportNavigation', () => { - const reactModule = jest.requireActual('react'); - const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); - return jest.fn(() => reactModule.createElement(View, {testID: 'report-carousel'})); -}); -jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation', () => { - const reactModule = jest.requireActual('react'); - const {View} = jest.requireActual<{View: React.ComponentType<{testID?: string; children?: React.ReactNode}>}>('react-native'); - return jest.fn(() => reactModule.createElement(View, {testID: 'transactions-carousel'})); -}); - -const mockedUseOnyx = jest.mocked(useOnyx); -const mockedUseRoute = jest.mocked(useRoute); -const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayout); -const mockedUseReportPrimaryAction = jest.mocked(useReportPrimaryAction); -const mockedMoreContentVisibility = jest.mocked(useMoneyReportHeaderMoreContentVisibility); -const mockedActions = jest.mocked(MoneyReportHeaderActions); -const mockedMoreContent = jest.mocked(MoneyReportHeaderMoreContent); - -/** Collects the tagged testIDs in render order so the test can assert left-to-right placement on the title row. */ -function getHeaderRowTestIDs(node: ReturnType['toJSON']>): string[] { - const found: string[] = []; - const walk = (current: unknown, isInsideHeaderRow: boolean) => { - if (!current || typeof current !== 'object') { - return; - } - if (Array.isArray(current)) { - for (const child of current) { - walk(child, isInsideHeaderRow); - } - return; - } - const element = current as {props?: Record; children?: unknown}; - const testID = element.props?.testID; - const isHeaderRow = testID === HEADER_ROW_TEST_ID; - if (isInsideHeaderRow && typeof testID === 'string' && testID !== HEADER_ROW_TEST_ID) { - found.push(testID); - } - walk(element.children, isInsideHeaderRow || isHeaderRow); - }; - walk(node, false); - return found; -} - -function renderHeader() { - return render( - , - ); -} - -/** - * Regression guard for https://github.com/Expensify/App/issues/98200: the report actions must sit on the title row - * whenever the more-content row would otherwise be blank, no matter whether the report was opened directly or from - * Search. Placement used to flip between the two entry points, moving the buttons onto a row of their own in Search. - */ -describe('MoneyReportHeader actions placement', () => { - beforeEach(() => { - jest.clearAllMocks(); - - // A wide layout, so the header-row placement is in play at all. - mockedUseResponsiveLayout.mockReturnValue({ - shouldUseNarrowLayout: false, - isSmallScreenWidth: false, - isInNarrowPaneModal: false, - isExtraSmallScreenHeight: false, - isMediumScreenWidth: false, - isLargeScreenWidth: true, - isExtraLargeScreenWidth: false, - isExtraSmallScreenWidth: false, - isSmallScreen: false, - onboardingIsMediumOrLargerScreenWidth: true, - isInLandscapeMode: false, - }); - mockedUseReportPrimaryAction.mockReturnValue(CONST.REPORT.PRIMARY_ACTIONS.PAY); - mockedMoreContentVisibility.mockReturnValue({statusBarType: undefined, shouldShowNextStep: false, hasStatusOrNextStep: false}); - mockedUseOnyx.mockImplementation((key) => { - if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { - return [iouReport, {status: 'loaded'}]; - } - if (key === `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`) { - return [dmChatReport, {status: 'loaded'}]; - } - return [undefined, {status: 'loaded'}]; - }); - }); - - it.each([ - ['opened directly', SCREENS.REPORT], - ['opened from Search', SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], - ['opened from Search as an expense', SCREENS.RIGHT_MODAL.SEARCH_REPORT], - ])('keeps the actions on the title row when the report is %s', (_label, routeName) => { - mockedUseRoute.mockReturnValue({key: 'route-1', name: routeName, params: {}}); - - const {toJSON} = renderHeader(); - - expect(getHeaderRowTestIDs(toJSON())).toContain(ACTIONS_TEST_ID); - // The actions row under the title must stay empty, otherwise the buttons would render twice. - expect(mockedMoreContent).toHaveBeenCalled(); - expect(mockedMoreContent.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldRenderActionsInRow: false})); - }); - - it.each([ - ['an invoice report, whose chat report is an invoice room', {reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM} as Report], - [ - 'a workspace report, whose chat report is a policy expense chat', - {reportID: CHAT_REPORT_ID, type: CONST.REPORT.TYPE.CHAT, chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT} as Report, - ], - ])('keeps the actions on the title row for %s', (_label, chatReport) => { - mockedUseOnyx.mockImplementation((key) => { - if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { - return [iouReport, {status: 'loaded'}]; - } - if (key === `${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`) { - return [chatReport, {status: 'loaded'}]; - } - return [undefined, {status: 'loaded'}]; - }); - mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.REPORT, params: {}}); - - const {toJSON} = renderHeader(); - - expect(getHeaderRowTestIDs(toJSON())).toContain(ACTIONS_TEST_ID); - expect(mockedMoreContent.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldRenderActionsInRow: false})); - }); - - it('renders the actions before the carousel so the carousel stays pinned to the top right', () => { - mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, params: {}}); - - const {toJSON} = renderHeader(); - - expect(getHeaderRowTestIDs(toJSON())).toEqual([ACTIONS_TEST_ID, REPORT_CAROUSEL_TEST_ID]); - }); - - it('moves the actions down to the more-content row when that row has a status or next step', () => { - mockedMoreContentVisibility.mockReturnValue({statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD, shouldShowNextStep: false, hasStatusOrNextStep: true}); - mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, params: {}}); - - const {toJSON} = renderHeader(); - - expect(getHeaderRowTestIDs(toJSON())).not.toContain(ACTIONS_TEST_ID); - expect(mockedActions).not.toHaveBeenCalled(); - expect(mockedMoreContent.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldRenderActionsInRow: true})); - }); -}); - -describe('MoneyReportHeader transaction carousel anchor', () => { - const threadReport = { - reportID: REPORT_ID, - chatReportID: CHAT_REPORT_ID, - parentReportID: PARENT_REPORT_ID, - parentReportActionID: PARENT_ACTION_ID, - type: CONST.REPORT.TYPE.CHAT, - } as Report; - - const parentIOUAction = { - reportActionID: PARENT_ACTION_ID, - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - originalMessage: {IOUTransactionID: THREAD_TRANSACTION_ID, type: CONST.IOU.REPORT_ACTION_TYPE.CREATE}, - }; - - /** Mirrors the failing state: the derived transactions index has nothing for this thread. */ - function mockThread({activeIDs, parentActions}: {activeIDs: string[]; parentActions: Record | undefined}) { - // The anchor is read through a `selector`, so the mock has to apply it the way useOnyx does. - mockedUseOnyx.mockImplementation((key, options) => { - const rawValue = (() => { - if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { - return threadReport; - } - if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS) { - return activeIDs; - } - if (key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`) { - return parentActions; - } - return undefined; - })(); - const {selector} = options ?? {}; - const value = selector ? selector(rawValue) : rawValue; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- useOnyx's result type can't express "value depends on the key", and each branch above returns the shape its own key really holds - return [value as NonNullable | undefined, {status: 'loaded'}]; - }); - mockedUseRoute.mockReturnValue({key: 'route-1', name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, params: {}}); - } - - beforeEach(() => { - mockedMoreContentVisibility.mockReturnValue({statusBarType: undefined, shouldShowNextStep: false, hasStatusOrNextStep: false}); - mockedUseReportPrimaryAction.mockReturnValue(CONST.REPORT.PRIMARY_ACTIONS.PAY); - }); - - it('renders the transaction carousel using the parent IOU action when the derived index is cold', () => { - mockThread({activeIDs: ['other-tx', THREAD_TRANSACTION_ID], parentActions: {[PARENT_ACTION_ID]: parentIOUAction}}); - - const {toJSON} = renderHeader(); - - expect(getHeaderRowTestIDs(toJSON())).toContain(TRANSACTIONS_CAROUSEL_TEST_ID); - }); - - it('does not render it when the resolved expense is not one of the carousel siblings', () => { - mockThread({activeIDs: ['other-tx', 'unrelated-tx'], parentActions: {[PARENT_ACTION_ID]: parentIOUAction}}); - - const {toJSON} = renderHeader(); - - expect(getHeaderRowTestIDs(toJSON())).not.toContain(TRANSACTIONS_CAROUSEL_TEST_ID); - }); - - it('does not render it when the parent action is unavailable, so there is no expense to anchor to', () => { - mockThread({activeIDs: ['other-tx', THREAD_TRANSACTION_ID], parentActions: undefined}); - - const {toJSON} = renderHeader(); - - expect(getHeaderRowTestIDs(toJSON())).not.toContain(TRANSACTIONS_CAROUSEL_TEST_ID); - }); -}); diff --git a/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx b/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx new file mode 100644 index 000000000000..0cb04c080d37 --- /dev/null +++ b/tests/ui/components/MoneyReportHeaderMoreContentTest.tsx @@ -0,0 +1,120 @@ +import {render} from '@testing-library/react-native'; + +import MoneyReportHeaderMoreContent from '@components/MoneyReportHeaderMoreContent'; +import MoneyReportHeaderNextStep from '@components/MoneyReportHeaderNextStep'; + +import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; +import useOnyx from '@hooks/useOnyx'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report} from '@src/types/onyx'; + +import type {UseOnyxResult} from 'react-native-onyx'; + +import React from 'react'; + +import createRandomPolicy from '../../utils/collections/policies'; + +const TEST_REPORT_ID = '1001'; +const TEST_POLICY_ID = 'policy1'; + +const report = { + reportID: TEST_REPORT_ID, + policyID: TEST_POLICY_ID, + type: CONST.REPORT.TYPE.EXPENSE, +} as Report; + +function createOnyxResult(value: NonNullable | undefined): UseOnyxResult { + return [value, {status: 'loaded'}]; +} + +// `useRoute` is only used to detect Search routes; default to a regular report route so `isReportInSearch` is false. +// Spread the real module so navigation internals (e.g. createNavigationContainerRef) pulled in by the real +// PolicyUtils import chain keep working. +jest.mock('@react-navigation/native', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actualNavigation = jest.requireActual('@react-navigation/native'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actualNavigation, + __esModule: true, + useRoute: jest.fn(() => ({name: 'report'})), + }; +}); + +// The next-step bar is the element under test; render nothing but track whether it was mounted via the visibility gate. +jest.mock('@components/MoneyReportHeaderNextStep', () => ({__esModule: true, default: jest.fn(() => null)})); +jest.mock('@components/MoneyReportHeaderStatusBarSection', () => ({__esModule: true, default: () => null})); +jest.mock('@components/MoneyRequestReportView/MoneyRequestReportNavigation', () => ({__esModule: true, default: () => null})); +jest.mock('@components/MoneyReportTransactionThreadContext', () => ({__esModule: true, useMoneyReportTransactionThread: jest.fn(() => ({iouTransactionID: undefined}))})); + +// Spread the real module: the live PolicyUtils import chain relies on many other ReportUtils exports. +jest.mock('@libs/ReportUtils', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actualReportUtils = jest.requireActual('@libs/ReportUtils'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actualReportUtils, + __esModule: true, + isInvoiceReport: jest.fn(() => false), + }; +}); + +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: jest.fn(() => ({}))})); +jest.mock('@hooks/useResponsiveLayout', () => ({__esModule: true, default: jest.fn(() => ({shouldUseNarrowLayout: false, isMediumScreenWidth: false}))})); +jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => ({__esModule: true, default: jest.fn(() => ({isWideRHPDisplayedOnWideLayout: false, isSuperWideRHPDisplayedOnWideLayout: false}))})); +jest.mock('@hooks/useMoneyReportHeaderStatusBar', () => ({__esModule: true, default: jest.fn(() => ({shouldShowStatusBar: false, statusBarType: undefined}))})); +jest.mock('@hooks/useOnyx', () => jest.fn()); + +const mockedUseOnyx = jest.mocked(useOnyx); +const mockedStatusBar = jest.mocked(useMoneyReportHeaderStatusBar); +const mockedNextStepBar = jest.mocked(MoneyReportHeaderNextStep); + +let policyValue: Policy | undefined; + +function mockPolicyType(type: Policy['type']) { + policyValue = {...createRandomPolicy(1, type), id: TEST_POLICY_ID}; +} + +describe('MoneyReportHeaderMoreContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + policyValue = undefined; + mockedStatusBar.mockReturnValue({shouldShowStatusBar: false, statusBarType: undefined}); + mockedUseOnyx.mockImplementation((key) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${TEST_REPORT_ID}`) { + return createOnyxResult(report); + } + if (key === `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`) { + return createOnyxResult(policyValue); + } + return createOnyxResult(undefined); + }); + }); + + it('renders the next step bar for a Submit workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.SUBMIT); + render(); + expect(mockedNextStepBar).toHaveBeenCalled(); + }); + + it('renders the next step bar for a paid (team) workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.TEAM); + render(); + expect(mockedNextStepBar).toHaveBeenCalled(); + }); + + it('does not render the next step bar for a personal workspace', () => { + mockPolicyType(CONST.POLICY.TYPE.PERSONAL); + render(); + expect(mockedNextStepBar).not.toHaveBeenCalled(); + }); + + it('does not render the next step bar when a status bar is shown', () => { + mockPolicyType(CONST.POLICY.TYPE.SUBMIT); + mockedStatusBar.mockReturnValue({shouldShowStatusBar: true, statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD}); + render(); + expect(mockedNextStepBar).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx b/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx deleted file mode 100644 index d52e54138a91..000000000000 --- a/tests/ui/components/MoneyReportHeaderMoreContentVisibilityTest.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import {renderHook} from '@testing-library/react-native'; - -import useMoneyReportHeaderMoreContentVisibility from '@hooks/useMoneyReportHeaderMoreContentVisibility'; -import useMoneyReportHeaderStatusBar from '@hooks/useMoneyReportHeaderStatusBar'; -import useOnyx from '@hooks/useOnyx'; - -import {isInvoiceReport} from '@libs/ReportUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report} from '@src/types/onyx'; - -import type {UseOnyxResult} from 'react-native-onyx'; - -import createRandomPolicy from '../../utils/collections/policies'; - -const TEST_REPORT_ID = '1001'; -const TEST_POLICY_ID = 'policy1'; - -const report = { - reportID: TEST_REPORT_ID, - policyID: TEST_POLICY_ID, - type: CONST.REPORT.TYPE.EXPENSE, -} as Report; - -function createOnyxResult(value: NonNullable | undefined): UseOnyxResult { - return [value, {status: 'loaded'}]; -} - -// Spread the real module: the live PolicyUtils import chain relies on many other ReportUtils exports. -jest.mock('@libs/ReportUtils', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actualReportUtils = jest.requireActual('@libs/ReportUtils'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actualReportUtils, - __esModule: true, - isInvoiceReport: jest.fn(() => false), - }; -}); - -jest.mock('@hooks/useMoneyReportHeaderStatusBar', () => ({__esModule: true, default: jest.fn(() => ({shouldShowStatusBar: false, statusBarType: undefined}))})); -jest.mock('@hooks/useOnyx', () => jest.fn()); - -const mockedUseOnyx = jest.mocked(useOnyx); -const mockedStatusBar = jest.mocked(useMoneyReportHeaderStatusBar); -const mockedIsInvoiceReport = jest.mocked(isInvoiceReport); - -let policyValue: Policy | undefined; - -function mockPolicyType(type: Policy['type']) { - policyValue = {...createRandomPolicy(1, type), id: TEST_POLICY_ID}; -} - -/** - * `hasStatusOrNextStep` is what the header uses to decide where the report actions go: at the end of the - * more-content row when that row has its own content, or in the header row when it would otherwise be blank. - */ -describe('useMoneyReportHeaderMoreContentVisibility', () => { - beforeEach(() => { - jest.clearAllMocks(); - policyValue = undefined; - mockedIsInvoiceReport.mockReturnValue(false); - mockedStatusBar.mockReturnValue({shouldShowStatusBar: false, statusBarType: undefined}); - mockedUseOnyx.mockImplementation((key) => { - if (key === `${ONYXKEYS.COLLECTION.REPORT}${TEST_REPORT_ID}`) { - return createOnyxResult(report); - } - if (key === `${ONYXKEYS.COLLECTION.POLICY}${TEST_POLICY_ID}`) { - return createOnyxResult(policyValue); - } - return createOnyxResult(undefined); - }); - }); - - it('shows the next step for a Submit workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.SUBMIT); - const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); - expect(result.current.shouldShowNextStep).toBe(true); - expect(result.current.hasStatusOrNextStep).toBe(true); - }); - - it('shows the next step for a paid (team) workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.TEAM); - const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); - expect(result.current.shouldShowNextStep).toBe(true); - expect(result.current.hasStatusOrNextStep).toBe(true); - }); - - it('does not show the next step for a personal workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.PERSONAL); - const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); - expect(result.current.shouldShowNextStep).toBe(false); - }); - - it('does not show the next step when a status bar is shown', () => { - mockPolicyType(CONST.POLICY.TYPE.SUBMIT); - mockedStatusBar.mockReturnValue({shouldShowStatusBar: true, statusBarType: CONST.REPORT.STATUS_BAR_TYPE.ON_HOLD}); - const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); - expect(result.current.shouldShowNextStep).toBe(false); - // A status bar still fills the more-content row, so the actions stay in it. - expect(result.current.hasStatusOrNextStep).toBe(true); - }); - - it('reports an empty more-content row for a personal workspace with no status bar', () => { - mockPolicyType(CONST.POLICY.TYPE.PERSONAL); - const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); - // This is the 1:1 DM IOU case: nothing to show, so the actions move up into the header row - // instead of sitting alone on a blank line under the title. - expect(result.current.hasStatusOrNextStep).toBe(false); - }); - - it('reports an empty more-content row for an invoice report, even on a paid workspace', () => { - mockPolicyType(CONST.POLICY.TYPE.TEAM); - mockedIsInvoiceReport.mockReturnValue(true); - const {result} = renderHook(() => useMoneyReportHeaderMoreContentVisibility(TEST_REPORT_ID)); - expect(result.current.shouldShowNextStep).toBe(false); - expect(result.current.hasStatusOrNextStep).toBe(false); - }); -}); diff --git a/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx b/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx deleted file mode 100644 index 13b5622dc034..000000000000 --- a/tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx +++ /dev/null @@ -1,458 +0,0 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; - -import MoneyRequestReportTransactionsNavigation from '@components/MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; - -import {createTransactionThreadReport} from '@libs/actions/Report'; -import {clearActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; -import {getReportIDToOpenForExpense} from '@libs/TransactionThreadNavigationUtils'; - -import Navigation from '@navigation/Navigation'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; - -import React from 'react'; - -/** - * These tests verify the navigation resolution of MoneyRequestReportTransactionsNavigation: - * given a transaction list (and optionally a search snapshot), pressing prev/next should resolve - * and navigate to the correct target reportID for each direction. The heavy hooks are mocked so - * the real selectors and the onNext/onPrevious branching logic are exercised in isolation. - */ - -type MockOnyxState = { - transactionIDsList: string[] | undefined; - snapshotHash: string | undefined; - snapshot: {data: Record} | undefined; - siblingDescriptors: Record | undefined; - transactionsCollection: Record; - reportActionsCollection: Record; - reportsCollection: Record; -}; - -const mockState: MockOnyxState = { - transactionIDsList: undefined, - snapshotHash: undefined, - snapshot: undefined, - siblingDescriptors: undefined, - transactionsCollection: {}, - reportActionsCollection: {}, - reportsCollection: {}, -}; - -const mockUseOnyx = jest.fn(); -const mockMarkReportRHPWidth = jest.fn(); - -jest.mock('@hooks/useOnyx', () => ({ - __esModule: true, - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - default: (...args: unknown[]) => mockUseOnyx(...args), -})); - -jest.mock('@hooks/useThemeStyles', () => ({ - __esModule: true, - default: () => ({}), -})); - -jest.mock('@hooks/useLocalize', () => ({ - __esModule: true, - default: () => ({translate: (key: string) => key}), -})); - -jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ - __esModule: true, - default: () => ({accountID: 1, email: 'me@example.com'}), -})); - -jest.mock('@components/WideRHPContextProvider', () => ({ - useWideRHPActions: () => ({markReportRHPWidth: mockMarkReportRHPWidth}), -})); - -jest.mock('@components/OnyxListItemProvider', () => ({ - usePersonalDetails: () => ({}), -})); - -type ReactActual = {createElement: typeof React.createElement; Fragment: typeof React.Fragment}; -type ReactNativeActual = { - Pressable: React.ComponentType<{testID?: string; disabled?: boolean; onPress?: () => void}>; - Text: React.ComponentType<{children?: React.ReactNode}>; -}; - -jest.mock('@components/Text', () => { - const {Text} = jest.requireActual('react-native'); - return {__esModule: true, default: Text}; -}); - -jest.mock('@components/PrevNextButtons', () => { - const ReactLib = jest.requireActual('react'); - const {Pressable} = jest.requireActual('react-native'); - return { - __esModule: true, - default: (props: {onNext: () => void; onPrevious: () => void; isNextButtonDisabled?: boolean; isPrevButtonDisabled?: boolean}) => - ReactLib.createElement( - ReactLib.Fragment, - null, - ReactLib.createElement(Pressable, {testID: 'prev-button', disabled: props.isPrevButtonDisabled, onPress: () => props.onPrevious()}), - ReactLib.createElement(Pressable, {testID: 'next-button', disabled: props.isNextButtonDisabled, onPress: () => props.onNext()}), - ), - }; -}); - -jest.mock('@navigation/Navigation', () => ({ - __esModule: true, - default: { - setParams: jest.fn(), - getActiveRoute: jest.fn(() => 'active-route'), - }, -})); - -const makeRootState = (focusedRouteName: string) => ({index: 0, routes: [{key: 'k', name: focusedRouteName}]}); -const mockGetRootState = jest.fn(() => makeRootState('testRoute')); - -jest.mock('@navigation/navigationRef', () => ({ - __esModule: true, - default: { - getRootState: () => mockGetRootState(), - getCurrentRoute: jest.fn(() => undefined), - }, -})); - -jest.mock('@libs/actions/Report', () => ({ - createTransactionThreadReport: jest.fn(() => undefined), - setOptimisticTransactionThread: jest.fn(), -})); - -jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ - clearActiveTransactionIDs: jest.fn(), -})); - -jest.mock('@libs/TransactionThreadNavigationUtils', () => ({ - getReportIDToOpenForExpense: jest.fn(() => 'resolved-descriptor-report'), -})); - -const makeIOUAction = (transactionID: string, {childReportID, reportID}: {childReportID?: string; reportID: string}) => ({ - reportActionID: `action_${transactionID}`, - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - originalMessage: {IOUTransactionID: transactionID, type: 'create'}, - childReportID, - reportID, -}); - -const CURRENT_ID = 'tCur'; -const PREV_ID = 'tPrev'; -const NEXT_ID = 'tNext'; - -const resetMockState = () => { - mockState.transactionIDsList = [PREV_ID, CURRENT_ID, NEXT_ID]; - mockState.snapshotHash = undefined; - mockState.snapshot = undefined; - mockState.siblingDescriptors = undefined; - mockState.transactionsCollection = {}; - mockState.reportActionsCollection = {}; - mockState.reportsCollection = {}; - mockGetRootState.mockReturnValue(makeRootState('testRoute')); -}; - -const setupUseOnyx = () => { - mockUseOnyx.mockImplementation((key: string, options?: {selector?: (data: unknown) => unknown}) => { - const selector = options?.selector; - if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS) { - return [mockState.transactionIDsList]; - } - if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH) { - return [mockState.snapshotHash]; - } - if (key === `${ONYXKEYS.COLLECTION.SNAPSHOT}${mockState.snapshotHash}`) { - return [mockState.snapshot]; - } - if (key === ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_THREAD_REPORT_IDS) { - return [mockState.siblingDescriptors]; - } - if (key === ONYXKEYS.COLLECTION.TRANSACTION) { - return [selector ? selector(mockState.transactionsCollection) : undefined]; - } - if (key === ONYXKEYS.COLLECTION.REPORT_ACTIONS) { - return [selector ? selector(mockState.reportActionsCollection) : undefined]; - } - if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { - return [mockState.reportsCollection[key]]; - } - // NVP_ONBOARDING (selector-based), NVP_INTRO_SELECTED, BETAS and anything else are not relevant to resolution. - return [undefined]; - }); -}; - -const renderNavigation = () => render(); - -// Navigation.setParams is deferred inside requestAnimationFrame. Run it synchronously so the resolved -// navigation happens during the press and can be asserted immediately afterwards. -const press = (testID: string) => { - global.requestAnimationFrame = (callback: FrameRequestCallback) => { - callback(0); - return 0; - }; - fireEvent.press(screen.getByTestId(testID)); -}; - -describe('MoneyRequestReportTransactionsNavigation', () => { - beforeEach(() => { - jest.clearAllMocks(); - resetMockState(); - setupUseOnyx(); - }); - - describe('one-transaction parent report', () => { - beforeEach(() => { - mockState.transactionsCollection = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, - }; - mockState.reportsCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 1}, - [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 1}, - }; - }); - - it('navigates next to the parent reportID', () => { - renderNavigation(); - - press('next-button'); - - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', reportActionID: undefined, anchorTransactionID: NEXT_ID})); - expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('rNext', 'wide'); - }); - - it('navigates previous to the parent reportID', () => { - renderNavigation(); - - press('prev-button'); - - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', reportActionID: undefined, anchorTransactionID: PREV_ID})); - expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('rPrev', 'wide'); - }); - }); - - describe('resolves siblings and parents from the search snapshot', () => { - beforeEach(() => { - // Live collections are intentionally empty; everything is only in the snapshot. - mockState.snapshotHash = 'hash1'; - mockState.snapshot = { - data: { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, - [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 1}, - [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 1}, - }, - }; - }); - - it('navigates next using snapshot-only data', () => { - renderNavigation(); - - press('next-button'); - - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', anchorTransactionID: NEXT_ID})); - }); - - it('navigates previous using snapshot-only data', () => { - renderNavigation(); - - press('prev-button'); - - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', anchorTransactionID: PREV_ID})); - }); - }); - - describe('multi-transaction parent with an existing thread', () => { - beforeEach(() => { - mockState.transactionsCollection = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, - }; - mockState.reportActionsCollection = { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {childReportID: 'threadPrev', reportID: 'rPrev'})}, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {childReportID: 'threadNext', reportID: 'rNext'})}, - }; - mockState.reportsCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, - [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, - }; - }); - - it('navigates next to the existing transaction thread reportID', () => { - renderNavigation(); - - press('next-button'); - - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadNext', anchorTransactionID: NEXT_ID})); - expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('threadNext', 'wide'); - }); - - it('navigates previous to the existing transaction thread reportID', () => { - renderNavigation(); - - press('prev-button'); - - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadPrev', anchorTransactionID: PREV_ID})); - expect(mockMarkReportRHPWidth).toHaveBeenCalledWith('threadPrev', 'wide'); - }); - }); - - describe('live report actions take precedence over a stale snapshot copy', () => { - beforeEach(() => { - mockState.transactionsCollection = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, - }; - mockState.reportActionsCollection = { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {childReportID: 'threadPrev', reportID: 'rPrev'})}, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {childReportID: 'threadNext', reportID: 'rNext'})}, - }; - mockState.reportsCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, - [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, - }; - mockState.snapshotHash = 'hash1'; - mockState.snapshot = { - data: { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {reportID: 'rPrev'})}, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {reportID: 'rNext'})}, - }, - }; - }); - - it('navigates next to the live thread instead of creating a duplicate', () => { - renderNavigation(); - - press('next-button'); - - expect(createTransactionThreadReport).not.toHaveBeenCalled(); - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadNext', anchorTransactionID: NEXT_ID})); - }); - - it('navigates previous to the live thread instead of creating a duplicate', () => { - renderNavigation(); - - press('prev-button'); - - expect(createTransactionThreadReport).not.toHaveBeenCalled(); - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'threadPrev', anchorTransactionID: PREV_ID})); - }); - }); - - describe('multi-transaction parent without a thread', () => { - beforeEach(() => { - mockState.transactionsCollection = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, - }; - mockState.reportActionsCollection = { - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rPrev`]: {actionPrev: makeIOUAction(PREV_ID, {reportID: 'rPrev'})}, - [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}rNext`]: {actionNext: makeIOUAction(NEXT_ID, {reportID: 'rNext'})}, - }; - mockState.reportsCollection = { - [`${ONYXKEYS.COLLECTION.REPORT}rPrev`]: {reportID: 'rPrev', transactionCount: 2}, - [`${ONYXKEYS.COLLECTION.REPORT}rNext`]: {reportID: 'rNext', transactionCount: 2}, - }; - }); - - it('creates a thread and navigates next, anchoring on the target transaction', () => { - renderNavigation(); - - press('next-button'); - - expect(createTransactionThreadReport).toHaveBeenCalled(); - // createTransactionThreadReport is mocked to return undefined, so the target falls back to the transaction's own reportID. - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rNext', anchorTransactionID: NEXT_ID})); - }); - - it('creates a thread and navigates previous, anchoring on the target transaction', () => { - renderNavigation(); - - press('prev-button'); - - expect(createTransactionThreadReport).toHaveBeenCalled(); - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'rPrev', anchorTransactionID: PREV_ID})); - }); - }); - - describe('snapshot-backed sibling descriptors', () => { - beforeEach(() => { - mockState.transactionsCollection = { - [`${ONYXKEYS.COLLECTION.TRANSACTION}${CURRENT_ID}`]: {transactionID: CURRENT_ID, reportID: 'rCur'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${PREV_ID}`]: {transactionID: PREV_ID, reportID: 'rPrev'}, - [`${ONYXKEYS.COLLECTION.TRANSACTION}${NEXT_ID}`]: {transactionID: NEXT_ID, reportID: 'rNext'}, - }; - // No parent report present -> not a one-transaction report, so resolution uses the descriptor. - mockState.siblingDescriptors = { - [PREV_ID]: {reportID: 'rPrev'}, - [NEXT_ID]: {reportID: 'rNext'}, - }; - }); - - it('navigates next to the descriptor-resolved reportID', () => { - jest.mocked(getReportIDToOpenForExpense).mockReturnValue('descNext'); - renderNavigation(); - - press('next-button'); - - expect(getReportIDToOpenForExpense).toHaveBeenCalled(); - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'descNext', anchorTransactionID: NEXT_ID})); - }); - - it('navigates previous to the descriptor-resolved reportID', () => { - jest.mocked(getReportIDToOpenForExpense).mockReturnValue('descPrev'); - renderNavigation(); - - press('prev-button'); - - expect(getReportIDToOpenForExpense).toHaveBeenCalled(); - expect(Navigation.setParams).toHaveBeenCalledWith(expect.objectContaining({reportID: 'descPrev', anchorTransactionID: PREV_ID})); - }); - }); - - it('does not render navigation when there are fewer than two transactions', () => { - mockState.transactionIDsList = [CURRENT_ID]; - - renderNavigation(); - - expect(screen.queryByTestId('next-button')).toBeNull(); - }); - - describe('clearing the carousel on unmount', () => { - const setFocusedRoute = (name: string) => { - mockGetRootState.mockReturnValue(makeRootState(name)); - }; - - // Unmounting onto one of these means we're still inside the expense-navigation flow, and a screen lower in - // the RHP stack may still depend on the carousel. In particular, opening the parent report from the - // subtitle link pushes an EXPENSE_REPORT / SEARCH_MONEY_REQUEST_REPORT RHP on top of the transaction - // thread, so backing out of an expense onto it must not wipe the underlying thread's carousel (#90366). - it.each([ - ['SEARCH_REPORT', SCREENS.RIGHT_MODAL.SEARCH_REPORT], - ['SEARCH_MONEY_REQUEST_REPORT', SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], - ['EXPENSE_REPORT', SCREENS.RIGHT_MODAL.EXPENSE_REPORT], - ['TRANSACTION_DUPLICATE.DYNAMIC_REVIEW', SCREENS.TRANSACTION_DUPLICATE.DYNAMIC_REVIEW], - ])('keeps the active transaction IDs when unmounting onto %s', (_label, screenName) => { - setFocusedRoute(screenName); - - renderNavigation().unmount(); - - expect(clearActiveTransactionIDs).not.toHaveBeenCalled(); - }); - - it('clears the active transaction IDs when unmounting onto an unrelated screen', () => { - setFocusedRoute(SCREENS.SEARCH.ROOT); - - renderNavigation().unmount(); - - expect(clearActiveTransactionIDs).toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/unit/hooks/useNavigateToTransactionThread.test.ts b/tests/unit/hooks/useNavigateToTransactionThread.test.ts deleted file mode 100644 index 7955d5a6f9e8..000000000000 --- a/tests/unit/hooks/useNavigateToTransactionThread.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {renderHook} from '@testing-library/react-native'; - -import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; - -import {setActiveTransactionIDs, shouldPreserveActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation'; -import Navigation from '@libs/Navigation/Navigation'; -import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; - -import type {Report} from '@src/types/onyx'; - -import createRandomReportAction from '../../utils/collections/reportActions'; -import {createExpenseReport} from '../../utils/collections/reports'; -import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; - -const TRANSACTION_ID = 'B2'; -const SIBLING_TRANSACTION_IDS = ['B1', 'B2']; -const THREAD_REPORT_ID = 'thread-1'; - -const report: Report = {...createExpenseReport(1), reportID: 'reportB'}; -// An IOU action that already has a thread, so the hook takes the short path to navigation. -const iouAction = {...createRandomReportAction(1), reportActionID: 'action1', childReportID: THREAD_REPORT_ID}; - -jest.mock('@libs/actions/TransactionThreadNavigation', () => ({ - setActiveTransactionIDs: jest.fn(() => Promise.resolve()), - shouldPreserveActiveTransactionIDs: jest.fn(() => false), -})); - -jest.mock('@libs/actions/Report', () => ({ - createTransactionThreadReport: jest.fn(), - setOptimisticTransactionThread: jest.fn(), -})); - -jest.mock('@libs/Navigation/Navigation', () => ({ - __esModule: true, - default: {getActiveRoute: jest.fn(() => '/search'), navigate: jest.fn()}, -})); - -jest.mock('@libs/ReportActionsUtils', () => ({getIOUActionForTransactionID: jest.fn()})); - -jest.mock('@components/WideRHPContextProvider', () => ({useWideRHPActions: jest.fn(() => ({markReportRHPWidth: jest.fn()}))})); - -// The personal details context is only forwarded to createTransactionThreadReport (mocked above), so an empty map is enough. -jest.mock('@components/OnyxListItemProvider', () => ({usePersonalDetails: jest.fn(() => ({}))})); - -jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({email: 'a@b.com', accountID: 1}))); -jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}])); - -const mockedGetIOUAction = jest.mocked(getIOUActionForTransactionID); -const mockedSetActiveTransactionIDs = jest.mocked(setActiveTransactionIDs); -const mockedShouldPreserve = jest.mocked(shouldPreserveActiveTransactionIDs); -const mockedNavigate = jest.mocked(Navigation.navigate); - -function callHook(overrides?: {shouldPreserveBroaderCarousel?: boolean}) { - const {result} = renderHook(() => useNavigateToTransactionThread()); - result.current({ - transactionID: TRANSACTION_ID, - reportActions: [iouAction], - report, - transaction: undefined, - siblingTransactionIDs: SIBLING_TRANSACTION_IDS, - ...overrides, - }); - return waitForBatchedUpdates(); -} - -/** - * Regression guard for https://github.com/Expensify/App/issues/98196: an expense row inside a report that was opened - * from a broader carousel (e.g. the Spend page) must not re-seed the carousel with just that report's expenses, which - * shrank the "x of y" counter and left the wrong list behind after navigating back. - */ -describe('useNavigateToTransactionThread', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockedGetIOUAction.mockReturnValue(iouAction); - mockedShouldPreserve.mockReturnValue(false); - mockedSetActiveTransactionIDs.mockReturnValue(Promise.resolve()); - }); - - it('seeds the carousel with the given siblings by default', async () => { - await callHook(); - - expect(mockedSetActiveTransactionIDs).toHaveBeenCalledWith(SIBLING_TRANSACTION_IDS); - expect(mockedShouldPreserve).not.toHaveBeenCalled(); - }); - - it('still seeds when preservation is requested but no broader carousel is active', async () => { - await callHook({shouldPreserveBroaderCarousel: true}); - - expect(mockedShouldPreserve).toHaveBeenCalledWith(SIBLING_TRANSACTION_IDS, TRANSACTION_ID); - expect(mockedSetActiveTransactionIDs).toHaveBeenCalledWith(SIBLING_TRANSACTION_IDS); - }); - - it('leaves a broader carousel untouched when preservation is requested', async () => { - mockedShouldPreserve.mockReturnValue(true); - - await callHook({shouldPreserveBroaderCarousel: true}); - - expect(mockedSetActiveTransactionIDs).not.toHaveBeenCalled(); - }); - - it('navigates to the thread whether or not the carousel was re-seeded', async () => { - mockedShouldPreserve.mockReturnValue(true); - - await callHook({shouldPreserveBroaderCarousel: true}); - - // Navigation used to be chained onto the seeding promise, so skipping the write must not skip the hop. - expect(mockedNavigate).toHaveBeenCalledTimes(1); - expect(mockedNavigate.mock.calls.at(0)?.at(0)).toContain(THREAD_REPORT_ID); - }); -}); diff --git a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts b/tests/unit/libs/actions/TransactionThreadNavigationTest.ts deleted file mode 100644 index 89771dc13a07..000000000000 --- a/tests/unit/libs/actions/TransactionThreadNavigationTest.ts +++ /dev/null @@ -1,135 +0,0 @@ -import {clearActiveTransactionIDs, setActiveTransactionIDs, shouldPreserveActiveTransactionIDs, shouldWriteActiveTransactionIDsForSearch} from '@libs/actions/TransactionThreadNavigation'; - -import ONYXKEYS from '@src/ONYXKEYS'; - -import Onyx from 'react-native-onyx'; - -import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; - -const SEARCH_HASH = 959171759; -const OTHER_SEARCH_HASH = 123456; - -// The Spend page's expense list, as the carousel was seeded with it when a row was pressed. -const SEEDED_IDS = ['A1', 'A2', 'A3']; - -// The Spend page holds one expense from report A and two from report B; report B owns only its own two. -const SPEND_PAGE_IDS = ['A1', 'B1', 'B2']; -const REPORT_B_IDS = ['B1', 'B2']; - -describe('shouldWriteActiveTransactionIDsForSearch', () => { - it('refreshes when the search gained one expense, so y grows by one', () => { - expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A0', ...SEEDED_IDS])).toBe(true); - }); - - it('refreshes when the search gained two expenses, so y grows by two', () => { - expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A0', 'A00', ...SEEDED_IDS])).toBe(true); - }); - - it('refreshes when the search lost an expense but still has two to navigate between', () => { - expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A1', 'A3'])).toBe(true); - }); - - it('refreshes when the same expenses are re-sorted, because the counter index follows the list order', () => { - expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A3', 'A2', 'A1'])).toBe(true); - }); - - it('does nothing when the carousel already matches the search', () => { - expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, [...SEEDED_IDS])).toBe(false); - }); - - it('leaves another search\u2019s carousel alone', () => { - expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, OTHER_SEARCH_HASH, SEARCH_HASH, ['A0', ...SEEDED_IDS])).toBe(false); - }); - - it('leaves a report-scoped carousel alone, since a drill-in clears the snapshot hash', () => { - expect(shouldWriteActiveTransactionIDsForSearch(['B1', 'B2'], undefined, SEARCH_HASH, ['A0', ...SEEDED_IDS])).toBe(false); - }); - - it('adopts the carousel when nothing is active, so it comes back after a clear', () => { - expect(shouldWriteActiveTransactionIDsForSearch(undefined, undefined, SEARCH_HASH, SEEDED_IDS)).toBe(true); - expect(shouldWriteActiveTransactionIDsForSearch([], undefined, SEARCH_HASH, SEEDED_IDS)).toBe(true); - }); - - it('does not adopt a carousel for a single expense, which is nothing to navigate between', () => { - expect(shouldWriteActiveTransactionIDsForSearch(undefined, undefined, SEARCH_HASH, ['A1'])).toBe(false); - }); - - it('re-seeds down to a single remaining expense rather than retiring the carousel', () => { - expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, ['A1'])).toBe(true); - }); - - it('restores the carousel once a shrunken search grows back to two expenses', () => { - expect(shouldWriteActiveTransactionIDsForSearch(['A1'], SEARCH_HASH, SEARCH_HASH, ['A1', 'A2'])).toBe(true); - }); - - // A merge deletes transactions through an unbatched Onyx.set, so the derived list momentarily empties. Writing that - // would leave nothing for the settled render to recover from. - it('ignores an empty search list, which is the shape a search takes mid-update', () => { - expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, SEARCH_HASH, SEARCH_HASH, [])).toBe(false); - }); - - it('leaves another search\u2019s carousel alone even when this search is empty', () => { - expect(shouldWriteActiveTransactionIDsForSearch(SEEDED_IDS, OTHER_SEARCH_HASH, SEARCH_HASH, [])).toBe(false); - }); -}); - -/** - * Regression guard for https://github.com/Expensify/App/issues/98196: opening an expense from Spend seeds the carousel - * with every expense in the search, but drilling into the owning report and tapping a row there re-seeded it with just - * that report's expenses. Navigating back then left the counter showing the report's total instead of the search's. - */ -describe('shouldPreserveActiveTransactionIDs', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(async () => { - await clearActiveTransactionIDs(); - await waitForBatchedUpdates(); - }); - - it('preserves a strictly broader carousel that contains the tapped expense', async () => { - await setActiveTransactionIDs(SPEND_PAGE_IDS, SEARCH_HASH); - - expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(true); - }); - - it('does not preserve when no carousel is active', () => { - expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(false); - }); - - it('does not preserve when the active carousel is missing the tapped expense', async () => { - await setActiveTransactionIDs(['C1', 'C2', 'C3'], SEARCH_HASH); - - expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(false); - }); - - it('does not preserve when the active carousel does not cover every sibling', async () => { - // Broader by length, but B2 is absent, so prev/next would skip an expense the report shows. - await setActiveTransactionIDs(['A1', 'B1', 'C1'], SEARCH_HASH); - - expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B1')).toBe(false); - }); - - it('does not preserve an equally sized carousel, so a re-sorted report re-seeds in its new visual order', async () => { - await setActiveTransactionIDs(['B2', 'B1'], SEARCH_HASH); - - expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B1')).toBe(false); - }); - - it('does not preserve a narrower carousel', async () => { - await setActiveTransactionIDs(['B1'], SEARCH_HASH); - - expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B1')).toBe(false); - }); - - it('does not preserve a carousel that only exists in persisted Onyx state', async () => { - // The Onyx key is persisted, so on a fresh load it can hold a carousel from an earlier session or a search that - // no longer matches the screen. Preserving that made a drill-in show a stale total ("4 of 6" in a 2-expense - // report), so only a list seeded during this session counts. - await Onyx.set(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, SPEND_PAGE_IDS); - await waitForBatchedUpdates(); - - expect(shouldPreserveActiveTransactionIDs(REPORT_B_IDS, 'B2')).toBe(false); - }); -}); From 7b1a21f2ea445837f946014a671184d0500420e5 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 27 Aug 2026 15:17:00 +0000 Subject: [PATCH 457/466] Update version to 9.4.62-4 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 2 +- ios/NotificationServiceExtension/Info.plist | 2 +- ios/ShareViewController/Info.plist | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 694cff1b3b31..d1f5bc5908bb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046203 - versionName "9.4.62-3" + versionCode 1009046204 + versionName "9.4.62-4" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index fccc1f7f2c89..cc56e98d794e 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.62.3 + 9.4.62.4 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 34a2016f5592..cc6e50177d2a 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.62 CFBundleVersion - 9.4.62.3 + 9.4.62.4 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 94a91731205b..0ab32c2176c4 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.4.62 CFBundleVersion - 9.4.62.3 + 9.4.62.4 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index bd2b22c852bf..3b603c1e44b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.62-3", + "version": "9.4.62-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.62-3", + "version": "9.4.62-4", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index dd8bd5513d46..338cc72cec81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.62-3", + "version": "9.4.62-4", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From b9d71078b163c80b188907732f6fb28728365249 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 27 Aug 2026 15:17:00 +0000 Subject: [PATCH 458/466] Update Mobile-Expensify submodule version to 9.4.62-4 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index fa97ddaed4f2..9897810db002 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit fa97ddaed4f2e5b8d8a915c84453ef667960a48a +Subproject commit 9897810db002a993bc94ee1a14b7b9e3ccaa0453 From c650e8c1b4867f9f37ec3107da113f922a65907e Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:03:53 -1000 Subject: [PATCH 459/466] Update Set-distance-rates.md --- .../workspaces/Set-distance-rates.md | 135 ++++++++++-------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index 47c2290dbae3..147a9e8852c2 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -1,118 +1,129 @@ --- title: Set Distance Rates -description: Set and manage distance rates for distance reimbursement in your Expensify workspace. +description: Set and manage rates for distance reimbursement in your Expensify workspace. keywords: [New Expensify, distance rates, mileage reimbursement, enable distance rates, workspace settings, bulk edit rates, auto-update government rate, IRS standard rate, mileage rates, distance bands] -internalScope: Audience is Workspace Admins. Covers configuring and auto-updating Distance rates, does not cover creating distance expenses. +internalScope: Audience is workspace admins. Covers configuring and auto-updating distance rates, does not cover creating distance expenses. --- # Set Distance Rates -Distance rates determine how much members are reimbursed for distance-based travel. Workspace Admins can create multiple rates, control when each rate applies, configure taxes, and automatically update rates when supported government reimbursement rates change. +Distance rates determine how much members are reimbursed for distance-based travel. Workspace admins can create multiple rates, control when each applies, configure the associated taxes, and automatically update rates when supported governments publish new ones. --- -## Who can set Distance rates +## Who can set distance rates -Workspace Admins can configure Distance rates for a workspace. +Workspace admins can configure distance rates for a workspace. -Automatic updates are available for Collect workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. +Automatic updates are available for Control workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. --- ## How to enable Distance rates for a workspace -To activate Distance rates for a workspace: - -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [workspace name]**. -2. Click **More Features**. +1. In the navigation tabs (on the left on web, and at the bottom on mobile), go to **Workspaces > [workspace name]**. +2. Select **More features**. 3. Toggle **Distance rates** to enable the setting. -Once enabled, **Distance Rates** will appear in the left menu. +Once enabled, **Distance rates** will appear in the workspace menu. --- -## How to add Distance rates +## How to add distance rates -1. Go to **Workspaces > [workspace Name] > Distance Rates**. -2. Click **Add Rate** in the top-right corner. -3. Enter the rate value (e.g., $0.585 per mile or km). -4. (Optional) Set a **Start date** and **End date** to define the period the rate applies to. Leave these blank if the rate has no time limit. -5. Click **Save**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Distance rates**. +3. Select **Add rate**. +4. Enter the display name and rate value (e.g., $0.585 per mile or km). +5. (Optional) Set a **Start date** and **End date** to define the period the rate applies to. Leave these blank if the rate has no time limit. +6. Click **Save**. -When a rate has a **Start date** or **End date**, Expensify uses it to apply the correct rate based on the expense date. See [How Expensify selects a rate when multiple rates exist](#How-Expensify-selects-a-Distance-rate-based-on-the-expense-date) below. +When a rate has a **Start date** or **End date**, Expensify automatically applies the correct rate based on the expense date. See [How Expensify selects a rate when multiple rates exist](#How-Expensify-selects-a-Distance-rate-based-on-the-expense-date) below. --- -## How to change a Distance rate, effective dates, or tax settings +## How to track tax on distance rates + +To track taxes on distance rates, **Taxes** must be enabled on the workspace.[Learn how to track taxes](/articles/new-expensify/workspaces/Track-Taxes). + +To enable **Track tax** for distance rates: + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Distance rates**. +3. Select **Settings**. +4. Enable **Track tax** -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [workspace name]**. -2. Click **Distance rates**. -3. Click on the rate you want to edit. +Once **Track tax** is enabled, you can assign a tax rate and tax reclaimable amount to an existing distance rate. The selected tax rate is automatically applied when the distance rate is used. + +**Note:** You can't assign a tax rate when creating a distance rate. Create the distance rate first, then edit it to configure the tax rate and tax reclaimable amount. + +## How to change a distance rate, effective dates, or tax rate + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Distance rates**. +3. Select the rate you want to edit. 4. Update any of the available settings: - - Name - - Rate + - **Name** + - **Rate** - **Start date** - **End date** - - Tax rate - - Tax reclaimable amount -5. Click **Save**. - -**Note:** If **Taxes** is enabled on the Workspace, each Distance rate can be associated with a tax rate and a tax reclaimable amount. The selected tax rate is automatically applied when the Distance rate is used on an expense. To enable tax tracking on a Workspace, [learn how to track Taxes](/articles/new-expensify/workspaces/Track-Taxes). + - **Tax rate** (if enabled) + - **Tax reclaimable** (if enabled) +5. Select **Save**. --- -## How to remove Distance rates +## How to disable or delete distance rates -To prevent members from using a Distance rate, you can either disable it or delete it. +To prevent members from using a distance rate, you can either disable it or delete it. -- Disabled rates remain in the Workspace but cannot be selected on new Distance expenses. +- Disabled rates remain in the workspace but cannot be selected on new distance expenses. - Deleted rates are permanently removed. -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [workspace name]**. -2. Click **Distance rates**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Distance rates**. 3. Select each rate you want to disable or delete. -4. Click **Selected** +4. Choose **Selected**. 5. Choose **Disable rates** or **Delete rates**. --- -## How to automatically update Distance rates when government rates change +## How to automatically update distance rates when government rates change -Expensify can automatically update Distance rates in your Workspace when the standard government reimbursement rate changes, such as the IRS standard mileage rate in the United States or equivalent rates in supported countries. +Expensify can automatically update distance rates in your workspace when the standard government reimbursement rate changes, such as the IRS standard mileage rate in the United States or equivalent rates in supported countries. -When **Auto-update government rate** is enabled, Expensify adds a new effective-dated Distance rate when a supported government publishes a new rate. This means admins don't need to manually update the standard rate each time it changes. +When **Auto-update government rate** is enabled, Expensify adds a new effective-dated distance rate when a supported government publishes a new rate. This means admins don't need to manually update the standard rate each time it changes. Automatic updates are available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. To enable automatic updates: -1. In the navigation tabs (on the left on web, and at the bottom on mobile), click **Workspaces > [workspace name]**. +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. 2. Select **Distance rates**. 3. Select **Settings**. 4. Enable **Auto-update government rate**. --- -## What happens when Expensify automatically updates a Distance rate +## What happens when Expensify automatically updates a distance rate -When Auto-update government rate is enabled and a new supported government rate takes effect: +When **Auto-update government rate** is enabled and a new supported government rate takes effect: - Expensify adds a new rate with a **Start date** matching the date the new rate takes effect. - Previous rates remain available so historical expenses can retain the rate that applied on their expense date. -- If the previous government rate does not have an End date, Expensify sets its End date to the day before the new rate takes effect. -- Workspace Admins are notified of the update in the workspace **#admins** room and in **Home > Announcements**. +- If the previous government rate does not have an **End date**, Expensify sets its **End date** to the day before the new rate takes effect. -Workspace Admins can still edit or disable an automatically added rate. +Workspace admins can still edit or disable an automatically added rate. ![Distance rates showing automatically created government rates]({{site.url}}/assets/images/Distance_rates_automatic_update.png){:width="100%"} --- -## How Expensify selects a Distance rate based on the expense date +## How Expensify selects a distance rate based on the expense date -When a workspace has more than one Distance rate, Expensify uses effective dates to determine which rate applies to an expense. This allows a workspace to keep multiple rates active, such as a previous year's rate and a new rate. +When a workspace has more than one distance rate, Expensify uses effective dates to determine which rate applies to an expense. This allows a workspace to keep multiple rates active, such as a previous year's rate and a new rate. - - When a member creates or edits a Distance expense, Expensify selects the rate whose **Start date** and **End date** include the expense date. + - When a member creates a distance expense, Expensify selects the rate whose **Start date** and **End date** include the expense date, using the most narrow and most recent date range if multiple apply, - If a member manually selects a rate that isn't valid for the expense date, the expense shows a violation indicating that the rate doesn't match the selected date. - Setting effective dates is optional. Rates without a **Start date** or **End date** continue to apply without an effective-date restriction. @@ -120,34 +131,34 @@ When a workspace has more than one Distance rate, Expensify uses effective dates # FAQ -## Can I create multiple Distance rates? +## Can I create multiple distance rates? -Yes. A Workspace can have multiple active Distance rates. Members can select from the available rates when creating a Distance expense. +Yes. A workspace can have multiple active distance rates. Members can select from the available rates when creating a distance expense. -## Can I disable a Distance rate without deleting it? +## Can I disable a distance rate without deleting it? -Yes. Disabled Distance rates remain in the Workspace but cannot be selected on new Distance expenses. You can re-enable the rate at any time. +Yes. Disabled distance rates remain in the workspace but cannot be selected on new distance expenses. You can re-enable the rate at any time. -## Can I apply taxes to Distance rates? +## Can I apply taxes to distance rates? -Yes. If **Taxes** is enabled on the Workspace, you can associate a tax rate with each Distance rate. When a member selects that Distance rate on an expense, the associated tax rate is applied automatically. +Yes. **Taxes** must be enabled on the workspace, and **Track tax** must be enabled for **Distance rates**. You can then edit an existing distance rate to assign a tax rate and tax reclaimable amount. The selected tax rate is automatically applied when the distance rate is used. -## What is the Tax reclaimable amount? +## What does Tax reclaimable mean for a distance rate? -The **Tax reclaimable** amount represents the portion of the Distance rate that can be reclaimed as tax. It is entered as a fixed monetary value for the Distance rate. +**Tax reclaimable** represents the portion of the distance rate that can be reclaimed as tax. It is entered as a fixed monetary value for the distance rate. -## What happens if I delete a Distance rate? +## What happens if I delete a distance rate? -Deleted Distance rates are permanently removed from the Workspace and cannot be restored. If you want to prevent members from using a rate without removing it permanently, disable the rate instead. +Deleted distance rates are permanently removed from the workspace and cannot be restored. If you want to prevent members from using a rate without removing it permanently, disable the rate instead. -## Can I change the reimbursement rate for existing Distance expenses? +## Can I change the reimbursement rate for existing distance expenses? -No. Updating a Distance rate only affects future Distance expenses. Existing expenses keep the rate that was applied when the expense was created. +No. Updating a distance rate only affects future distance expenses. Existing expenses keep the rate that was applied when the expense was created. -## Do I need at least one Distance rate? +## Do I need at least one distance rate? -Yes. When **Distance Rates** is enabled, the Workspace must always have at least one active Distance rate. +Yes. When **Distance rates** is enabled, the workspace must always have at least one active distance rate. ## Why isn't Auto-update government rate turned on for my workspace? -Auto-update government rate is turned on by default only for new workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. +**Auto-update government rate** is turned on by default only for new workspaces. Existing workspaces are opted out by default, so you'll need to turn it on manually. It's also only available for workspaces with a default currency of USD, CAD, GBP, AUD, or NZD. From 284f48c7fb8e63e36f8c09392cd529cee87be466 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:04:40 -1000 Subject: [PATCH 460/466] Delete docs/assets/images/Distance_rates_automatic_update.png --- .../images/Distance_rates_automatic_update.png | Bin 502768 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/assets/images/Distance_rates_automatic_update.png diff --git a/docs/assets/images/Distance_rates_automatic_update.png b/docs/assets/images/Distance_rates_automatic_update.png deleted file mode 100644 index e4e07b6f767cf7cde6a615548d8d2fa19802416a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 502768 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 z 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

vfwTeDHi)8 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 z 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

?tx2f0ag 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

W

&N+7 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 O%!rsh_1F~MrvCxsdj_Zg From b7a9b29708cfe0409c7cbeb762dd4f2fcb7cb280 Mon Sep 17 00:00:00 2001 From: Stephanie Elliott <31225194+stephanieelliott@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:04:56 -1000 Subject: [PATCH 461/466] Add files via upload --- .../images/Distance_rates_automatic_update.png | Bin 0 -> 484212 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/assets/images/Distance_rates_automatic_update.png diff --git a/docs/assets/images/Distance_rates_automatic_update.png b/docs/assets/images/Distance_rates_automatic_update.png new file mode 100644 index 0000000000000000000000000000000000000000..a39d7586d5eb927baa52b7fa86e0129fbcac4107 GIT binary patch literal 484212 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≻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 z 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

u&K1yC(S1hYxOJ9B#$Op+UiIm8tDY6`kK>Z7 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%0Ah0-|`oGrF35= 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 literal 0 HcmV?d00001 From 00ac6c7989ceb397d87826dd0353fa7908be7d71 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Fri, 28 Aug 2026 02:26:23 +0500 Subject: [PATCH 462/466] removed extra comments --- src/components/Search/SearchAutocompleteList.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 5a5d17d00c36..3840a36d68c2 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -63,6 +63,7 @@ type GetAdditionalSectionsCallback = (options: Options, sectionIndex: number) => type SearchAutocompleteListProps = { /** Value of TextInput */ autocompleteQueryValue: string; + /** Immediate (non-debounced) query from the input for UI-only behavior */ inputQueryValue?: string; @@ -320,10 +321,6 @@ function SearchAutocompleteList({ } }; - // The "reset focus when query changes" effect lives further down (after firstRecentReportFlatIndex/Key and - // normalizedReferenceText are computed) because it now needs those values to decide, synchronously, whether - // the debounced query has already settled for the freshly typed text. See the comment there for why. - // Track external text input focus to prevent list items from stealing focus while typing useEffect(() => { if (!textInputRef?.current) { From 68ca97079294211dd20723d7d38fd7a5847b3aa5 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 27 Aug 2026 21:35:29 +0000 Subject: [PATCH 463/466] Auto-tighten eslint-seatbelt baseline --- config/eslint/eslint.seatbelt.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index b60605ec90a3..ed6e892b6627 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -658,10 +658,10 @@ "../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/ReportPrimaryActionUtils.ts" "no-restricted-imports" 1 "../../src/libs/ReportTitleUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 5 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 3 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/getPolicy" 27 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 8 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 34 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 33 "../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 16 "../../src/libs/SearchAutocompleteUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/libs/SearchQueryUtils.ts" "@typescript-eslint/no-deprecated/deprecatedGetReportName" 1 From eaf47475a3a5111d6ef6741af6e1adfd504d4dc2 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 27 Aug 2026 21:50:16 +0000 Subject: [PATCH 464/466] Update version to 9.4.63-0 --- android/app/build.gradle | 4 ++-- ios/NewExpensify/Info.plist | 4 ++-- ios/NotificationServiceExtension/Info.plist | 4 ++-- ios/ShareViewController/Info.plist | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index d1f5bc5908bb..d696f795edea 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009046204 - versionName "9.4.62-4" + versionCode 1009046300 + versionName "9.4.63-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index cc56e98d794e..6858ef3e0c73 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.4.62 + 9.4.63 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.62.4 + 9.4.63.0 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index cc6e50177d2a..b0f75221f069 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.62 + 9.4.63 CFBundleVersion - 9.4.62.4 + 9.4.63.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 0ab32c2176c4..d2fe52c28c7a 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.62 + 9.4.63 CFBundleVersion - 9.4.62.4 + 9.4.63.0 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 3b603c1e44b3..41eb41342f16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.62-4", + "version": "9.4.63-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.62-4", + "version": "9.4.63-0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 338cc72cec81..071d9c16c201 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.62-4", + "version": "9.4.63-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", From 039798727af51a89544966fc5c83475d9a118615 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Thu, 27 Aug 2026 21:50:16 +0000 Subject: [PATCH 465/466] Update Mobile-Expensify submodule version to 9.4.63-0 --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 9897810db002..9586fa869bca 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 9897810db002a993bc94ee1a14b7b9e3ccaa0453 +Subproject commit 9586fa869bca239f26d7dbf3d2da5d3012be193d From fa7140b85a7904dda535bd1b02db77182c9c0423 Mon Sep 17 00:00:00 2001 From: OSBotify Date: Fri, 28 Aug 2026 00:02:39 +0000 Subject: [PATCH 466/466] Auto-tighten eslint-seatbelt baseline --- config/eslint/eslint.seatbelt.tsv | 3 --- 1 file changed, 3 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index ed6e892b6627..b02d18dfd999 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1103,9 +1103,7 @@ "../../src/pages/settings/Wallet/InternationalDepositAccount/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Wallet/PaymentMethodList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/pages/settings/Wallet/PersonalCardDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/index.native.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage/useFixPersonalCardConnection.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/pages/settings/Wallet/PersonalCards/steps/PlaidConnectionStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1234,7 +1232,6 @@ "../../src/pages/workspace/companyCards/WorkspaceVerifyWorkAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/companyCards/WorkspaceVerifyWorkAccountPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 -"../../src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "react-hooks/preserve-manual-memoization" 2 "../../src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx" "react-hooks/set-state-in-effect" 1