From cab0ba11d0ff213b127e3438d783ee1a65c88542 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Wed, 15 Jul 2026 16:15:59 +0800 Subject: [PATCH 001/129] change clear to reset --- src/CONST/index.ts | 2 +- .../SearchFiltersBarNarrow.tsx | 6 +++--- .../SearchPageHeader/SearchFiltersBarWide.tsx | 6 +++--- ...Button.tsx => SearchFiltersResetButton.tsx} | 18 +++++++++--------- .../SearchPageHeader/useSearchFiltersBar.tsx | 6 +++--- src/styles/index.ts | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) rename src/components/Search/SearchPageHeader/{SearchFiltersClearButton.tsx => SearchFiltersResetButton.tsx} (66%) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7d37ca62ac30..b28aa9920f4c 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8307,7 +8307,7 @@ const CONST = { TYPE_MENU_ITEM: 'Search-TypeMenuItem', SAVED_SEARCH_MENU_ITEM: 'Search-SavedSearchMenuItem', SAVE_VIEW_BUTTON: 'Search-SaveViewButton', - CLEAR_FILTERS_BUTTON: 'Search-ClearFiltersButton', + RESET_FILTERS_BUTTON: 'Search-ResetFiltersButton', ACTION_CELL_VIEW: 'Search-ActionCellView', ACTION_CELL_PAY: 'Search-ActionCellPay', ACTION_CELL_ACTION: 'Search-ActionCellAction', diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx index df93611620f2..c7722c3da9b7 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx @@ -13,7 +13,7 @@ import {FlatList} from 'react-native'; import type {FilterItem} from './useSearchFiltersBar'; import SearchFilterBar from './SearchFilterBar'; -import SearchFiltersClearButton from './SearchFiltersClearButton'; +import SearchFiltersResetButton from './SearchFiltersResetButton'; import useSearchFiltersBar from './useSearchFiltersBar'; type SearchFiltersBarNarrowProps = { @@ -23,7 +23,7 @@ type SearchFiltersBarNarrowProps = { function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) { const styles = useThemeStyles(); const scrollRef = useRef>(null); - const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters} = useSearchFiltersBar(queryJSON); + const {filters, hasErrors, shouldShowFiltersBarLoading, resetFilters} = useSearchFiltersBar(queryJSON); const adjustScroll = (info: {distanceFromEnd: number}) => { // Workaround for a known React Native bug on Android (https://github.com/facebook/react-native/issues/27504): @@ -69,7 +69,7 @@ function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) { renderItem={renderFilterItem} onEndReached={adjustScroll} onEndReachedThreshold={0.75} - ListFooterComponent={filters.length > 0 ? : undefined} + ListFooterComponent={filters.length > 0 ? : undefined} /> ); } diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx index 111481c6f3e0..1c3e850d87c3 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBarWide.tsx @@ -6,7 +6,7 @@ import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan import React from 'react'; import SearchFilterBar from './SearchFilterBar'; -import SearchFiltersClearButton from './SearchFiltersClearButton'; +import SearchFiltersResetButton from './SearchFiltersResetButton'; import useSearchFiltersBar from './useSearchFiltersBar'; type SearchFiltersBarWideProps = { @@ -14,7 +14,7 @@ type SearchFiltersBarWideProps = { }; function SearchFiltersBarWide({queryJSON}: SearchFiltersBarWideProps) { - const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters} = useSearchFiltersBar(queryJSON); + const {filters, hasErrors, shouldShowFiltersBarLoading, resetFilters} = useSearchFiltersBar(queryJSON); if (hasErrors) { return null; @@ -41,7 +41,7 @@ function SearchFiltersBarWide({queryJSON}: SearchFiltersBarWideProps) { item={item} /> ))} - {filters.length > 0 && } + {filters.length > 0 && } ); } diff --git a/src/components/Search/SearchPageHeader/SearchFiltersClearButton.tsx b/src/components/Search/SearchPageHeader/SearchFiltersResetButton.tsx similarity index 66% rename from src/components/Search/SearchPageHeader/SearchFiltersClearButton.tsx rename to src/components/Search/SearchPageHeader/SearchFiltersResetButton.tsx index 6376bae4c5a2..6f8414f201cc 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersClearButton.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersResetButton.tsx @@ -11,32 +11,32 @@ import CONST from '@src/CONST'; import React from 'react'; -type SearchFiltersClearButtonProps = { +type SearchFiltersResetButtonProps = { onPress: () => void; }; -function SearchFiltersClearButton({onPress}: SearchFiltersClearButtonProps) { +function SearchFiltersResetButton({onPress}: SearchFiltersResetButtonProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['RotateLeft']); return ( - {translate('common.clear')} + {translate('common.reset')} ); } -export default SearchFiltersClearButton; +export default SearchFiltersResetButton; diff --git a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx index 00cb525f14c2..c42036da01fa 100644 --- a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx @@ -41,7 +41,7 @@ type UseSearchFiltersBarResult = { filters: Array; hasErrors: boolean; shouldShowFiltersBarLoading: boolean; - clearFilters: () => void; + resetFilters: () => void; }; type FilterPopupProps = { @@ -197,7 +197,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes }), ); - const clearFilters = () => { + const resetFilters = () => { setFilterQueryParams(getAdvancedFiltersToReset(searchAdvancedFiltersForm ?? {})); setSearchContext(false); }; @@ -206,7 +206,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON): UseSearchFiltersBarRes filters, hasErrors: Object.keys(currentSearchResults?.errors ?? {}).length > 0 && !isOffline, shouldShowFiltersBarLoading, - clearFilters, + resetFilters, }; } diff --git a/src/styles/index.ts b/src/styles/index.ts index 167081fda20a..c59697594d85 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5202,7 +5202,7 @@ const staticStyles = (theme: ThemeColors) => alignSelf: 'flex-start', }, - searchFiltersClearButton: { + searchFiltersResetButton: { flexDirection: 'row', gap: 4, alignItems: 'center', From 6ed5a0a2d155266728829547f674043a0d056fda Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Wed, 15 Jul 2026 21:59:56 +0800 Subject: [PATCH 002/129] keyed the saved search with an ID --- src/CONST/index.ts | 1 + .../MoneyReportHeaderSecondaryActions.tsx | 2 +- .../PayPrimaryAction.tsx | 2 +- .../SubmitPrimaryAction.tsx | 2 +- .../Search/SearchSelectionFooter.tsx | 4 +- src/components/Search/index.tsx | 11 +++++- src/hooks/useDeleteSavedSearch.tsx | 11 +++--- src/hooks/useLifecycleActions.tsx | 2 +- src/hooks/useSearchPageSetup.ts | 2 +- src/hooks/useSearchShouldCalculateTotals.ts | 8 ++-- src/hooks/useSelectionModePayment.ts | 2 +- src/hooks/useShareSavedSearch.ts | 10 ++--- src/libs/API/parameters/DeleteSavedSearch.ts | 2 +- src/libs/API/parameters/SaveSearch.ts | 1 + src/libs/SearchUIUtils.ts | 13 +++++-- src/libs/actions/Search.ts | 19 +++++----- src/pages/ReportSubmitToContent.tsx | 2 +- src/pages/Search/SavedSearchList.tsx | 34 +++++++++-------- src/pages/Search/SearchPageWide.tsx | 2 +- src/pages/Search/SearchTypeMenuNarrow.tsx | 38 ++++++++++--------- src/pages/Search/SearchTypeMenuWide.tsx | 7 +--- src/types/onyx/SaveSearch.ts | 2 +- .../useSearchShouldCalculateTotals.test.ts | 14 +++---- 23 files changed, 104 insertions(+), 87 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index b28aa9920f4c..a59a76f88519 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7386,6 +7386,7 @@ const CONST = { TOP_MERCHANTS: 'topMerchants', SPEND_OVER_TIME: 'spendOverTime', }, + SAVED_SEARCH_PREFIX: 'savedSearch_', GROUP_PREFIX: 'group_', ANIMATION: { FADE_DURATION: 200, diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 497a3debc167..1e6991be8bdd 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -166,7 +166,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const isInvoiceReport = isInvoiceReportUtil(moneyRequestReport); const isAnyTransactionOnHold = hasHeldExpensesReportUtils(allTransactions); diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 81b9d5e0f309..adf1d65123f5 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -119,7 +119,7 @@ function PayPrimaryAction({reportID, chatReportID}: PayPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const {openHoldMenu} = useMoneyReportHeaderModals(); diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index dfa555acbf0a..a12ea6a0266e 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -110,7 +110,7 @@ function SubmitPrimaryActionContent({reportID}: SubmitPrimaryActionProps) { const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const handleSubmit = () => { if (!moneyRequestReport || shouldBlockSubmit) { diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 7c40a8c30c0a..c1c139c986a8 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -22,8 +22,8 @@ type SearchSelectionFooterProps = { function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); - const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); - const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true, areAllMatchingItemsSelected); + const {currentSearchKey} = useSearchQueryContext(); + const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); const metadata = searchResults?.search; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d827d2157fa2..b4144fcee396 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -47,6 +47,7 @@ import { isTransactionListItemType, isTransactionReportGroupListItemType, isTransactionSearchType, + searchKeyToSavedSearchID, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, } from '@libs/SearchUIUtils'; @@ -180,13 +181,19 @@ function Search({ const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0, areAllMatchingItemsSelected); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, offset === 0, areAllMatchingItemsSelected); const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); const searchListRef = useRef | null>(null); - const savedSearchSelector = useCallback((searches: OnyxEntry) => searches?.[hash], [hash]); + const savedSearchSelector = useCallback( + (searches: OnyxEntry) => { + const savedSearchID = searchKeyToSavedSearchID(currentSearchKey); + return savedSearchID ? searches?.[savedSearchID] : undefined; + }, + [currentSearchKey], + ); const [savedSearch] = useOnyx(ONYXKEYS.SAVED_SEARCHES, { selector: savedSearchSelector, }); diff --git a/src/hooks/useDeleteSavedSearch.tsx b/src/hooks/useDeleteSavedSearch.tsx index 3087ed51a92a..9a757fb0616d 100644 --- a/src/hooks/useDeleteSavedSearch.tsx +++ b/src/hooks/useDeleteSavedSearch.tsx @@ -4,6 +4,7 @@ import {useSearchQueryContext} from '@components/Search/SearchContext'; import {deleteSavedSearch} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {searchKeyToSavedSearchID} from '@libs/SearchUIUtils'; import ROUTES from '@src/ROUTES'; @@ -14,11 +15,11 @@ import useLocalize from './useLocalize'; export default function useDeleteSavedSearch() { const {translate} = useLocalize(); - const {currentSearchHash} = useSearchQueryContext(); + const {currentSearchKey} = useSearchQueryContext(); const {showConfirmModal} = useConfirmModal(); const handleDeleteSavedSearch = useCallback( - (hash: number) => { + (savedSearchID: string) => { showConfirmModal({ title: translate('search.deleteSavedSearch'), prompt: translate('search.deleteSavedSearchConfirm'), @@ -29,9 +30,9 @@ export default function useDeleteSavedSearch() { if (result.action !== ModalActions.CONFIRM) { return; } - deleteSavedSearch(hash); + deleteSavedSearch(savedSearchID); - if (hash === currentSearchHash) { + if (savedSearchID === searchKeyToSavedSearchID(currentSearchKey)) { Navigation.navigate( ROUTES.SEARCH_ROOT.getRoute({ query: buildCannedSearchQuery(), @@ -40,7 +41,7 @@ export default function useDeleteSavedSearch() { } }); }, - [showConfirmModal, translate, currentSearchHash], + [showConfirmModal, translate, currentSearchKey], ); return {showDeleteModal: handleDeleteSavedSearch}; diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 14c15b99cca0..dab5e9fde626 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -122,7 +122,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, const {currentSearchResults} = useSearchResultsContext(); const {selectedTransactionIDs} = useSearchSelectionContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'ThumbsUp', 'CircularArrowBackwards', 'Clear', 'MoneyBag']); diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index c57f4d247065..227c4d6cfad6 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -34,7 +34,7 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { const {currentSearchKey} = useSearchQueryContext(); const hash = queryJSON?.hash; - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); // Derived primitives so effects do not depend on the whole snapshot object (new reference every // Onyx merge) while exhaustive-deps still sees every transition that matters for firing search(). diff --git a/src/hooks/useSearchShouldCalculateTotals.ts b/src/hooks/useSearchShouldCalculateTotals.ts index bfbac7546488..623621fe024e 100644 --- a/src/hooks/useSearchShouldCalculateTotals.ts +++ b/src/hooks/useSearchShouldCalculateTotals.ts @@ -1,3 +1,4 @@ +import {searchKeyToSavedSearchID} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -7,7 +8,7 @@ import {useMemo} from 'react'; import useOnyx from './useOnyx'; -function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, searchHash: number | undefined, enabled: boolean, areAllMatchingItemsSelected = false) { +function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, enabled: boolean, areAllMatchingItemsSelected = false) { const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const shouldCalculateTotals = useMemo(() => { @@ -43,10 +44,11 @@ function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, search ]; const isSuggestedSearchWithTotals = eligibleSearchKeys.includes(searchKey); - const isSavedSearch = searchHash !== undefined && savedSearches && !!savedSearches[searchHash]; + const savedSearchID = searchKeyToSavedSearchID(searchKey); + const isSavedSearch = savedSearchID !== undefined && savedSearches && !!savedSearches[savedSearchID]; return isSuggestedSearchWithTotals || isSavedSearch; - }, [enabled, savedSearches, searchKey, searchHash, areAllMatchingItemsSelected]); + }, [enabled, savedSearches, searchKey, areAllMatchingItemsSelected]); return shouldCalculateTotals ?? false; } diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 8c627f815c1b..30258b829810 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -88,7 +88,7 @@ function useSelectionModePayment({ const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(moneyRequestReport?.ownerAccountID)}); diff --git a/src/hooks/useShareSavedSearch.ts b/src/hooks/useShareSavedSearch.ts index 733c5a1d1714..d954a3e32392 100644 --- a/src/hooks/useShareSavedSearch.ts +++ b/src/hooks/useShareSavedSearch.ts @@ -14,7 +14,7 @@ const MENU_CLOSE_DELAY_MS = 800; function useShareSavedSearch() { const {environmentURL} = useEnvironment(); - const [copiedHash, setCopiedHash] = useState(null); + const [copiedID, setCopiedID] = useState(null); const timeoutRef = useRef | null>(null); useEffect(() => { @@ -23,21 +23,21 @@ function useShareSavedSearch() { }; }, []); - const handleShare = (itemHash: number, itemQuery: string) => { + const handleShare = (itemID: string, itemQuery: string) => { const url = `${environmentURL}/${ROUTES.SEARCH_ROOT.getRoute({query: itemQuery})}`; Clipboard.setString(url); - setCopiedHash(itemHash); + setCopiedID(itemID); if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { - setCopiedHash((prev) => (prev === itemHash ? null : prev)); + setCopiedID((prev) => (prev === itemID ? null : prev)); timeoutRef.current = null; }, SHARE_FEEDBACK_DURATION_MS); }; - return {copiedHash, handleShare}; + return {copiedID, handleShare}; } export {MENU_CLOSE_DELAY_MS}; diff --git a/src/libs/API/parameters/DeleteSavedSearch.ts b/src/libs/API/parameters/DeleteSavedSearch.ts index 23b20204bcd2..f0f4b4a02138 100644 --- a/src/libs/API/parameters/DeleteSavedSearch.ts +++ b/src/libs/API/parameters/DeleteSavedSearch.ts @@ -1,5 +1,5 @@ type DeleteSavedSearchParams = { - hash: number; + savedSearchID: string; }; export default DeleteSavedSearchParams; diff --git a/src/libs/API/parameters/SaveSearch.ts b/src/libs/API/parameters/SaveSearch.ts index 9dd3416320c7..ef3c12972b09 100644 --- a/src/libs/API/parameters/SaveSearch.ts +++ b/src/libs/API/parameters/SaveSearch.ts @@ -2,6 +2,7 @@ import type {SearchQueryString} from '@components/Search/types'; type SaveSearchParams = { jsonQuery: SearchQueryString; + savedSearchID: string; newName?: string; }; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 4662b5aae58f..b368240ea4cb 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -521,7 +521,7 @@ type ViolationKey = `${typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${strin type SearchGroupKey = `${typeof CONST.SEARCH.GROUP_PREFIX}${string}`; -type SearchKey = ValueOf; +type SearchKey = ValueOf | `${typeof CONST.SEARCH.SAVED_SEARCH_PREFIX}${string}`; type SavedSearchMenuItem = MenuItemWithLink & { key: string; @@ -4509,10 +4509,10 @@ type ShareProps = { function getOverflowMenu( icons: OverflowMenuIconsType, itemName: string, - hash: number, + savedSearchID: string, inputQuery: string, translate: LocalizedTranslate, - showDeleteModal: (hash: number) => void, + showDeleteModal: (savedSearchID: string) => void, isMobileMenu?: boolean, closeMenu?: () => void, shareProps?: ShareProps, @@ -4550,7 +4550,7 @@ function getOverflowMenu( if (isMobileMenu && closeMenu) { closeMenu(); } - showDeleteModal(hash); + showDeleteModal(savedSearchID); }, icon: icons.Trashcan, shouldShowRightIcon: false, @@ -4561,6 +4561,10 @@ function getOverflowMenu( ]; } +function searchKeyToSavedSearchID(key: SearchKey | undefined) { + return key?.startsWith(CONST.SEARCH.SAVED_SEARCH_PREFIX) ? key.replace(CONST.SEARCH.SAVED_SEARCH_PREFIX, '') : undefined; +} + /** * Checks if the passed username is a correct standard username, and not a placeholder */ @@ -6499,6 +6503,7 @@ export { isReportActionListItemType, shouldShowYear, getOverflowMenu, + searchKeyToSavedSearchID, isCorrectSearchUserName, isReportActionEntry, isTaskListItemType, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index a4c17be3fcfd..0ca55639ee5d 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -727,13 +727,14 @@ function getOnyxLoadingData( function saveSearch({queryJSON, newName}: {queryJSON: Readonly; newName?: string}) { const saveSearchName = newName ?? queryJSON?.inputQuery ?? ''; const jsonQuery = JSON.stringify(queryJSON); + const savedSearchID = rand64(); const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [queryJSON.hash]: { + [savedSearchID]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, name: saveSearchName, query: queryJSON.inputQuery, @@ -747,7 +748,7 @@ function saveSearch({queryJSON, newName}: {queryJSON: Readonly; onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [queryJSON.hash]: null, + [savedSearchID]: null, }, }, ]; @@ -757,22 +758,22 @@ function saveSearch({queryJSON, newName}: {queryJSON: Readonly; onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [queryJSON.hash]: { + [savedSearchID]: { pendingAction: null, }, }, }, ]; - write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, newName: saveSearchName}, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, savedSearchID, newName: saveSearchName}, {optimisticData, failureData, successData}); } -function deleteSavedSearch(hash: number) { +function deleteSavedSearch(savedSearchID: string) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [hash]: { + [savedSearchID]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, }, }, @@ -783,7 +784,7 @@ function deleteSavedSearch(hash: number) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [hash]: null, + [savedSearchID]: null, }, }, ]; @@ -792,14 +793,14 @@ function deleteSavedSearch(hash: number) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.SAVED_SEARCHES}`, value: { - [hash]: { + [savedSearchID]: { pendingAction: null, }, }, }, ]; - write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {hash}, {optimisticData, failureData, successData}); + write(WRITE_COMMANDS.DELETE_SAVED_SEARCH, {savedSearchID}, {optimisticData, failureData, successData}); } function openSearchPage(params?: OpenSearchPageParams) { diff --git a/src/pages/ReportSubmitToContent.tsx b/src/pages/ReportSubmitToContent.tsx index 07a5bc8c9509..bc47ed263532 100644 --- a/src/pages/ReportSubmitToContent.tsx +++ b/src/pages/ReportSubmitToContent.tsx @@ -100,7 +100,7 @@ function ReportSubmitToContent({ const {isOffline} = useNetwork(); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); const {currentSearchResults} = useSearchResultsContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const lazyIllustrations = useMemoizedLazyIllustrations(['PaperAirplane']); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.login ?? ''); diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index 49aa7fe257e0..fc6ce5d35963 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -13,11 +13,11 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShareSavedSearch from '@hooks/useShareSavedSearch'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setSearchContext} from '@libs/actions/Search'; +import {setCurrentSearchKey, setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import type {SavedSearchMenuItem} from '@libs/SearchUIUtils'; +import type {SavedSearchMenuItem, SearchKey} from '@libs/SearchUIUtils'; import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; @@ -36,7 +36,6 @@ import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; import SearchTypeMenuItem from './SearchTypeMenuItem'; type SavedSearchListProps = { - hash: number | undefined; areAllSectionsExpanded: boolean; }; @@ -44,9 +43,9 @@ type SavedSearchMenuItemBuilderParams = { item: SaveSearchItem; key: string; index: number; - hash: number | undefined; + currentSearchKey: SearchKey | undefined; title: string; - getOverflowMenu: (itemName: string, itemHash: number, itemQuery: string) => ReturnType; + getOverflowMenu: (itemName: string, itemSavedSearchID: string, itemQuery: string) => ReturnType; shouldShowSavedSearchTooltip: boolean; hideSavedSearchTooltip: (() => void) | undefined; renderSavedSearchTooltip: () => React.JSX.Element; @@ -59,7 +58,7 @@ function buildSavedSearchMenuItem({ item, key, index, - hash, + currentSearchKey, title, getOverflowMenu, shouldShowSavedSearchTooltip, @@ -69,7 +68,8 @@ function buildSavedSearchMenuItem({ tooltipWrapperStyle, isCopied, }: SavedSearchMenuItemBuilderParams): SavedSearchMenuItem { - const isItemFocused = Number(key) === hash; + const savedSearchKey = `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${key}` as const; + const isItemFocused = savedSearchKey === currentSearchKey; const baseMenuItem: SavedSearchMenuItem = createBaseSavedSearchMenuItem(item, key, index, title, isItemFocused); return { @@ -78,11 +78,12 @@ function buildSavedSearchMenuItem({ sentryLabel: CONST.SENTRY_LABEL.SEARCH.SAVED_SEARCH_MENU_ITEM, onPress: () => { setSearchContext(false); + setCurrentSearchKey(savedSearchKey); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); }, rightComponent: ( - getOverflowMenuUtil(expensifyIcons, itemName, itemHash, itemQuery, translate, showDeleteModal, false, undefined, { - onShare: () => handleShare(itemHash, itemQuery), - isCopied: copiedHash === itemHash, + const getOverflowMenu = (itemName: string, itemID: string, itemQuery: string) => + getOverflowMenuUtil(expensifyIcons, itemName, itemID, itemQuery, translate, showDeleteModal, false, undefined, { + onShare: () => handleShare(itemID, itemQuery), + isCopied: copiedID === itemID, }); const itemStyle = [styles.alignItemsCenter]; @@ -165,7 +167,7 @@ function SavedSearchList({hash, areAllSectionsExpanded}: SavedSearchListProps) { item, key, index, - hash, + currentSearchKey, title: item.name === item.query ? (savedSearchTitles.get(item.query) ?? item.name) : item.name, getOverflowMenu, shouldShowSavedSearchTooltip, @@ -173,7 +175,7 @@ function SavedSearchList({hash, areAllSectionsExpanded}: SavedSearchListProps) { renderSavedSearchTooltip, itemStyle, tooltipWrapperStyle, - isCopied: copiedHash === Number(key), + isCopied: copiedID === key, }), ) .sort((a, b) => localeCompare(a.title ?? '', b.title ?? '')) diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 168ff2416740..3236f9ae43a2 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -72,7 +72,7 @@ function SearchPageWide({ // the indicator unreserved and it drops onto its own line. Reading `hasSelectedTransactions` re-renders only // this component on selection changes (its memoized JSX keeps the subtree from re-rendering; // verified via profiling), so the heavy list is unaffected. - const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, queryJSON?.hash, true); + const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true); const shouldReserveFooterSpace = hasSelectedTransactions || (shouldAllowFooterTotals && !!searchResults?.search?.count); const {saveScrollOffset} = useContext(ScrollOffsetContext); const receiptDropTargetRef = useRef(null); diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 78322d13d876..5f82f1b820e4 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -125,7 +125,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const menuAnchorRef = useRef(null); const {showDeleteModal} = useDeleteSavedSearch(); - const {copiedHash, handleShare} = useShareSavedSearch(); + const {copiedID, handleShare} = useShareSavedSearch(); const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'Receipt', @@ -151,7 +151,6 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const queryMap = new Map(); const tabItems: TabSelectorBaseItem[] = []; const savedSearchesPopoverMenuItems: Record = {}; - let activeKey = ''; const savedSearchesTabItems: TabSelectorBaseItem[] = savedSearches ? Object.entries(savedSearches) @@ -162,22 +161,28 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const title = item.name === item.query ? (savedSearchTitles.get(item.query) ?? item.name) : item.name; - queryMap.set(key, {query: item.query ?? '', name: item.name}); - const itemHash = Number(key); - savedSearchesPopoverMenuItems[key] = getOverflowMenu(expensifyIcons, title, itemHash, item.query, translate, showDeleteModal, true, () => setSavedSearchToModifyKey(null), { - onShare: () => { - handleShare(itemHash, item.query); - setTimeout(() => setSavedSearchToModifyKey(null), MENU_CLOSE_DELAY_MS); + const savedSearchKey = `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${key}`; + queryMap.set(savedSearchKey, {query: item.query ?? '', name: item.name}); + savedSearchesPopoverMenuItems[savedSearchKey] = getOverflowMenu( + expensifyIcons, + title, + key, + item.query, + translate, + showDeleteModal, + true, + () => setSavedSearchToModifyKey(null), + { + onShare: () => { + handleShare(key, item.query); + setTimeout(() => setSavedSearchToModifyKey(null), MENU_CLOSE_DELAY_MS); + }, + isCopied: copiedID === key, }, - isCopied: copiedHash === itemHash, - }); - - if (Number(key) === queryJSON?.hash) { - activeKey = key; - } + ); return { - key, + key: savedSearchKey, icon: expensifyIcons.Bookmark, title, isDisabled: item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, @@ -203,9 +208,6 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps badgeText, }); queryMap.set(item.key, {query: item.searchQuery}); - if (item.key === activeTypeMenuKey) { - activeKey = item.key; - } } } } diff --git a/src/pages/Search/SearchTypeMenuWide.tsx b/src/pages/Search/SearchTypeMenuWide.tsx index a8a1b666a91f..8b27b968fbcf 100644 --- a/src/pages/Search/SearchTypeMenuWide.tsx +++ b/src/pages/Search/SearchTypeMenuWide.tsx @@ -98,12 +98,7 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount title={translate(section.translationPath)} badgeText={getSectionBadgeText(section.translationPath, reportCounts)} > - {isSavedSearchesSection && ( - - )} + {isSavedSearchesSection && } {!isSavedSearchesSection && section.menuItems.map((item, itemIndex) => { const flattenedIndex = sectionStartIndex + itemIndex; diff --git a/src/types/onyx/SaveSearch.ts b/src/types/onyx/SaveSearch.ts index 6b3a903b1639..286d0f5dc504 100644 --- a/src/types/onyx/SaveSearch.ts +++ b/src/types/onyx/SaveSearch.ts @@ -14,6 +14,6 @@ type SaveSearchItem = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** * Model of saved searches */ -type SaveSearch = Record; +type SaveSearch = Record; export type {SaveSearch, SaveSearchItem}; diff --git a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts index 4ec2528a44a3..156518d14af8 100644 --- a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts +++ b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts @@ -32,19 +32,19 @@ describe('useSearchShouldCalculateTotals', () => { }); it('returns false when disabled', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, 123, false)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, false)); expect(result.current).toBe(false); }); it('returns true for eligible suggested searches', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, 123, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, true)); expect(result.current).toBe(true); }); it('returns false for non-eligible searches', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, true)); expect(result.current).toBe(false); }); @@ -58,7 +58,7 @@ describe('useSearchShouldCalculateTotals', () => { }, }; - const {result} = renderHook(() => useSearchShouldCalculateTotals(undefined, 456, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals('savedSearch_456', true)); expect(result.current).toBe(true); }); @@ -72,19 +72,19 @@ describe('useSearchShouldCalculateTotals', () => { }, }; - const {result} = renderHook(() => useSearchShouldCalculateTotals(undefined, 789, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals('savedSearch_123', true)); expect(result.current).toBe(false); }); it('returns true for an ad-hoc search when all matching items are selected', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, true, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, true, true)); expect(result.current).toBe(true); }); it('returns true when all matching items are selected even when the hook is disabled (select-all bypasses the offset gate)', () => { - const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, false, true)); + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, false, true)); expect(result.current).toBe(true); }); From 40c63f0d35c0012c7111deaa05df032bae0193e3 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Thu, 16 Jul 2026 19:06:14 +0800 Subject: [PATCH 003/129] highlight the menu based on the search key --- src/ONYXKEYS.ts | 6 ++ .../SearchPageHeader/SearchPageHeaderWide.tsx | 9 ++- src/components/Search/SearchQueryProvider.tsx | 40 ++++++++++- .../Search/SearchRouter/SearchRouter.tsx | 3 +- .../Search/hooks/useUpdateFilterQuery.tsx | 2 + src/hooks/useSearchTypeMenuSections.ts | 66 ++----------------- src/libs/SearchUIUtils.ts | 6 +- src/pages/Search/EmptySearchView.tsx | 2 +- src/pages/Search/SavedSearchList.tsx | 3 +- .../Search/SearchAdvancedFiltersProvider.tsx | 4 +- src/pages/Search/SearchTypeMenuNarrow.tsx | 25 ++++--- src/pages/Search/SearchTypeMenuWide.tsx | 48 +++++--------- src/setup/index.ts | 1 + 13 files changed, 92 insertions(+), 123 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d673456d2bfc..523a3e4ab624 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,3 +1,5 @@ +import type {SearchKey} from '@libs/SearchUIUtils'; + import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; @@ -650,6 +652,9 @@ const ONYXKEYS = { /** Stores the information about the recent searches */ RECENT_SEARCHES: 'nvp_recentSearches', + /** The currently selected search key */ + RAM_ONLY_CURRENT_SEARCH_KEY: 'currentSearchKey', + /** Stores the current search page context (e.g., whether to show the search query) */ SEARCH_CONTEXT: 'searchContext', @@ -1476,6 +1481,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_TRY_NEW_DOT]: OnyxTypes.TryNewDot; [ONYXKEYS.RECENT_SEARCHES]: Record; [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; + [ONYXKEYS.RAM_ONLY_CURRENT_SEARCH_KEY]: SearchKey; [ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext; [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; diff --git a/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx b/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx index 4b9915ed7150..6c5465d602b0 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeaderWide.tsx @@ -8,17 +8,20 @@ import CONST from '@src/CONST'; import React from 'react'; +import {useSearchQueryContext} from '../SearchContext'; + type SearchPageHeaderWideProps = { queryJSON: SearchQueryJSON; }; function SearchPageHeaderWide({queryJSON}: SearchPageHeaderWideProps) { const {translate} = useLocalize(); - const {typeMenuSections, activeItemIndex} = useSearchTypeMenuSections(queryJSON); - const selectedItem = typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex); + const typeMenuSections = useSearchTypeMenuSections(); + const {currentSearchKey} = useSearchQueryContext(); + const selectedItem = typeMenuSections.flatMap((section) => section.menuItems).find((item) => item.key === currentSearchKey); let title = translate('common.spend'); - if (activeItemIndex >= 0 && selectedItem) { + if (selectedItem) { title = translate(selectedItem.translationPath); } else { const {type} = queryJSON; diff --git a/src/components/Search/SearchQueryProvider.tsx b/src/components/Search/SearchQueryProvider.tsx index dbbbb5fd5a7c..a15884a246ac 100644 --- a/src/components/Search/SearchQueryProvider.tsx +++ b/src/components/Search/SearchQueryProvider.tsx @@ -1,18 +1,23 @@ import useCardFeedsForDisplay from '@hooks/useCardFeedsForDisplay'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; import usePreviousDefined from '@hooks/usePreviousDefined'; import useRootNavigationState from '@hooks/useRootNavigationState'; +import {setCurrentSearchKey} from '@libs/actions/Search'; import {getDeepestFocusedScreen} from '@libs/Navigation/Navigation'; import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; import {getSuggestedSearches} from '@libs/SearchUIUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {NavigationState} from '@react-navigation/routers'; import {useNavigation} from '@react-navigation/native'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import type {SearchQueryActionsValue, SearchQueryContextValue} from './types'; @@ -48,14 +53,43 @@ function SearchQueryProvider({children}: SearchQueryProviderProps) { const currentSearchHash = currentSearchQueryJSON?.hash ?? -1; const currentSimilarSearchHash = currentSearchQueryJSON?.similarSearchHash ?? -1; - const currentSearchKey = Object.values(suggestedSearches).find((search) => search.similarSearchHash === currentSimilarSearchHash)?.key; + const suggestedSearchKey = Object.values(suggestedSearches).find((search) => search.similarSearchHash === currentSimilarSearchHash)?.key; + const typeToGenericKey: Record = { + [CONST.SEARCH.DATA_TYPES.EXPENSE]: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: CONST.SEARCH.SEARCH_KEYS.REPORTS, + }; + const searchKeyFromType = currentSearchQueryJSON?.type ? typeToGenericKey[currentSearchQueryJSON.type] : undefined; + const searchKeyFallback = suggestedSearchKey ?? searchKeyFromType; + const [currentSearchKeyOnyx] = useOnyx(ONYXKEYS.RAM_ONLY_CURRENT_SEARCH_KEY); const [shouldResetSearchQuery, setShouldResetSearchQuery] = useState(false); + const currentQueryFilterKeys = new Set(currentSearchQueryJSON?.flatFilters.map((filter) => filter.key)); + const currentSearchKeyDefaultFilterKeys = new Set(currentSearchKeyOnyx ? suggestedSearches[currentSearchKeyOnyx]?.searchQueryJSON?.flatFilters.map((filter) => filter.key) : undefined); + + useEffect(() => { + // Every time the query changes, we invalidate the currentSearchKey if the new query doesn't have the default filters + // from the currently selected search key query. For example, the "Card statements" suggested search default filters + // are Feed and Posted. When the query changes (by removing Posted), the search key becomes invalid, it's not a + // "Card statements" search anymore. This can happen when accessing the page through a link/deeplink. + if (currentQueryFilterKeys.isSupersetOf(currentSearchKeyDefaultFilterKeys)) { + return; + } + setCurrentSearchKey(null); + }, [currentSearchHash]); + + useEffect(() => { + // currentSearchKey is a RAM-only Onyx data, so the initial value will always be empty and need to be hydrated. + if (currentSearchKeyOnyx || !searchKeyFallback) { + return; + } + setCurrentSearchKey(searchKeyFallback); + }, [searchKeyFallback]); + const queryValue: SearchQueryContextValue = { currentSearchHash, currentSimilarSearchHash, - currentSearchKey, + currentSearchKey: currentSearchKeyOnyx ?? searchKeyFallback, currentSearchQueryJSON, suggestedSearches, shouldResetSearchQuery, diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index cf50abde51de..4c472b448e1d 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -43,7 +43,7 @@ import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; import {navigateToAndOpenReport, searchInServer} from '@userActions/Report'; -import {setSearchContext} from '@userActions/Search'; +import {setCurrentSearchKey, setSearchContext} from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -359,6 +359,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla backHistory(() => { onRouterClose(); setSearchContext(true); + setCurrentSearchKey(null); Navigation.navigate( ROUTES.SEARCH_ROOT.getRoute({query: updatedQuery, rawQuery: shouldSkipAmountConversion || !isFromSearchPageSearchButton ? undefined : queryWithSubstitutions}), ); diff --git a/src/components/Search/hooks/useUpdateFilterQuery.tsx b/src/components/Search/hooks/useUpdateFilterQuery.tsx index 08dac155a641..19872ef690f3 100644 --- a/src/components/Search/hooks/useUpdateFilterQuery.tsx +++ b/src/components/Search/hooks/useUpdateFilterQuery.tsx @@ -3,6 +3,7 @@ import type {SearchQueryJSON} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {setCurrentSearchKey} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {buildFilterQueryWithSortDefaults} from '@libs/SearchQueryUtils'; import {filterValidHasValues} from '@libs/SearchUIUtils'; @@ -25,6 +26,7 @@ function useUpdateFilterQuery(queryJSON: SearchQueryJSON | undefined) { updatedFilterFormValues.columns = []; updatedFilterFormValues.status = undefined; updatedFilterFormValues.has = filterValidHasValues(updatedFilterFormValues.has, updatedFilterFormValues.type, translate); + setCurrentSearchKey(null); } if (updatedFilterFormValues.groupBy !== currentValues.groupBy) { diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 5ab4e02039d2..d5d9adb17ef4 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -1,6 +1,5 @@ -import {createTypeMenuSections, doesSearchItemMatchSort} from '@libs/SearchUIUtils'; +import {createTypeMenuSections} from '@libs/SearchUIUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isTrackIntentUserSelector} from '@src/selectors/Onboarding'; import type {Policy, Session} from '@src/types/onyx'; @@ -48,20 +47,10 @@ const currentUserLoginAndAccountIDSelector = (session: OnyxEntry) => ({ accountID: session?.accountID, }); -type UseSearchTypeMenuSectionsParams = { - hash?: number; - similarSearchHash?: number; - sortBy?: string; - sortOrder?: string; - type?: string; -}; - /** - * Get a list of all search groupings, along with their search items. Also returns the - * currently focused search, based on the hash + * Get a list of all search groupings, along with their search items. */ -const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams) => { - const {hash, similarSearchHash, sortBy, sortOrder, type} = queryParams ?? {}; +const useSearchTypeMenuSections = () => { const [defaultExpensifyCard] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST, {selector: defaultExpensifyCardSelector}); const {defaultCardFeed, cardFeedsByPolicy} = useCardFeedsForDisplay(); @@ -130,54 +119,7 @@ const useSearchTypeMenuSections = (queryParams?: UseSearchTypeMenuSectionsParams ], ); - const activeItemIndex = useMemo(() => { - const isSavedSearchActive = hash !== undefined && !!savedSearches && Object.keys(savedSearches).some((key) => Number(key) === hash); - - if (isSavedSearchActive) { - return -1; - } - - let index = 0; - for (const section of typeMenuSections) { - const found = section.menuItems.findIndex((item) => { - if (item.similarSearchHash !== similarSearchHash) { - return false; - } - return doesSearchItemMatchSort(item.key, item.searchQueryJSON?.sortBy, item.searchQueryJSON?.sortOrder, sortBy, sortOrder); - }); - if (found !== -1) { - return index + found; - } - index += section.menuItems.length; - } - - // Fallback: if no exact match found, select the generic search key matching the type - const typeToGenericKey: Record = { - [CONST.SEARCH.DATA_TYPES.EXPENSE]: CONST.SEARCH.SEARCH_KEYS.EXPENSES, - [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: CONST.SEARCH.SEARCH_KEYS.REPORTS, - }; - const fallbackKey = type ? typeToGenericKey[type] : undefined; - if (fallbackKey) { - let fallbackIndex = 0; - for (const section of typeMenuSections) { - const found = section.menuItems.findIndex((item) => item.key === fallbackKey); - if (found !== -1) { - return fallbackIndex + found; - } - fallbackIndex += section.menuItems.length; - } - } - - return -1; - }, [typeMenuSections, savedSearches, hash, similarSearchHash, sortBy, sortOrder, type]); - - const activeKey = activeItemIndex < 0 ? undefined : typeMenuSections.flatMap((section) => section.menuItems).at(activeItemIndex)?.key; - - return { - typeMenuSections, - activeItemIndex, - activeKey, - }; + return typeMenuSections; }; export default useSearchTypeMenuSections; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b368240ea4cb..0e02375f601d 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -719,11 +719,7 @@ function createTopSearchMenuItem( * If you are trying to access data about a specific search, you do NOT need to subscribe to the data (such as feeds) if it does not * affect the specific query you are looking for */ -function getSuggestedSearches( - accountID: number = CONST.DEFAULT_NUMBER_ID, - defaultFeedID?: string, - shouldShowExpensifyCard?: boolean, -): Record, SearchTypeMenuItem> { +function getSuggestedSearches(accountID: number = CONST.DEFAULT_NUMBER_ID, defaultFeedID?: string, shouldShowExpensifyCard?: boolean): Record { return { [CONST.SEARCH.SEARCH_KEYS.EXPENSES]: { key: CONST.SEARCH.SEARCH_KEYS.EXPENSES, diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 4824ab5d0611..321a3c575c75 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -76,7 +76,7 @@ type EmptySearchViewItem = { function EmptySearchView({similarSearchHash, type, hasResults, queryJSON, onScroll, contentContainerStyle}: EmptySearchViewProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {typeMenuSections} = useSearchTypeMenuSections(); + const typeMenuSections = useSearchTypeMenuSections(); const {isBetaEnabled} = usePermissions(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index fc6ce5d35963..915b0e735677 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -2,6 +2,7 @@ import MenuItemList from '@components/MenuItemList'; import {useSearchSidebarCollapse} from '@components/Navigation/SearchSidebarCollapseStore'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; import useDeleteSavedSearch from '@hooks/useDeleteSavedSearch'; import useFeedKeysWithAssignedCards from '@hooks/useFeedKeysWithAssignedCards'; @@ -111,7 +112,6 @@ function SavedSearchList({areAllSectionsExpanded}: SavedSearchListProps) { const isFocused = useIsFocused(); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); - const [currentSearchKey] = useOnyx(ONYXKEYS.RAM_ONLY_CURRENT_SEARCH_KEY); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const personalDetails = usePersonalDetails(); const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); @@ -122,6 +122,7 @@ function SavedSearchList({areAllSectionsExpanded}: SavedSearchListProps) { const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const reportAttributes = useReportAttributes(); + const {currentSearchKey} = useSearchQueryContext(); const {showDeleteModal} = useDeleteSavedSearch(); const { diff --git a/src/pages/Search/SearchAdvancedFiltersProvider.tsx b/src/pages/Search/SearchAdvancedFiltersProvider.tsx index 109be7adb121..9d2140a934d0 100644 --- a/src/pages/Search/SearchAdvancedFiltersProvider.tsx +++ b/src/pages/Search/SearchAdvancedFiltersProvider.tsx @@ -3,10 +3,11 @@ import {useSearchQueryContext} from '@components/Search/SearchContext'; import useOnyx from '@hooks/useOnyx'; -import {setSearchContext} from '@libs/actions/Search'; +import {setCurrentSearchKey, setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; import {getAdvancedFiltersToReset} from '@libs/SearchQueryUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -59,6 +60,7 @@ function SearchAdvancedFiltersProvider({children}: SearchAdvancedFiltersProvider Navigation.dismissModal({ afterTransition: () => { setFilterQueryParams(advancedFiltersToReset); + setCurrentSearchKey(CONST.SEARCH.SEARCH_KEYS.EXPENSES); setSearchContext(false); }, }); diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 5f82f1b820e4..81188da5acac 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -2,6 +2,7 @@ import type BaseModalProps from '@components/Modal/types'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PopoverMenu from '@components/PopoverMenu'; import type {PopoverMenuItem} from '@components/PopoverMenu'; +import {useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; import TabSelectorContextProvider from '@components/TabSelector/TabSelectorContext'; @@ -19,10 +20,11 @@ import useShareSavedSearch, {MENU_CLOSE_DELAY_MS} from '@hooks/useShareSavedSear import useThemeStyles from '@hooks/useThemeStyles'; import useTodoCounts from '@hooks/useTodoCounts'; -import {setSearchContext} from '@libs/actions/Search'; +import {setCurrentSearchKey, setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import {getItemBadgeText, getOverflowMenu} from '@libs/SearchUIUtils'; +import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -80,13 +82,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const {isOffline} = useNetwork(); const navigation = useNavigation(); const {translate, localeCompare} = useLocalize(); - const {typeMenuSections, activeKey: activeTypeMenuKey} = useSearchTypeMenuSections({ - hash: queryJSON?.hash, - similarSearchHash: queryJSON?.similarSearchHash, - sortBy: queryJSON?.sortBy, - sortOrder: queryJSON?.sortOrder, - type: queryJSON?.type, - }); + const typeMenuSections = useSearchTypeMenuSections(); const personalDetails = usePersonalDetails(); const feedKeysWithCards = useFeedKeysWithAssignedCards(); const [restoreFocusType, setRestoreFocusType] = useState(); @@ -102,6 +98,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const {counts: reportCounts} = useTodoCounts(isFocused); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); const reportAttributes = useReportAttributes(); + const {currentSearchKey} = useSearchQueryContext(); const taxRates = getAllTaxRates(allPolicies); const cardsForSavedSearchDisplay = mergeCardListWithWorkspaceFeeds(workspaceCardList ?? CONST.EMPTY_OBJECT, cardList); @@ -148,9 +145,9 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps 'CheckCircle', ]); - const queryMap = new Map(); + const queryMap = new Map(); const tabItems: TabSelectorBaseItem[] = []; - const savedSearchesPopoverMenuItems: Record = {}; + const savedSearchesPopoverMenuItems: Partial> = {}; const savedSearchesTabItems: TabSelectorBaseItem[] = savedSearches ? Object.entries(savedSearches) @@ -161,7 +158,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const title = item.name === item.query ? (savedSearchTitles.get(item.query) ?? item.name) : item.name; - const savedSearchKey = `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${key}`; + const savedSearchKey = `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${key}` as const; queryMap.set(savedSearchKey, {query: item.query ?? '', name: item.name}); savedSearchesPopoverMenuItems[savedSearchKey] = getOverflowMenu( expensifyIcons, @@ -212,7 +209,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps } } - const popoverMenuItems = savedSearchToModifyKey ? savedSearchesPopoverMenuItems?.[savedSearchToModifyKey] : []; + const popoverMenuItems = savedSearchToModifyKey ? (savedSearchesPopoverMenuItems?.[savedSearchToModifyKey] ?? []) : []; const shouldShowSavedSearchPopover = savedSearchToModifyKey && popoverMenuItems.length > 0; const handleActiveTabPress = (tabKey: string) => { @@ -221,6 +218,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps return; } onTabPress?.(); + setCurrentSearchKey(tabKey); setSearchContext(false); }; @@ -230,6 +228,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps return; } onTabPress?.(); + setCurrentSearchKey(tabKey); setSearchContext(false); navigation.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS, @@ -250,7 +249,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps return ( void; + onItemPress: (key: SearchKey, query: string) => void; onCollapsed: (isCollapsed: boolean) => void; }; -function Section({section, hash, activeItemIndex, sectionStartIndex, reportCounts, areAllSectionsExpanded, onItemPress, onCollapsed}: SectionParams) { +function Section({section, reportCounts, areAllSectionsExpanded, onItemPress, onCollapsed}: SectionParams) { const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'Basket', @@ -69,6 +61,8 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount 'CheckCircle', ]); + const {currentSearchKey} = useSearchQueryContext(); + const [isExpanded, setIsExpanded] = useState(true); const onUnmount = useEffectEvent(() => { @@ -100,9 +94,8 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount > {isSavedSearchesSection && } {!isSavedSearchesSection && - section.menuItems.map((item, itemIndex) => { - const flattenedIndex = sectionStartIndex + itemIndex; - const focused = activeItemIndex === flattenedIndex; + section.menuItems.map((item) => { + const focused = item.key === currentSearchKey; const icon = typeof item.icon === 'string' ? expensifyIcons[item.icon] : item.icon; return ( @@ -112,7 +105,7 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount icon={icon} badgeText={getItemBadgeText(item.key, reportCounts)} focused={focused} - onPress={() => onItemPress(item.searchQuery)} + onPress={() => onItemPress(item.key, item.searchQuery)} /> ); })} @@ -120,14 +113,12 @@ function Section({section, hash, activeItemIndex, sectionStartIndex, reportCount ); } -function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { - const {hash, similarSearchHash, sortBy, sortOrder, type} = queryJSON ?? {}; - +function SearchTypeMenuWide() { const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {singleExecution} = useSingleExecution(); const {clearSelectedTransactions} = useSearchSelectionActions(); - const {typeMenuSections, activeItemIndex} = useSearchTypeMenuSections({hash, similarSearchHash, sortBy, sortOrder, type}); + const typeMenuSections = useSearchTypeMenuSections(); const {isVisuallyCollapsed} = useSearchSidebarCollapse(); const [isSearchDataLoaded, isSearchDataLoadedResult] = useOnyx(ONYXKEYS.IS_SEARCH_PAGE_DATA_LOADED); // Intentionally left enabled (no focus freeze): the wide menu renders in the search navigator's ExtraContent @@ -146,9 +137,10 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { saveScrollOffset(route, e.nativeEvent.contentOffset.y); }; - const handleTypeMenuItemPress = singleExecution((searchQuery: string) => { + const handleTypeMenuItemPress = singleExecution((key: SearchKey, searchQuery: string) => { clearSelectedTransactions(); setSearchContext(false); + setCurrentSearchKey(key); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); }); @@ -160,10 +152,6 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { scrollViewRef.current.scrollTo({y: scrollOffset, animated: false}); }, [getScrollOffset, route]); - const sectionStartIndices = [0]; - for (const section of typeMenuSections) { - sectionStartIndices.push((sectionStartIndices.at(-1) ?? 0) + section.menuItems.length); - } const expenseReportsSection = typeMenuSections.find((section) => section.translationPath === 'search.tabs.expenseReports'); const nonExpenseReportsSections = typeMenuSections.filter((section) => section.translationPath !== 'search.tabs.expenseReports'); @@ -188,9 +176,6 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { section={expenseReportsSection} onItemPress={handleTypeMenuItemPress} onCollapsed={updateCollapsedCount} - hash={hash} - sectionStartIndex={0} - activeItemIndex={activeItemIndex} reportCounts={reportCounts} areAllSectionsExpanded={areAllSectionsExpanded} /> @@ -202,15 +187,12 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { shouldHideLabels={isVisuallyCollapsed} /> ) : ( - nonExpenseReportsSections.map((section, index) => ( + nonExpenseReportsSections.map((section) => (
diff --git a/src/setup/index.ts b/src/setup/index.ts index c65d8f014455..5c1f5fcbf075 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -79,6 +79,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_CURRENT_SEARCH_KEY, ], }); From c85e5ca7a71797f2d4b9b051bce5b9776f5cc319 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Fri, 17 Jul 2026 16:15:21 +0800 Subject: [PATCH 004/129] fallback the initial highlight from the search filters or saved search similar hash --- src/ONYXKEYS.ts | 4 ++ src/components/Search/SearchQueryProvider.tsx | 50 ++++++++++++++----- src/types/onyx/SearchFilters.ts | 5 ++ src/types/onyx/index.ts | 2 + 4 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 src/types/onyx/SearchFilters.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 523a3e4ab624..7c59f7fe8098 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -652,6 +652,9 @@ const ONYXKEYS = { /** Stores the information about the recent searches */ RECENT_SEARCHES: 'nvp_recentSearches', + /** Stores the last query for each suggested/saved search */ + SEARCH_FILTERS: 'nvp_searchFilters', + /** The currently selected search key */ RAM_ONLY_CURRENT_SEARCH_KEY: 'currentSearchKey', @@ -1481,6 +1484,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_TRY_NEW_DOT]: OnyxTypes.TryNewDot; [ONYXKEYS.RECENT_SEARCHES]: Record; [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; + [ONYXKEYS.SEARCH_FILTERS]: OnyxTypes.SearchFilters; [ONYXKEYS.RAM_ONLY_CURRENT_SEARCH_KEY]: SearchKey; [ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext; [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; diff --git a/src/components/Search/SearchQueryProvider.tsx b/src/components/Search/SearchQueryProvider.tsx index a15884a246ac..c2b18d4e5a8b 100644 --- a/src/components/Search/SearchQueryProvider.tsx +++ b/src/components/Search/SearchQueryProvider.tsx @@ -53,17 +53,43 @@ function SearchQueryProvider({children}: SearchQueryProviderProps) { const currentSearchHash = currentSearchQueryJSON?.hash ?? -1; const currentSimilarSearchHash = currentSearchQueryJSON?.similarSearchHash ?? -1; - const suggestedSearchKey = Object.values(suggestedSearches).find((search) => search.similarSearchHash === currentSimilarSearchHash)?.key; - const typeToGenericKey: Record = { - [CONST.SEARCH.DATA_TYPES.EXPENSE]: CONST.SEARCH.SEARCH_KEYS.EXPENSES, - [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: CONST.SEARCH.SEARCH_KEYS.REPORTS, - }; - const searchKeyFromType = currentSearchQueryJSON?.type ? typeToGenericKey[currentSearchQueryJSON.type] : undefined; - const searchKeyFallback = suggestedSearchKey ?? searchKeyFromType; + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); const [currentSearchKeyOnyx] = useOnyx(ONYXKEYS.RAM_ONLY_CURRENT_SEARCH_KEY); + const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const [shouldResetSearchQuery, setShouldResetSearchQuery] = useState(false); + const currentSearchKey = (() => { + if (currentSearchKeyOnyx) { + return currentSearchKeyOnyx; + } + + const suggestedSearchKey = Object.values(suggestedSearches).find((search) => { + const savedSearchFilterQuery = searchFilters?.[search.key]; + const savedSearchFilter = savedSearchFilterQuery ? buildSearchQueryJSON(savedSearchFilterQuery) : undefined; + return (savedSearchFilter ?? search).similarSearchHash === currentSimilarSearchHash; + })?.key; + if (suggestedSearchKey) { + return suggestedSearchKey; + } + + const savedSearchKey = Object.keys(savedSearches ?? {}).find((key) => { + const query = searchFilters?.[`${CONST.SEARCH.SAVED_SEARCH_PREFIX}${key}`] ?? savedSearches?.[key].query; + return query ? buildSearchQueryJSON(query)?.similarSearchHash === currentSimilarSearchHash : false; + }); + + if (savedSearchKey) { + return `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${savedSearchKey}` as const; + } + + const typeToGenericKey: Record = { + [CONST.SEARCH.DATA_TYPES.EXPENSE]: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + [CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT]: CONST.SEARCH.SEARCH_KEYS.REPORTS, + }; + return currentSearchQueryJSON?.type ? typeToGenericKey[currentSearchQueryJSON.type] : undefined; + })(); + const currentQueryFilterKeys = new Set(currentSearchQueryJSON?.flatFilters.map((filter) => filter.key)); const currentSearchKeyDefaultFilterKeys = new Set(currentSearchKeyOnyx ? suggestedSearches[currentSearchKeyOnyx]?.searchQueryJSON?.flatFilters.map((filter) => filter.key) : undefined); @@ -79,17 +105,17 @@ function SearchQueryProvider({children}: SearchQueryProviderProps) { }, [currentSearchHash]); useEffect(() => { - // currentSearchKey is a RAM-only Onyx data, so the initial value will always be empty and need to be hydrated. - if (currentSearchKeyOnyx || !searchKeyFallback) { + // currentSearchKeyOnyx is a RAM-only Onyx data, so the initial value will always be empty and need to be hydrated. + if (currentSearchKeyOnyx) { return; } - setCurrentSearchKey(searchKeyFallback); - }, [searchKeyFallback]); + setCurrentSearchKey(currentSearchKey ?? null); + }, [currentSearchKey]); const queryValue: SearchQueryContextValue = { currentSearchHash, currentSimilarSearchHash, - currentSearchKey: currentSearchKeyOnyx ?? searchKeyFallback, + currentSearchKey, currentSearchQueryJSON, suggestedSearches, shouldResetSearchQuery, diff --git a/src/types/onyx/SearchFilters.ts b/src/types/onyx/SearchFilters.ts new file mode 100644 index 000000000000..c15b34a45047 --- /dev/null +++ b/src/types/onyx/SearchFilters.ts @@ -0,0 +1,5 @@ +import type {SearchKey} from '@libs/SearchUIUtils'; + +type SearchFilters = Record; + +export default SearchFilters; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index d8eebfcc9732..9360a18cf187 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -166,6 +166,7 @@ import type {SaveSearch} from './SaveSearch'; import type ScheduleCallDraft from './ScheduleCallDraft'; import type ScreenShareRequest from './ScreenShareRequest'; import type SearchContext from './SearchContext'; +import type SearchFilters from './SearchFilters'; import type SearchResults from './SearchResults'; import type SearchSidebar from './SearchSidebar'; import type SecurityGroup from './SecurityGroup'; @@ -380,6 +381,7 @@ export type { WorkspaceCardFeedsStatus, DomainSettings, SaveSearch, + SearchFilters, RecentSearchItem, SearchContext, SearchSidebar, From a4590afe06784e532e0cadf379619009c7c975fa Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Fri, 17 Jul 2026 17:38:24 +0800 Subject: [PATCH 005/129] open the last query for each search key --- src/components/Search/SearchQueryProvider.tsx | 10 ++++----- src/libs/SearchUIUtils.ts | 5 +++++ src/libs/actions/Search.ts | 21 ++++++++++++++++++- src/pages/Search/SavedSearchList.tsx | 10 ++++++--- src/pages/Search/SearchTypeMenuNarrow.tsx | 7 ++++--- src/pages/Search/SearchTypeMenuWide.tsx | 3 ++- 6 files changed, 43 insertions(+), 13 deletions(-) diff --git a/src/components/Search/SearchQueryProvider.tsx b/src/components/Search/SearchQueryProvider.tsx index c2b18d4e5a8b..ca1d886e489f 100644 --- a/src/components/Search/SearchQueryProvider.tsx +++ b/src/components/Search/SearchQueryProvider.tsx @@ -7,7 +7,7 @@ import useRootNavigationState from '@hooks/useRootNavigationState'; import {setCurrentSearchKey} from '@libs/actions/Search'; import {getDeepestFocusedScreen} from '@libs/Navigation/Navigation'; import {buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; -import {getSuggestedSearches} from '@libs/SearchUIUtils'; +import {getSuggestedSearches, savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -74,13 +74,13 @@ function SearchQueryProvider({children}: SearchQueryProviderProps) { return suggestedSearchKey; } - const savedSearchKey = Object.keys(savedSearches ?? {}).find((key) => { - const query = searchFilters?.[`${CONST.SEARCH.SAVED_SEARCH_PREFIX}${key}`] ?? savedSearches?.[key].query; + const savedSearchID = Object.keys(savedSearches ?? {}).find((id) => { + const query = searchFilters?.[savedSearchIDToSearchKey(id)] ?? savedSearches?.[id].query; return query ? buildSearchQueryJSON(query)?.similarSearchHash === currentSimilarSearchHash : false; }); - if (savedSearchKey) { - return `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${savedSearchKey}` as const; + if (savedSearchID) { + return savedSearchIDToSearchKey(savedSearchID); } const typeToGenericKey: Record = { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 0e02375f601d..b3315807cd6b 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -4557,6 +4557,10 @@ function getOverflowMenu( ]; } +function savedSearchIDToSearchKey(id: string): SearchKey { + return `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${id}`; +} + function searchKeyToSavedSearchID(key: SearchKey | undefined) { return key?.startsWith(CONST.SEARCH.SAVED_SEARCH_PREFIX) ? key.replace(CONST.SEARCH.SAVED_SEARCH_PREFIX, '') : undefined; } @@ -6499,6 +6503,7 @@ export { isReportActionListItemType, shouldShowYear, getOverflowMenu, + savedSearchIDToSearchKey, searchKeyToSavedSearchID, isCorrectSearchUserName, isReportActionEntry, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 0ca55639ee5d..2f441ca29277 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -919,7 +919,7 @@ function search({ } inFlightSearchRequests.add(dedupeKey); - const {optimisticData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); + const onyxLoadingData = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; const backendQueryJSON = shouldUseBackendDateSortFallback(queryJSON.sortBy) ? { @@ -950,6 +950,20 @@ function search({ }); } + const optimisticData: Array> = [...(onyxLoadingData.optimisticData ?? [])]; + const failureData: Array> = [...(onyxLoadingData.failureData ?? [])]; + const finallyData = onyxLoadingData.finallyData; + + if (searchKey) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SEARCH_FILTERS, + value: { + [searchKey]: query.inputQuery, + }, + }); + } + const startRequest = () => makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}) .then((result) => { @@ -1546,6 +1560,10 @@ function setSearchContext(shouldShowSearchQuery: boolean) { Onyx.set(ONYXKEYS.SEARCH_CONTEXT, {shouldShowSearchQuery}); } +function setCurrentSearchKey(key: SearchKey | null) { + Onyx.set(ONYXKEYS.RAM_ONLY_CURRENT_SEARCH_KEY, key); +} + /** * For Expense reports, user can choose both expense and transaction, in this case we need to check for both selected reports and transactions * This function checks if all remaining selected transactions (not included in selectedReports) are eligible for bulk pay @@ -1820,6 +1838,7 @@ export { queueExportSearchWithTemplate, updateAdvancedFilters, setSearchContext, + setCurrentSearchKey, deleteSavedSearch, getSearchPayOnyxData, getSearchApproveOnyxData, diff --git a/src/pages/Search/SavedSearchList.tsx b/src/pages/Search/SavedSearchList.tsx index 915b0e735677..c449964bede5 100644 --- a/src/pages/Search/SavedSearchList.tsx +++ b/src/pages/Search/SavedSearchList.tsx @@ -19,7 +19,7 @@ import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {SavedSearchMenuItem, SearchKey} from '@libs/SearchUIUtils'; -import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil} from '@libs/SearchUIUtils'; +import {createBaseSavedSearchMenuItem, getOverflowMenu as getOverflowMenuUtil, savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; @@ -42,6 +42,7 @@ type SavedSearchListProps = { type SavedSearchMenuItemBuilderParams = { item: SaveSearchItem; + itemQuery: string; key: string; index: number; currentSearchKey: SearchKey | undefined; @@ -57,6 +58,7 @@ type SavedSearchMenuItemBuilderParams = { function buildSavedSearchMenuItem({ item, + itemQuery, key, index, currentSearchKey, @@ -69,7 +71,7 @@ function buildSavedSearchMenuItem({ tooltipWrapperStyle, isCopied, }: SavedSearchMenuItemBuilderParams): SavedSearchMenuItem { - const savedSearchKey = `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${key}` as const; + const savedSearchKey = savedSearchIDToSearchKey(key); const isItemFocused = savedSearchKey === currentSearchKey; const baseMenuItem: SavedSearchMenuItem = createBaseSavedSearchMenuItem(item, key, index, title, isItemFocused); @@ -80,7 +82,7 @@ function buildSavedSearchMenuItem({ onPress: () => { setSearchContext(false); setCurrentSearchKey(savedSearchKey); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: itemQuery, name: item?.name})); }, rightComponent: ( buildSavedSearchMenuItem({ item, + itemQuery: searchFilters?.[savedSearchIDToSearchKey(key)] ?? item.query ?? '', key, index, currentSearchKey, diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 81188da5acac..41e6c86b99b7 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -23,7 +23,7 @@ import useTodoCounts from '@hooks/useTodoCounts'; import {setCurrentSearchKey, setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {getItemBadgeText, getOverflowMenu} from '@libs/SearchUIUtils'; +import {getItemBadgeText, getOverflowMenu, savedSearchIDToSearchKey} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -94,6 +94,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); const [workspaceCardList] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); const isFocused = useIsFocused(); const {counts: reportCounts} = useTodoCounts(isFocused); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); @@ -158,7 +159,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const title = item.name === item.query ? (savedSearchTitles.get(item.query) ?? item.name) : item.name; - const savedSearchKey = `${CONST.SEARCH.SAVED_SEARCH_PREFIX}${key}` as const; + const savedSearchKey = savedSearchIDToSearchKey(key); queryMap.set(savedSearchKey, {query: item.query ?? '', name: item.name}); savedSearchesPopoverMenuItems[savedSearchKey] = getOverflowMenu( expensifyIcons, @@ -233,7 +234,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps navigation.dispatch({ type: CONST.NAVIGATION.ACTION_TYPE.PUSH_PARAMS, payload: { - params: {q: searchData.query, name: searchData.name, rawQuery: undefined}, + params: {q: searchFilters?.[tabKey] ?? searchData.query, name: searchData.name, rawQuery: undefined}, }, }); }; diff --git a/src/pages/Search/SearchTypeMenuWide.tsx b/src/pages/Search/SearchTypeMenuWide.tsx index ca3f7ae44d98..f544f7b3c8e9 100644 --- a/src/pages/Search/SearchTypeMenuWide.tsx +++ b/src/pages/Search/SearchTypeMenuWide.tsx @@ -121,6 +121,7 @@ function SearchTypeMenuWide() { const typeMenuSections = useSearchTypeMenuSections(); const {isVisuallyCollapsed} = useSearchSidebarCollapse(); const [isSearchDataLoaded, isSearchDataLoadedResult] = useOnyx(ONYXKEYS.IS_SEARCH_PAGE_DATA_LOADED); + const [searchFilters] = useOnyx(ONYXKEYS.SEARCH_FILTERS); // Intentionally left enabled (no focus freeze): the wide menu renders in the search navigator's ExtraContent // slot, where useIsFocused() does not track visibility, so freezing on it would be unreliable. const {counts: reportCounts} = useTodoCounts(); @@ -141,7 +142,7 @@ function SearchTypeMenuWide() { clearSelectedTransactions(); setSearchContext(false); setCurrentSearchKey(key); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchFilters?.[key] ?? searchQuery})); }); useLayoutEffect(() => { From df11952be192e97338522bbaeb6243710e826835 Mon Sep 17 00:00:00 2001 From: Bernhard Owen Josephus Date: Sat, 18 Jul 2026 14:04:34 +0800 Subject: [PATCH 006/129] Merge branch 'main' into feat/95976-persist-filters-across-search --- .../actions/javascript/bumpVersion/index.js | 359 ++++++ .../markPullRequestsAsDeployed/index.js | 59 +- .../markPullRequestsAsDeployed.ts | 92 +- .github/workflows/androidBump.yml | 2 +- .github/workflows/authorChecklist.yml | 2 +- .github/workflows/buildAdHoc.yml | 4 +- .github/workflows/buildAndroid.yml | 2 +- .github/workflows/buildIOS.yml | 2 +- .../workflows/buildVictoryChartRenderer.yml | 2 +- .github/workflows/buildWeb.yml | 2 +- .github/workflows/bunTests.yml | 2 +- .github/workflows/checkSVGCompression.yml | 2 +- .github/workflows/cherryPick.yml | 24 +- .github/workflows/cla.yml | 2 +- .github/workflows/claude-review.yml | 4 +- .github/workflows/createDeployChecklist.yml | 2 +- .github/workflows/createNewVersion.yml | 2 +- .github/workflows/cspell.yml | 2 +- .github/workflows/deploy.yml | 49 +- .github/workflows/deployBlocker.yml | 2 +- .github/workflows/deployExpensifyHelp.yml | 2 +- .github/workflows/failureNotifier.yml | 2 +- .github/workflows/finishReleaseCycle.yml | 2 +- .github/workflows/formatCodeCovComment.yml | 2 +- .github/workflows/generateTranslations.yml | 2 +- .github/workflows/knip.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/lockDeploys.yml | 2 +- .github/workflows/oxfmt.yml | 2 +- .github/workflows/postDeployComments.yml | 2 +- .github/workflows/preDeploy.yml | 4 +- .github/workflows/proposalPolice.yml | 2 +- .../publishReactNativeAndroidArtifacts.yml | 4 +- .../workflows/react-compiler-compliance.yml | 2 +- .../workflows/reassurePerformanceTests.yml | 6 +- .github/workflows/remote-build-android.yml | 2 +- .github/workflows/remote-build-ios.yml | 2 +- .github/workflows/reviewerChecklist.yml | 2 +- .github/workflows/shellCheck.yml | 2 +- .github/workflows/syncVersions.yml | 2 +- .github/workflows/test.yml | 4 +- .github/workflows/testBuildOnPush.yml | 4 +- .github/workflows/translationDryRun.yml | 2 +- .github/workflows/typecheck.yml | 2 +- .github/workflows/unused-styles.yml | 2 +- .github/workflows/updateHelpDotRedirects.yml | 2 +- .github/workflows/updateProtectedBranch.yml | 2 +- .github/workflows/validateBuildRequest.yml | 2 +- .github/workflows/validateContributorPR.yml | 2 +- .github/workflows/validateDocsRoutes.yml | 2 +- .github/workflows/validateGithubActions.yml | 2 +- .../validateMobileExpensifySubmodule.yml | 4 +- .github/workflows/validatePatches.yml | 2 +- .github/workflows/verifyParserFiles.yml | 2 +- .github/workflows/verifyPodfile.yml | 2 +- .github/workflows/verifySignedCommits.yml | 2 +- .github/workflows/welcome.yml | 2 +- Mobile-Expensify | 2 +- android/app/build.gradle | 4 +- assets/emojis/index.ts | 1 + .../illustration_agents-ice-cream.svg | 1 + config/eslint/eslint.seatbelt.tsv | 114 +- contributingGuides/OBSERVABILITY_METRICS.md | 3 +- contributingGuides/SEQUENTIAL_QUEUE.md | 2 +- cspell.json | 20 +- .../Classic-Submit-Classic-Track-vs-Submit.md | 193 +++ .../Upcoming-Integrations-And-Accounting.md | 5 +- .../certinia/Connect-To-Certinia.md | 2 +- .../ai-agents/Create-and-Use-Custom-Agents.md | 16 + ...earn-How-Billing-and-Subscriptions-Work.md | 2 +- .../manage-billing/Request-Tax-Exemption.md | 4 + .../Transfer-Workspace-Ownership.md | 4 + .../Cancel-an-Annual-Subscription.md | 6 +- .../Change-Your-Workspace-Plan.md | 4 + .../How-Concierge-Analyzes-Spend.md | 7 +- .../certinia/Configure-Certinia.md | 143 +++ .../certinia/Connect-To-Certinia.md | 112 ++ .../certinia/Troubleshooting/Certinia-FAQ.md | 96 ++ ...d-Transactions-Using-3DS-Authentication.md | 137 +++ .../Cardholder-Settings-and-Features.md | 2 + .../expensify-card/Expensify-Card-Perks.md | 2 + .../Expensify-Card-Spend-Rules.md | 104 +- ...-Up-and-Manage-the-Expensify-Card-UK-EU.md | 138 +++ .../Set-Up-and-Manage-the-Expensify-Card.md | 15 + .../Accounting-Search-Shortcuts.md | 4 +- .../reports-and-expenses/Create-an-Expense.md | 4 + .../Create-and-Submit-Reports.md | 2 +- .../reports-and-expenses/Edit-Expenses.md | 2 +- .../Getting-Started-with-the-Spend-Page.md | 2 +- ...-and-Resolve-Flagged-Duplicate-Expenses.md | 34 +- .../Managing-Expenses-in-a-Report.md | 43 +- ...-Search-Operators-to-Filter-and-Analyze.md | 1 + .../Using-Reports-in-New-Expensify.md | 4 +- .../settings/Account-Settings.md | 6 +- .../workspaces/Managing-Workspace-Members.md | 23 +- .../workspaces/Set-distance-rates.md | 4 +- ...on-and-Login-errors.html => certinia.html} | 0 .../Troubleshooting/Export-Errors.html | 5 - ios/NewExpensify/Info.plist | 4 +- ios/NotificationServiceExtension/Info.plist | 4 +- ios/Podfile.lock | 6 +- ios/ShareViewController/Info.plist | 4 +- package-lock.json | 22 +- package.json | 6 +- patches/react-native/details.md | 42 + ...+0.85.3+038+nested-text-border-width.patch | 1079 +++++++++++++++++ ...ative+0.85.3+039+nested-text-padding.patch | 747 ++++++++++++ scripts/bumpVersion.ts | 32 +- scripts/createRetestRequestForCP.ts | 265 ++++ scripts/utils/PromisePool.ts | 4 +- server/libs/log.ts | 157 +++ server/libs/rsyslogWriter.ts | 135 +++ server/stubs/expensify-log.ts | 10 +- server/victory-chart-renderer/src/cli.tsx | 34 +- .../src/loadChartFontsForCli.ts | 10 +- server/victory-chart-renderer/src/log.ts | 10 + .../tests/__golden__/top-categories-10.png | Bin 41903 -> 42313 bytes .../top-categories-6-label-indicators.png | Bin 37891 -> 36064 bytes .../tests/__golden__/top-categories-6.png | Bin 34515 -> 35298 bytes .../top-categories-crowded-slices.png | Bin 0 -> 36712 bytes .../top-categories-single-slice.png | Bin 0 -> 23276 bytes ...op-employees-by-spend-truncated-labels.png | Bin 18183 -> 18231 bytes .../__golden__/top-employees-by-spend.png | Bin 13166 -> 13401 bytes .../top-categories-crowded-slices.xml | 8 + .../fixtures/top-categories-single-slice.xml | 8 + .../victory-chart-renderer/tests/log.test.ts | 185 +++ .../tests/render.test.ts | 2 +- .../victory-chart-renderer/tests/testUtils.ts | 2 + src/CONST/LOCALES.ts | 4 +- src/CONST/index.ts | 95 +- src/DeepLinkHandler.tsx | 42 +- src/ONYXKEYS.ts | 9 + src/ROUTES.ts | 58 +- src/SCREENS.ts | 8 +- .../AccountManagerBookCallButton.tsx | 85 ++ src/components/AgentRules/AgentRulesList.tsx | 71 ++ .../AgentRules/useAgentRulesSectionHeader.tsx | 68 ++ .../AnimatedFlatListWithCellRenderer.tsx | 88 +- .../AttachmentPicker/index.native.tsx | 3 +- .../Attachments/AttachmentView/index.tsx | 15 + .../BaseAutoCompleteSuggestions.tsx | 19 +- ...BaseVacationDelegateSelectionComponent.tsx | 3 +- src/components/Button/index.tsx | 105 +- .../ButtonComposed/context/ButtonContext.ts | 2 +- .../ButtonComposed/context/index.ts | 1 - .../ButtonComposed/context/types.ts | 2 +- src/components/ButtonComposed/types.ts | 4 +- .../ButtonWithDropdownMenu/index.tsx | 79 +- src/components/CategoryPicker/index.tsx | 31 +- src/components/CopyTextToClipboard.tsx | 8 +- .../index.tsx | 5 +- src/components/DelegatorList.tsx | 4 +- src/components/DotIndicatorMessage.tsx | 4 +- src/components/FilePicker/index.native.tsx | 3 +- .../FlatList/hooks/useFlatListScrollKey.ts | 112 +- src/components/Form/FormProvider.tsx | 17 +- src/components/Form/FormWrapper.tsx | 30 +- .../VictoryChartContainerFixed.tsx | 10 +- .../VictoryChartContainer/index.native.tsx | 5 +- .../components/VictoryChartPie.tsx | 142 ++- .../components/VictoryChartPieLabel.tsx | 19 +- .../VictoryChartPieLabelIndicator.tsx | 42 +- .../VictoryChartRenderer/constants.ts | 16 +- .../context/VictoryChartContext.tsx | 24 +- .../parsers/processVictoryChartTree.ts | 40 +- .../parsers/victoryAxisParser.ts | 80 +- .../VictoryChartRenderer/types.ts | 12 + .../utils/computeAdjustedOverlayY.ts | 18 + .../utils/computeDynamicChartHeight.ts | 62 + .../utils/computePieLabelLayout.ts | 218 ++++ .../utils/getFontGlyphWidth.ts | 14 + .../utils/resolvePadding.ts | 17 + src/components/HeaderLoadingBar.tsx | 2 +- src/components/Hoverable/ActiveHoverable.tsx | 25 +- src/components/Hoverable/types.ts | 9 + .../IconWrapperStyles/index.ios.ts | 0 .../IconWrapperStyles/index.ts | 0 .../IconWrapperStyles/types.ts | 0 src/components/Icon/InlineIcon/index.tsx | 62 + .../Icon/chunks/illustrations.chunk.ts | 2 + src/components/Icon/index.tsx | 61 +- src/components/Icon/primitives/BaseIcon.tsx | 42 - src/components/Icon/primitives/InlineIcon.tsx | 48 - src/components/Icon/primitives/types.ts | 41 - .../{primitives => utils}/resolveIconSize.ts | 7 +- src/components/ImportColumn.tsx | 58 +- .../ImportOnyxState/index.native.tsx | 3 +- src/components/KYCWall/BaseKYCWall.tsx | 15 +- src/components/MenuItem.tsx | 20 +- src/components/MoneyReportHeader.tsx | 46 +- .../MoneyReportHeaderSecondaryActions.tsx | 3 + src/components/MoneyReportHeaderModals.tsx | 8 +- .../MoneyReportHeaderModalsContext.tsx | 2 +- .../MoneyReportHeaderMoreContent.tsx | 46 +- .../MarkAsResolvedPrimaryAction.tsx | 8 +- .../PayPrimaryAction.tsx | 3 + .../SubmitPrimaryAction.tsx | 104 +- src/components/MoneyRequestHeader.tsx | 32 +- .../MoneyRequestHeaderPrimaryAction.tsx | 2 +- .../MoneyRequestReportNavigation.tsx | 14 +- .../MoneyRequestReportTransactionList.tsx | 34 +- ...neyRequestReportTransactionsNavigation.tsx | 282 +++-- .../MoneyRequestViewReportFields.tsx | 2 +- .../OutcomeScreen/OutcomeScreenBase.tsx | 27 +- .../createScreenWithDefaults.tsx | 25 +- src/components/Navigation/SearchSidebar.tsx | 2 +- .../Navigation/TopBarWithLoadingBar.tsx | 2 +- .../PDFThumbnail/PDFThumbnailError.tsx | 11 +- src/components/PDFThumbnail/index.tsx | 18 +- src/components/Picker/BasePicker.tsx | 17 +- src/components/Picker/index.tsx | 20 +- .../Pressable/PressableWithDelayToggle.tsx | 21 +- src/components/PromotedActionsBar.tsx | 5 +- src/components/ReceiptImage/index.tsx | 9 +- .../useReportActionAvatars.ts | 2 +- .../ReportActionItem/ExportIntegration.tsx | 6 +- .../LocalPDFReceiptPreview/index.native.tsx | 22 + .../LocalPDFReceiptPreview/index.tsx | 112 ++ .../LocalPDFReceiptPreview/types.ts | 15 + .../ReportActionItem/MoneyReportView.tsx | 2 +- .../MoneyRequestReceiptView.tsx | 7 +- .../ApproveActionButton.tsx | 64 +- .../MoneyRequestReportPreviewProvider.tsx | 10 +- .../PayActionButton.tsx | 59 +- .../SubmitActionButton.tsx | 31 +- .../useConfirmApproveReportAction.ts | 65 + .../useReportPreviewActionButtonData.ts | 34 + .../useReportPreviewCarousel.tsx | 17 +- .../ReportActionItem/MoneyRequestView.tsx | 21 +- .../TransactionPreviewContent.tsx | 19 +- .../TransactionPreview/index.tsx | 5 +- .../TransactionPreview/types.ts | 5 +- src/components/ReportPDFDownloadModal.tsx | 21 +- src/components/Rule/TextBase.tsx | 41 +- .../FilterComponents/BankAccountSelector.tsx | 19 +- .../Search/FilterComponents/CardSelector.tsx | 21 +- .../Search/FilterComponents/MultiSelect.tsx | 14 +- .../Search/FilterComponents/SingleSelect.tsx | 39 +- .../FilterComponents/WorkspaceSelector.tsx | 18 +- .../Search/FilterDropdowns/DropdownButton.tsx | 2 +- .../Search/SearchAutocompleteList.tsx | 36 +- .../ListItem/ActionCell/PayActionCell.tsx | 2 + .../ListItem/BaseListItemHeader.tsx | 18 +- .../ListItem/CardListItemHeader.tsx | 17 +- .../ListItem/ExpenseReportListItem.tsx | 4 +- .../ListItem/MemberListItemHeader.tsx | 17 +- .../ListItem/TransactionGroupListExpanded.tsx | 27 +- .../ListItem/TransactionGroupListItem.tsx | 19 +- .../ListItem/TransactionListItem/index.tsx | 2 +- .../ListItem/WithdrawalIDListItemHeader.tsx | 17 +- .../Search/SearchRouter/useAskConcierge.tsx | 3 +- .../Search/SearchSingleSelectionPicker.tsx | 14 +- src/components/Search/index.tsx | 17 + .../SelectionList/BaseSelectionList.tsx | 32 +- .../BaseSelectionListWithSections.tsx | 30 +- .../hooks/useFlattenedSections.ts | 46 +- src/components/SettlementButton/index.tsx | 9 +- .../SidePanel/RHPVariantTest/index.ts | 11 +- src/components/SubStepForms/AddressStep.tsx | 19 +- .../SubStepForms/DateOfBirthStep.tsx | 21 +- .../SubStepForms/DocusignFullStep.tsx | 27 +- src/components/SubStepForms/FullNameStep.tsx | 23 +- .../SubStepForms/PushRowFieldsStep.tsx | 30 +- .../SubStepForms/RegistrationNumberStep.tsx | 30 +- .../TabSelector/TabSelectorBase.tsx | 4 +- .../TabSelector/TabSelectorContext.tsx | 6 +- src/components/TabSelector/types.context.ts | 2 +- src/components/TabSelector/types.ts | 16 +- src/components/Table/Table.tsx | 51 +- src/components/Table/middlewares/filtering.ts | 54 +- src/components/Table/middlewares/sorting.ts | 52 +- .../Tables/AgentsTable/AgentsTableRow.tsx | 9 +- src/components/Tables/AgentsTable/index.tsx | 15 +- .../Tables/RoomMembersTable/index.tsx | 5 +- .../WorkspaceCategoryRulesTable/index.tsx | 21 +- .../Tables/WorkspaceViewTagsTable/index.tsx | 1 + src/components/TestDrive/TestDriveDemo.tsx | 4 +- .../EditableCell/usePopoverEditState.ts | 96 +- .../TransactionItemRowRBR.tsx | 5 +- src/components/VacationDelegateMenuItem.tsx | 4 +- src/components/createOnyxContext.tsx | 26 +- .../withCurrentUserPersonalDetails.tsx | 25 +- src/components/withNavigationFallback.tsx | 48 +- .../withNavigationTransitionEnd.tsx | 44 +- src/components/withToggleVisibilityView.tsx | 38 +- src/components/withViewportOffsetTop.tsx | 52 +- src/hooks/useCardsLists.tsx | 26 + src/hooks/useChatWithAgent.ts | 14 +- src/hooks/useDebounce.ts | 42 +- src/hooks/useDebounceNonReactive.ts | 52 +- src/hooks/useDeleteTransactions.ts | 4 + src/hooks/useFilesValidation.tsx | 123 +- src/hooks/useFilteredOptions.ts | 11 +- src/hooks/useHasReportAwaitingApproval.ts | 60 + src/hooks/useInFlightRequests.ts | 133 ++ src/hooks/useInitialSelection.ts | 38 +- src/hooks/useLazyAsset.ts | 114 +- src/hooks/useLoadingBarVisibility.ts | 33 - src/hooks/useOnboardingFlow.ts | 11 +- src/hooks/useOnyx.ts | 47 +- src/hooks/useOptimisticNextStep.ts | 4 + src/hooks/usePaymentContext.tsx | 5 +- src/hooks/usePaymentOptions.ts | 9 +- src/hooks/useProactiveAppReview.ts | 58 +- src/hooks/useSearchBulkActions.ts | 4 +- src/hooks/useSearchTypeMenuSections.ts | 13 +- src/hooks/useSelectedExpenseReports.ts | 44 + src/hooks/useShortMentionsList.ts | 6 +- src/hooks/useStableIndexedHandler.ts | 40 +- src/hooks/useStepFormSubmit.ts | 57 +- src/hooks/useSubPage/index.tsx | 139 +-- src/hooks/useTransactionInlineEdit.ts | 5 + src/hooks/useWorkletStateMachine/index.ts | 160 +-- src/languages/de.ts | 77 +- src/languages/en.ts | 77 +- src/languages/es.ts | 78 +- src/languages/fr.ts | 78 +- src/languages/it.ts | 73 +- src/languages/ja.ts | 81 +- src/languages/nl.ts | 73 +- src/languages/pl.ts | 74 +- src/languages/pt-BR.ts | 73 +- src/languages/zh-hans.ts | 73 +- src/libs/API/index.ts | 20 +- .../API/parameters/BeginAppleSignInParams.ts | 1 + .../API/parameters/BeginGoogleSignInParams.ts | 1 + .../ImportMerchantRulesSpreadsheet.ts | 12 + .../JoinReportViaSecureLinkParams.ts | 6 + src/libs/API/parameters/MergeReportsParams.ts | 7 + .../UpdateRilletCardProgramAccountParams.ts | 10 + ...ateRilletExportToMultipleAccountsParams.ts | 6 + src/libs/API/parameters/index.ts | 5 + src/libs/API/types.ts | 10 + src/libs/AgentRulesUtils.ts | 36 + src/libs/CardFeedUtils.ts | 90 +- src/libs/ExportOnyxState/common.ts | 2 + src/libs/FullReconnectUtils.ts | 44 +- src/libs/IOUAmountSubmission.ts | 6 + .../Middleware/HandleUnusedOptimisticID.ts | 76 +- .../Middleware/RecordFullReconnectTime.ts | 26 + src/libs/Middleware/index.ts | 3 +- .../AppNavigator/AuthScreensInitHandler.tsx | 5 +- .../ModalStackNavigators/index.tsx | 10 +- .../createRightModalNavigator/index.tsx | 7 +- .../GetStateForActionHandlers.ts | 1 + .../RootStackRouter.ts | 55 + .../createRootStackNavigator/index.tsx | 19 +- .../createSearchFullscreenNavigator/index.tsx | 11 +- .../useCustomState/index.ts | 4 +- .../createSplitNavigator/index.tsx | 15 +- .../createWorkspaceNavigator/index.tsx | 7 +- .../AppNavigator/withAgentAccessDenied.tsx | 47 +- .../index.native.tsx | 199 +-- .../index.tsx | 232 ++-- .../types/NavigatorComponent.ts | 20 +- .../helpers/getCentralPaneReportID.ts | 23 + .../helpers/getRouteBeneathTopmostRHP.ts | 21 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 6 + src/libs/Navigation/linkingConfig/config.ts | 17 +- .../Navigation/linkingConfig/subscribe.ts | 8 + src/libs/Navigation/types.ts | 36 +- src/libs/Network/SequentialQueue.ts | 5 +- src/libs/NextStepUtils.ts | 30 +- .../LocalNotification/BrowserNotifications.ts | 68 +- src/libs/OptionsListUtils/index.ts | 40 +- src/libs/PersonalDetailsUtils.ts | 48 +- src/libs/PolicyUtils.ts | 144 ++- src/libs/Reauthentication.ts | 25 +- src/libs/ReportActionFollowupUtils/index.ts | 4 +- src/libs/ReportActionsUtils.ts | 40 +- src/libs/ReportPreviewActionUtils.ts | 7 +- src/libs/ReportPrimaryActionUtils.ts | 40 +- src/libs/ReportSecondaryActionUtils.ts | 13 +- src/libs/ReportUtils.ts | 433 +++++-- src/libs/SearchUIUtils.ts | 31 +- src/libs/SidebarUtils.ts | 6 + src/libs/TransactionPreviewUtils.ts | 27 +- src/libs/TransactionUtils/index.ts | 49 +- src/libs/Violations/ViolationsUtils.ts | 7 +- src/libs/actions/Agent.ts | 3 +- src/libs/actions/App.ts | 48 +- src/libs/actions/CompanyCards.ts | 3 +- src/libs/actions/IOU/BulkEdit.ts | 4 + src/libs/actions/IOU/Hold.ts | 2 + src/libs/actions/IOU/MoneyRequestBuilder.ts | 22 +- src/libs/actions/IOU/PayMoneyRequest.ts | 17 +- src/libs/actions/IOU/PerDiem.ts | 1 + src/libs/actions/IOU/Receipt.ts | 43 +- src/libs/actions/IOU/RejectMoneyRequest.ts | 10 +- src/libs/actions/IOU/ReportWorkflow.ts | 86 +- src/libs/actions/IOU/Split.ts | 53 +- .../actions/IOU/SplitTransactionUpdate.ts | 21 +- src/libs/actions/IOU/TrackExpense.ts | 31 +- src/libs/actions/IOU/UpdateMoneyRequest.ts | 90 +- .../IOU/types/CreateTrackExpenseParams.ts | 6 +- .../actions/IOU/types/TrackedExpenseParams.ts | 2 - src/libs/actions/Link.ts | 2 +- src/libs/actions/MergeTransaction.ts | 19 +- .../OnyxDerived/configs/reportAttributes.ts | 13 +- src/libs/actions/OnyxUpdates.ts | 56 +- src/libs/actions/Policy/Category.ts | 30 +- src/libs/actions/Policy/Member.ts | 52 +- src/libs/actions/Policy/Policy.ts | 64 +- src/libs/actions/Policy/Rules.ts | 44 +- .../ReimbursementAccount/navigation.ts | 13 - src/libs/actions/Report/SuggestedFollowup.ts | 2 + src/libs/actions/Report/index.ts | 524 ++++++-- src/libs/actions/RequestConflictUtils.ts | 5 + src/libs/actions/Search.ts | 45 +- src/libs/actions/Session/index.ts | 20 +- src/libs/actions/SplitExpenses.ts | 5 +- src/libs/actions/Task.ts | 9 +- src/libs/actions/Transaction.ts | 66 +- src/libs/actions/TransactionInlineEdit.ts | 10 +- .../actions/TransactionThreadNavigation.ts | 34 +- src/libs/actions/connections/Rillet.ts | 110 ++ src/libs/cropOrRotateImage/index.native.ts | 5 +- src/libs/fileDownload/FileUtils.ts | 3 +- .../fileDownload/checkFileExists/index.ts | 32 +- src/libs/fileDownload/index.android.ts | 5 +- src/libs/fileURIToPath.ts | 24 + src/libs/genericMemo.ts | 16 - .../index.native.ts | 13 +- src/libs/navigateAfterOnboarding.ts | 9 +- src/libs/telemetry/getSendMessageSource.ts | 155 +++ src/libs/validateAttachmentFile.ts | 9 + src/pages/DynamicEditReportFieldPage.tsx | 2 +- .../DynamicReportChangeWorkspacePage.tsx | 5 +- src/pages/DynamicReportDetailsPage.tsx | 2 + .../DynamicReportParticipantsInvitePage.tsx | 30 +- src/pages/DynamicRoomInvitePage.tsx | 27 +- .../BaseOnboardingInterestedFeatures.tsx | 2 +- .../BaseOnboardingWorkspaces.tsx | 2 +- src/pages/ProfilePage.tsx | 16 +- .../subSteps/AccountHolderDetails.tsx | 1 + .../ConnectBankAccount/ConnectBankAccount.tsx | 7 +- .../components/BankAccountValidationForm.tsx | 16 +- .../USD/USDVerifiedBankAccountFlowPage.tsx | 8 +- .../SearchEditMultiplePage.tsx | 14 +- .../SearchPageNarrow/StaticSearchTypeMenu.tsx | 6 +- src/pages/Search/SearchPageNarrow/index.tsx | 2 +- src/pages/Search/SearchTypeMenuNarrow.tsx | 4 +- src/pages/Share/ShareDetailsPage.tsx | 1 + src/pages/Share/SubmitDetailsPage.tsx | 2 + src/pages/Share/getFileSize/index.native.ts | 4 +- src/pages/ShareCodePage.tsx | 37 +- .../TransactionMerge/ConfirmationPage.tsx | 6 + src/pages/home/ForYouSection/index.tsx | 4 + .../ForYouSection/shouldHideForYouSection.ts | 13 +- .../hooks/useGettingStartedItems.ts | 11 + src/pages/home/RecentlyAddedSection/index.tsx | 2 +- .../useRecentlyAddedData.ts | 45 +- src/pages/inbox/HeaderView.tsx | 33 + src/pages/inbox/ReportFetchHandler.tsx | 39 +- src/pages/inbox/ReportNavigateAwayHandler.tsx | 12 +- .../BaseReportActionContextMenu.tsx | 14 +- .../report/ContextMenu/ContextMenuActions.tsx | 24 +- .../AttachmentPickerWithMenuItems.tsx | 15 +- .../ReportActionCompose/SuggestionMention.tsx | 14 +- .../ReportActionCompose/useComposerSubmit.ts | 19 +- .../ReportActionCompose/useReceiptDrop.ts | 3 +- src/pages/inbox/report/ReportActionsList.tsx | 4 +- .../inbox/report/ReportActionsListHeader.tsx | 8 +- .../inbox/report/ReportTypingIndicator.tsx | 2 +- .../actionContents/MemberChangeContent.tsx | 9 +- .../inbox/report/useDebouncedSaveDraft.ts | 60 +- .../withReportAndPrivateNotesOrNotFound.tsx | 158 +-- .../withReportAndReportActionOrNotFound.tsx | 131 +- src/pages/iou/DynamicSplitBillDetailsPage.tsx | 4 + ...eEditPage.tsx => SplitExpenseEditPage.tsx} | 20 +- src/pages/iou/SplitExpensePage.tsx | 8 +- .../request/IOURequestRedirectToStartPage.tsx | 14 +- .../iou/request/ParticipantSearchResults.tsx | 3 + .../iou/request/step/IOURequestStepAmount.tsx | 8 +- .../request/step/IOURequestStepAttendees.tsx | 7 + .../request/step/IOURequestStepCategory.tsx | 5 + .../step/IOURequestStepCategoryCreate.tsx | 5 + .../iou/request/step/IOURequestStepDate.tsx | 3 + .../step/IOURequestStepDescription.tsx | 5 + .../request/step/IOURequestStepDistance.tsx | 11 + .../step/IOURequestStepDistanceManual.tsx | 5 + .../step/IOURequestStepDistanceOdometer.tsx | 5 + .../step/IOURequestStepDistanceRate.tsx | 5 + .../request/step/IOURequestStepMerchant.tsx | 3 + .../components/ScanEditReceipt.tsx | 5 +- .../components/ScanSkipConfirmation.tsx | 8 +- .../iou/request/step/IOURequestStepTag.tsx | 5 + .../step/IOURequestStepTaxAmountPage.tsx | 3 + .../step/IOURequestStepTaxRatePage.tsx | 3 + .../request/step/IOURequestStepUpgrade.tsx | 9 +- .../step/confirmation/useExpenseSubmission.ts | 48 +- .../step/withFullTransactionOrNotFound.tsx | 85 +- .../step/withWritableReportOrNotFound.tsx | 95 +- .../routes/TransactionReceiptModalContent.tsx | 6 +- .../ReportAddAttachmentModalContent/index.tsx | 41 +- src/pages/settings/Agents/AgentsPage.tsx | 144 ++- .../DynamicExitSurveyConfirmPage.tsx | 27 +- src/pages/settings/HelpPage/HelpPage.tsx | 51 +- .../Profile/Contacts/ContactMethodsPage.tsx | 17 +- .../Security/DeviceManagementPage.tsx | 12 +- .../Security/TwoFactorAuth/DisablePage.tsx | 11 +- .../Security/TwoFactorAuth/DisabledPage.tsx | 11 +- .../DynamicTwoFactorAuthPage.tsx | 11 +- .../TwoFactorAuth/DynamicVerifyPage.tsx | 11 +- .../ReplaceDeviceVerifyNewPage.tsx | 11 +- .../ReplaceDeviceVerifyOldPage.tsx | 11 +- .../CancelSubscriptionPage/index.tsx | 20 +- .../SaveWithExpensifyButton/index.tsx | 9 +- .../Subscription/SubscriptionPlan/index.tsx | 9 +- .../subPages/Confirmation.tsx | 12 +- .../Wallet/ImportTransactionsPage.tsx | 11 +- .../PersonalInfo/PersonalInfo.tsx | 17 + ...rsonalCardEditTransactionStartDatePage.tsx | 13 +- .../upgrade/PersonalCardUpgradePage.tsx | 3 + .../PersonalCards/upgrade/UpgradeIntro.tsx | 11 +- .../UnshareBankAccount/UnshareBankAccount.tsx | 20 +- .../settings/Wallet/WalletPage/index.tsx | 18 +- src/pages/settings/Wallet/WalletPage/utils.ts | 19 + .../signin/SAMLSignInPage/index.native.tsx | 5 + src/pages/signin/SignInModal.tsx | 5 + src/pages/signin/SignInPage.tsx | 12 +- ...ConnectExistingBusinessBankAccountPage.tsx | 2 +- .../DynamicWorkspaceConfirmationPage.tsx | 3 + src/pages/workspace/WorkspaceMembersPage.tsx | 2 - .../WorkspaceMoreFeaturesPage/index.tsx | 6 +- src/pages/workspace/WorkspacesListPage.tsx | 5 +- .../accounting/AccountingContext/index.tsx | 10 + .../workspace/accounting/ClaimOfferPage.tsx | 18 +- .../accounting/PolicyAccountingPage.tsx | 21 +- .../DynamicSageIntacctPrerequisitesPage.tsx | 17 +- .../import/SageIntacctUserDimensionsPage.tsx | 17 +- .../import/NetSuiteImportCustomFieldPage.tsx | 11 +- .../qbd/QuickBooksDesktopSetupPage.tsx | 19 +- .../qbd/RequireQuickBooksDesktopPage.tsx | 19 +- .../rillet/advanced/RilletAdvancedPage.tsx | 8 +- .../advanced/RilletBillPaymentAccountPage.tsx | 2 +- ...lletExpensifyCardSettlementAccountPage.tsx | 2 +- ...etTravelInvoicingSettlementAccountPage.tsx | 2 +- .../rillet/export/RilletCardAccount.tsx | 90 ++ .../export/RilletCardAccountCardList.tsx | 114 ++ .../export/RilletCardProgramAccount.tsx | 91 ++ .../RilletCardProgramAccountSelector.tsx | 125 ++ .../export/RilletCompanyCardAccountPage.tsx | 2 +- .../rillet/export/RilletExportPage.tsx | 67 +- .../rillet/import/RilletImportPage.tsx | 4 +- src/pages/workspace/accounting/types.ts | 4 +- src/pages/workspace/accounting/utils.tsx | 19 +- .../DynamicDefaultCategorySelectorPage.tsx | 12 +- ...ompanyCardEditTransactionStartDatePage.tsx | 13 +- .../assignCard/TransactionStartDateStep.tsx | 13 +- src/pages/workspace/companyCards/utils.tsx | 48 + .../CopyPolicySettingsConfirmPage.tsx | 11 +- .../deleteWorkspace/DeleteWorkspaceFlow.tsx | 33 +- .../PolicyCommuterExclusionsPage.tsx | 11 +- .../workspace/downgrade/DowngradeIntro.tsx | 20 +- .../downgrade/DynamicPayAndDowngradePage.tsx | 14 +- .../expensifyCard/WorkspaceCardsListLabel.tsx | 23 + .../workspace/hr/HRApprovalModePageBase.tsx | 11 +- .../workspace/hr/merge/MergeHRGroupsPage.tsx | 11 +- .../invoices/WorkspaceInvoiceVBASection.tsx | 7 +- .../WorkspaceInviteMessageComponent.tsx | 6 +- .../members/WorkspaceOwnerChangeCheck.tsx | 11 +- .../workspace/rooms/WorkspaceRoomsPage.tsx | 14 +- .../rules/AgentRules/AddAgentRulePage.tsx | 48 +- .../rules/AgentRules/EditAgentRulePage.tsx | 17 +- .../workspace/rules/AgentRulesSection.tsx | 113 +- .../MerchantRules/ImportMerchantRulesPage.tsx | 35 + .../ImportedMerchantRulesPage.tsx | 295 +++++ src/pages/workspace/rules/PolicyRulesPage.tsx | 28 +- .../workspace/rules/PolicyRulesPageRevamp.tsx | 102 +- src/pages/workspace/rules/RulesNewPage.tsx | 37 +- .../rules/RulesProhibitedDefaultPage.tsx | 11 +- .../rules/RulesRequireFieldsPage.tsx | 11 +- .../rules/getImportMerchantRulesOption.ts | 53 + .../workspace/rules/tabs/RulesAgentsTab.tsx | 110 ++ .../rules/tabs/RulesCardRestrictionsTab.tsx | 2 + .../workspace/rules/tabs/RulesGeneralTab.tsx | 5 +- .../rules/tabs/useRulesTableBulkActions.ts | 6 +- .../tags/ImportMultiLevelTagsSettingsPage.tsx | 11 +- .../WorkspaceTravelInvoicingSection.tsx | 9 +- .../workspace/upgrade/GenericFeaturesView.tsx | 20 +- .../workspace/upgrade/UpgradeIntroView.tsx | 2 +- .../upgrade/WorkspaceUpgradePage.tsx | 5 +- src/pages/workspace/withPolicy.tsx | 57 +- .../withPolicyAndFullscreenLoading.tsx | 66 +- src/pages/workspace/withPolicyConnections.tsx | 71 +- .../workflows/WorkspaceWorkflowsPage.tsx | 30 +- .../workflows/WorkspaceWorkflowsPayerPage.tsx | 27 +- src/selectors/PersonalDetails.ts | 11 +- src/selectors/ReportMetaData.ts | 17 +- src/setup/moduleInitPolyfill.ts | 4 +- .../MoneyRequestReportPreview.stories.tsx | 1 + .../TransactionPreviewContent.stories.tsx | 1 + src/styles/index.ts | 19 + src/styles/utils/sizing.ts | 4 + src/styles/variables.ts | 4 + src/types/env.d.ts | 1 + src/types/global.d.ts | 3 +- src/types/onyx/Account.ts | 3 + src/types/onyx/Policy.ts | 13 +- src/types/onyx/SearchResults.ts | 12 + src/types/onyx/Transaction.ts | 4 +- tests/actions/IOU/RequestMoneyTest.ts | 4 +- tests/actions/IOU/SplitReportTotalsTest.ts | 2 + tests/actions/IOUTest/BulkEditTest.ts | 30 + .../actions/IOUTest/DeleteMoneyRequestTest.ts | 2 + .../GetUpdateMoneyRequestParamsTest.ts | 6 + tests/actions/IOUTest/ReceiptTest.ts | 69 +- .../actions/IOUTest/RejectMoneyRequestTest.ts | 66 +- tests/actions/IOUTest/ReportWorkflowTest.ts | 139 ++- .../IOUTest/SplitDistanceMessageTest.ts | 9 + tests/actions/IOUTest/SplitSelfDMTest.ts | 6 + tests/actions/IOUTest/SplitTest.ts | 113 +- tests/actions/IOUTest/TrackExpenseTest.ts | 190 +-- .../actions/IOUTest/UpdateMoneyRequestTest.ts | 120 ++ tests/actions/MergeTransactionTest.ts | 46 +- tests/actions/PolicyMemberTest.ts | 73 +- tests/actions/PolicyTest.ts | 87 +- tests/actions/ReportPreviewActionUtilsTest.ts | 209 +++- tests/actions/ReportTest.ts | 459 ++++++- tests/actions/SessionTest.ts | 60 + tests/actions/TaskTest.ts | 58 +- tests/actions/TransactionTest.ts | 12 +- tests/perf-test/ReportUtils.perf-test.ts | 4 +- tests/ui/BankAccountSelectorTest.tsx | 164 +++ ...VacationDelegateSelectionComponentTest.tsx | 4 +- tests/ui/CardSelectorTest.tsx | 163 +++ tests/ui/HelpPageTest.tsx | 84 ++ .../ui/IOURequestRedirectToStartPageTest.tsx | 176 +++ tests/ui/MultiSelectTest.tsx | 118 ++ tests/ui/SessionTest.tsx | 120 +- tests/ui/SingleSelectTest.tsx | 115 ++ tests/ui/WorkspaceMembersTest.tsx | 4 + tests/ui/WorkspaceSelectorTest.tsx | 147 +++ .../AccountManagerBookCallButtonTest.tsx | 79 ++ tests/ui/components/ComposedButton.tsx | 2 +- tests/ui/components/HeaderViewTest.tsx | 95 +- .../MoneyReportHeaderMoreContentTest.tsx | 36 +- tests/ui/components/PayActionButtonTest.tsx | 2 +- .../components/SearchAutocompleteListTest.tsx | 3 + .../ui/components/SubmitActionButtonTest.tsx | 2 +- tests/unit/AgentActionTest.ts | 4 +- tests/unit/CopyPolicySettingsConfirmTest.tsx | 29 +- tests/unit/DeepLinkHandlerTest.tsx | 121 ++ tests/unit/FullReconnectUtilsTest.ts | 76 +- .../useRecentlyAddedDataTest.ts | 22 + tests/unit/IOUAmountSubmissionTest.ts | 1 + tests/unit/ImportedMerchantRulesPageTest.ts | 57 + tests/unit/MiddlewareTest.ts | 318 +++++ .../handleNavigationGuardRedirect.test.ts | 199 +++ tests/unit/NextStepUtilsTest.ts | 35 +- tests/unit/OnyxUpdatesTest.ts | 95 ++ tests/unit/OptionsListUtilsTest.tsx | 60 +- tests/unit/PolicyUtilsTest.ts | 123 +- tests/unit/PromisePoolTest.ts | 24 + tests/unit/PromotedActionsBarTest.ts | 17 +- tests/unit/QuickActionMenuItemTest.tsx | 70 ++ .../RecordFullReconnectTimeMiddlewareTest.ts | 92 ++ tests/unit/ReportActionsListHeaderTest.tsx | 6 +- tests/unit/ReportActionsUtilsTest.ts | 52 + .../ReportAddApproverPageTranslateTest.tsx | 97 ++ tests/unit/ReportPrimaryActionUtilsTest.ts | 218 ++++ tests/unit/ReportSecondaryActionUtilsTest.ts | 195 +++ tests/unit/ReportTypingIndicatorTest.tsx | 63 + tests/unit/ReportUtilsTest.ts | 987 +++++++++++++-- tests/unit/Search/SearchQueryUtilsTest.ts | 31 + tests/unit/Search/SearchUIUtilsTest.ts | 24 + tests/unit/Search/searchSnapshotStateTest.ts | 142 +++ .../SearchAddApproverPageTranslateTest.tsx | 95 ++ tests/unit/SequentialQueueTest.ts | 32 + tests/unit/ShareCodePageTranslateTest.tsx | 162 +++ tests/unit/SidebarUtilsTest.ts | 118 +- tests/unit/SubscribeToFullReconnectTest.ts | 110 +- tests/unit/SuggestionMentionTest.tsx | 20 +- tests/unit/TaskViewTranslateTest.tsx | 75 ++ tests/unit/TransactionPreviewUtils.test.ts | 43 +- tests/unit/TransactionUtilsTest.ts | 26 +- tests/unit/UserAvatarUtilsTest.ts | 9 + tests/unit/UserSelectionListItemTest.tsx | 55 + tests/unit/ValidateAttachmentFileTest.ts | 72 ++ tests/unit/ViolationUtilsTest.ts | 127 +- tests/unit/WalletPageUtilsTest.ts | 50 + tests/unit/checkFileExistsTest.ts | 35 + .../components/Form/FormProvider.test.tsx | 235 ++++ .../unit/components/Form/FormWrapper.test.tsx | 126 ++ .../computePieLabelLayout.test.ts | 258 ++++ ...questReportTransactionsNavigation.test.tsx | 374 ++++++ .../SidePanel/RHPVariantTest.test.ts | 96 ++ tests/unit/computeAdjustedOverlayYTest.ts | 31 + tests/unit/computeDynamicChartHeightTest.ts | 105 ++ tests/unit/createRetestRequestForCPTest.ts | 82 ++ tests/unit/fileURIToPathTest.ts | 39 + tests/unit/getCentralPaneReportIDTest.ts | 40 + tests/unit/getFileSizeTest.ts | 34 + tests/unit/getRouteBeneathTopmostRHPTest.ts | 56 + tests/unit/getSendMessageSourceTest.ts | 424 +++++++ .../unit/hooks/useGettingStartedItems.test.ts | 161 +++ tests/unit/hooks/useLazyAsset.test.ts | 25 +- tests/unit/libs/PersonalDetailsUtilsTest.ts | 82 +- tests/unit/libs/receiptDurableStorageTest.ts | 105 +- tests/unit/navigateAfterOnboardingTest.ts | 43 +- .../shouldHideForYouSectionTest.ts | 9 +- .../pages/settings/AgentsTableRowTest.tsx | 45 +- .../unit/useHasReportAwaitingApprovalTest.tsx | 134 ++ tests/unit/useInFlightRequestsTest.ts | 143 +++ tests/unit/useProactiveAppReviewTest.ts | 56 + .../useReportActionAvatarsTranslateTest.tsx | 57 + tests/unit/verifyFileFormatTest.ts | 33 + tests/unit/withAgentAccessDenied.test.tsx | 52 +- 709 files changed, 24410 insertions(+), 4912 deletions(-) create mode 100644 assets/images/product-illustrations/illustration_agents-ice-cream.svg create mode 100644 docs/articles/Unlisted/Classic-Submit-Classic-Track-vs-Submit.md create mode 100644 docs/articles/new-expensify/connections/certinia/Configure-Certinia.md create mode 100644 docs/articles/new-expensify/connections/certinia/Connect-To-Certinia.md create mode 100644 docs/articles/new-expensify/connections/certinia/Troubleshooting/Certinia-FAQ.md create mode 100644 docs/articles/new-expensify/expensify-card/Approve-UK-EU-Expensify-Card-Transactions-Using-3DS-Authentication.md create mode 100644 docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-UK-EU.md rename docs/new-expensify/hubs/connections/{certinia/Troubleshooting/Authentication-and-Login-errors.html => certinia.html} (100%) delete mode 100644 docs/new-expensify/hubs/connections/certinia/Troubleshooting/Export-Errors.html create mode 100644 patches/react-native/react-native+0.85.3+038+nested-text-border-width.patch create mode 100644 patches/react-native/react-native+0.85.3+039+nested-text-padding.patch create mode 100644 scripts/createRetestRequestForCP.ts create mode 100644 server/libs/log.ts create mode 100644 server/libs/rsyslogWriter.ts create mode 100644 server/victory-chart-renderer/src/log.ts create mode 100644 server/victory-chart-renderer/tests/__golden__/top-categories-crowded-slices.png create mode 100644 server/victory-chart-renderer/tests/__golden__/top-categories-single-slice.png create mode 100644 server/victory-chart-renderer/tests/fixtures/top-categories-crowded-slices.xml create mode 100644 server/victory-chart-renderer/tests/fixtures/top-categories-single-slice.xml create mode 100644 server/victory-chart-renderer/tests/log.test.ts create mode 100644 src/components/AccountManagerBookCallButton.tsx create mode 100644 src/components/AgentRules/AgentRulesList.tsx create mode 100644 src/components/AgentRules/useAgentRulesSectionHeader.tsx create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeAdjustedOverlayY.ts create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeDynamicChartHeight.ts create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieLabelLayout.ts create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getFontGlyphWidth.ts create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolvePadding.ts rename src/components/Icon/{ => InlineIcon}/IconWrapperStyles/index.ios.ts (100%) rename src/components/Icon/{ => InlineIcon}/IconWrapperStyles/index.ts (100%) rename src/components/Icon/{ => InlineIcon}/IconWrapperStyles/types.ts (100%) create mode 100644 src/components/Icon/InlineIcon/index.tsx delete mode 100644 src/components/Icon/primitives/BaseIcon.tsx delete mode 100644 src/components/Icon/primitives/InlineIcon.tsx delete mode 100644 src/components/Icon/primitives/types.ts rename src/components/Icon/{primitives => utils}/resolveIconSize.ts (78%) create mode 100644 src/components/ReportActionItem/LocalPDFReceiptPreview/index.native.tsx create mode 100644 src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx create mode 100644 src/components/ReportActionItem/LocalPDFReceiptPreview/types.ts create mode 100644 src/components/ReportActionItem/MoneyRequestReportPreview/useConfirmApproveReportAction.ts create mode 100644 src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionButtonData.ts create mode 100644 src/hooks/useCardsLists.tsx create mode 100644 src/hooks/useHasReportAwaitingApproval.ts create mode 100644 src/hooks/useInFlightRequests.ts delete mode 100644 src/hooks/useLoadingBarVisibility.ts create mode 100644 src/hooks/useSelectedExpenseReports.ts create mode 100644 src/libs/API/parameters/ImportMerchantRulesSpreadsheet.ts create mode 100644 src/libs/API/parameters/JoinReportViaSecureLinkParams.ts create mode 100644 src/libs/API/parameters/MergeReportsParams.ts create mode 100644 src/libs/API/parameters/UpdateRilletCardProgramAccountParams.ts create mode 100644 src/libs/API/parameters/UpdateRilletExportToMultipleAccountsParams.ts create mode 100644 src/libs/AgentRulesUtils.ts create mode 100644 src/libs/Middleware/RecordFullReconnectTime.ts create mode 100644 src/libs/Navigation/helpers/getCentralPaneReportID.ts create mode 100644 src/libs/Navigation/helpers/getRouteBeneathTopmostRHP.ts create mode 100644 src/libs/fileURIToPath.ts delete mode 100644 src/libs/genericMemo.ts create mode 100644 src/libs/telemetry/getSendMessageSource.ts rename src/pages/iou/{DynamicSplitExpenseEditPage.tsx => SplitExpenseEditPage.tsx} (97%) create mode 100644 src/pages/settings/Wallet/WalletPage/utils.ts create mode 100644 src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx create mode 100644 src/pages/workspace/accounting/rillet/export/RilletCardAccountCardList.tsx create mode 100644 src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx create mode 100644 src/pages/workspace/accounting/rillet/export/RilletCardProgramAccountSelector.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/ImportMerchantRulesPage.tsx create mode 100644 src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx create mode 100644 src/pages/workspace/rules/getImportMerchantRulesOption.ts create mode 100644 src/pages/workspace/rules/tabs/RulesAgentsTab.tsx create mode 100644 tests/ui/BankAccountSelectorTest.tsx create mode 100644 tests/ui/CardSelectorTest.tsx create mode 100644 tests/ui/HelpPageTest.tsx create mode 100644 tests/ui/IOURequestRedirectToStartPageTest.tsx create mode 100644 tests/ui/MultiSelectTest.tsx create mode 100644 tests/ui/SingleSelectTest.tsx create mode 100644 tests/ui/WorkspaceSelectorTest.tsx create mode 100644 tests/ui/components/AccountManagerBookCallButtonTest.tsx create mode 100644 tests/unit/DeepLinkHandlerTest.tsx create mode 100644 tests/unit/ImportedMerchantRulesPageTest.ts create mode 100644 tests/unit/Navigation/guards/handleNavigationGuardRedirect.test.ts create mode 100644 tests/unit/QuickActionMenuItemTest.tsx create mode 100644 tests/unit/RecordFullReconnectTimeMiddlewareTest.ts create mode 100644 tests/unit/ReportAddApproverPageTranslateTest.tsx create mode 100644 tests/unit/ReportTypingIndicatorTest.tsx create mode 100644 tests/unit/Search/searchSnapshotStateTest.ts create mode 100644 tests/unit/SearchAddApproverPageTranslateTest.tsx create mode 100644 tests/unit/ShareCodePageTranslateTest.tsx create mode 100644 tests/unit/TaskViewTranslateTest.tsx create mode 100644 tests/unit/UserSelectionListItemTest.tsx create mode 100644 tests/unit/WalletPageUtilsTest.ts create mode 100644 tests/unit/components/Form/FormProvider.test.tsx create mode 100644 tests/unit/components/Form/FormWrapper.test.tsx create mode 100644 tests/unit/components/HTMLEngineProvider/computePieLabelLayout.test.ts create mode 100644 tests/unit/components/MoneyRequestReportTransactionsNavigation.test.tsx create mode 100644 tests/unit/components/SidePanel/RHPVariantTest.test.ts create mode 100644 tests/unit/computeAdjustedOverlayYTest.ts create mode 100644 tests/unit/computeDynamicChartHeightTest.ts create mode 100644 tests/unit/createRetestRequestForCPTest.ts create mode 100644 tests/unit/fileURIToPathTest.ts create mode 100644 tests/unit/getCentralPaneReportIDTest.ts create mode 100644 tests/unit/getFileSizeTest.ts create mode 100644 tests/unit/getRouteBeneathTopmostRHPTest.ts create mode 100644 tests/unit/getSendMessageSourceTest.ts create mode 100644 tests/unit/useHasReportAwaitingApprovalTest.tsx create mode 100644 tests/unit/useInFlightRequestsTest.ts create mode 100644 tests/unit/useProactiveAppReviewTest.ts create mode 100644 tests/unit/useReportActionAvatarsTranslateTest.tsx create mode 100644 tests/unit/verifyFileFormatTest.ts diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js index 8b08eb71cf80..62451e2cc656 100644 --- a/.github/actions/javascript/bumpVersion/index.js +++ b/.github/actions/javascript/bumpVersion/index.js @@ -1763,6 +1763,356 @@ function checkBypass(reqUrl) { exports.checkBypass = checkBypass; //# sourceMappingURL=proxy.js.map +/***/ }), + +/***/ 7720: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +/** + * This file contains a CLI utility class which can be used to declaratively implement a strongly-typed CLI. + * You provide a CLIConfig defining your arguments, then the class will handle parsing argv, type validation, error handling, and help messages. + */ +const readline = __importStar(__nccwpck_require__(4521)); +const SafeString_js_1 = __importDefault(__nccwpck_require__(6690)); +class CLI { + constructor(config) { + var _a, _b, _c, _d, _e, _f; + this.config = config; + const rawArgs = process.argv.slice(2); + // Initialize all flags to false by default (including built-in flags) + this.flags = Object.assign(Object.assign({}, Object.fromEntries(Object.keys((_a = config.flags) !== null && _a !== void 0 ? _a : {}).map((key) => [key, false]))), { yes: false, no: false, help: false }); + try { + const parsedNamedArgs = {}; + const parsedPositionalArgs = {}; + const providedNamedArgs = new Set(); + let positionalIndex = 0; + for (let i = 0; i < rawArgs.length; i++) { + const rawArg = rawArgs.at(i); + if (rawArg === undefined) { + continue; + } + if (rawArg.startsWith('--')) { + // Either a flag or a named param + const [rawArgName, rawArgValue] = rawArg.slice(2).split('='); + if (rawArgName in this.flags) { + // Arg is a flag + this.flags[rawArgName] = true; + } + else if (config.namedArgs && rawArgName in config.namedArgs) { + // Arg is a named arg + providedNamedArgs.add(rawArgName); + // Grab the value from the split token, otherwise go for the next token + let argValueBeforeParse = ''; + if (rawArgValue) { + argValueBeforeParse = rawArgValue; + } + else { + argValueBeforeParse = (_b = rawArgs.at(++i)) !== null && _b !== void 0 ? _b : ''; + if (!argValueBeforeParse || argValueBeforeParse.startsWith('--')) { + throw new Error(`Missing value for --${rawArgName}`); + } + } + const spec = config.namedArgs[rawArgName]; + parsedNamedArgs[rawArgName] = CLI.parseStringArg(argValueBeforeParse, rawArgName, spec); + } + else { + console.error(`Unknown flag: --${rawArgName}`); + process.exit(1); + } + } + else { + // Arg is a positional arg + const spec = (_c = config.positionalArgs) === null || _c === void 0 ? void 0 : _c.at(positionalIndex); + if (spec === undefined) { + throw new Error(`Unexpected arg: ${rawArg}`); + } + if (spec.variadic) { + // Variadic: collect this and all remaining non-flag args into an array + const collected = []; + for (let j = i; j < rawArgs.length; j++) { + const remaining = rawArgs.at(j); + if (remaining === undefined || remaining.startsWith('--')) { + break; + } + collected.push(remaining); + } + parsedPositionalArgs[spec.name] = collected; + break; + } + parsedPositionalArgs[spec.name] = CLI.parseStringArg(rawArg, spec.name, spec); + positionalIndex++; + } + } + // Handle help command + if (this.flags.help) { + this.printHelp(); + process.exit(0); + } + // Handle supersession logic + const supersededArgs = new Set(); + for (const [name, spec] of Object.entries((_d = config.namedArgs) !== null && _d !== void 0 ? _d : {})) { + if (providedNamedArgs.has(name) && spec.supersedes) { + for (const supersededArg of spec.supersedes) { + supersededArgs.add(supersededArg); + if (providedNamedArgs.has(supersededArg)) { + console.warn(`⚠️ Warning: --${supersededArg} is superseded by --${name} and will be ignored.`); + } + } + } + } + // Validate that all required args are present, assign defaults where values are not parsed + for (const [name, spec] of Object.entries((_e = config.namedArgs) !== null && _e !== void 0 ? _e : {})) { + if (name in parsedNamedArgs) { + if (supersededArgs.has(name)) { + parsedNamedArgs[name] = undefined; + } + } + else if (supersededArgs.has(name)) { + // This arg was superseded, so don't require it and don't assign a default + continue; + } + else if (spec.default !== undefined) { + parsedNamedArgs[name] = spec.default; + } + else if (spec.required === false) { + // Explicitly marked as optional, leave undefined + continue; + } + else { + // Arguments without defaults are required by default (unless explicitly marked as optional) + throw new Error(`Missing required named argument --${name}`); + } + } + for (const spec of (_f = config.positionalArgs) !== null && _f !== void 0 ? _f : []) { + if (!(spec.name in parsedPositionalArgs)) { + if (spec.default !== undefined) { + parsedPositionalArgs[spec.name] = spec.default; + } + else if (spec.variadic) { + parsedPositionalArgs[spec.name] = []; + } + else { + throw new Error(`Missing required positional argument --${spec.name}`); + } + } + } + this.namedArgs = parsedNamedArgs; + this.positionalArgs = parsedPositionalArgs; + } + catch (err) { + // If help flag was set, the error is from process.exit(0) in tests (where it's mocked to throw) - just rethrow it + if (this.flags.help) { + throw err; + } + if (err instanceof Error) { + console.error(err.message); + this.printHelp(); + } + else { + console.error('An unexpected error occurred initializing the CLI.'); + } + process.exit(1); + } + } + printHelp() { + var _a; + const { flags = {}, namedArgs = {}, positionalArgs = [] } = this.config; + const scriptName = (_a = process.argv.at(1)) !== null && _a !== void 0 ? _a : 'script.ts'; + const positionalUsage = positionalArgs + .map((arg) => { + const label = arg.variadic ? `${arg.name}...` : arg.name; + return arg.default === undefined ? `<${label}>` : `[${label}]`; + }) + .join(' '); + const namedArgUsage = Object.keys(namedArgs) + .map((key) => `[--${key} ]`) + .join(' '); + const flagUsage = [...Object.keys(flags), '--yes', '--no', '--help'].map((key) => `[${key.startsWith('--') ? key : `--${key}`}]`).join(' '); + console.log(`\nUsage: npx ts-node ${scriptName} ${flagUsage} ${namedArgUsage} ${positionalUsage}\n`); + console.log('Flags:'); + for (const [name, spec] of Object.entries(flags)) { + console.log(` --${name.padEnd(20)} ${spec.description}`); + } + // Built-in flags + console.log(` --${'yes'.padEnd(20)} Automatically answer "yes" to all confirmation prompts.`); + console.log(` --${'no'.padEnd(20)} Automatically answer "no" to all confirmation prompts.`); + console.log(` --${'help'.padEnd(20)} Show this help message.`); + console.log(''); + if (Object.keys(namedArgs).length > 0) { + console.log('Named Arguments:'); + for (const [name, spec] of Object.entries(namedArgs)) { + const defaultLabel = spec.default !== undefined ? ` (default: ${(0, SafeString_js_1.default)(spec.default)})` : ''; + const supersededLabel = spec.supersedes && spec.supersedes.length > 0 ? ` (supersedes: ${spec.supersedes.join(', ')})` : ''; + console.log(` --${name.padEnd(20)} ${spec.description}${defaultLabel}${supersededLabel}`); + } + console.log(''); + } + if (positionalArgs.length > 0) { + console.log('Positional Arguments:'); + for (const arg of positionalArgs) { + const defaultLabel = arg.default !== undefined ? ` (default: ${(0, SafeString_js_1.default)(arg.default)})` : ''; + console.log(` ${arg.name.padEnd(22)} ${arg.description}${defaultLabel}`); + } + console.log(''); + } + } + static parseStringArg(rawString, paramName, spec) { + if ('parse' in spec && !!spec.parse) { + try { + return spec.parse(rawString); + } + catch (error) { + let errorMessage = ''; + if (error instanceof Error) { + errorMessage = error.message; + } + console.error(`Invalid value for --${paramName}: ${errorMessage}`); + process.exit(1); + } + } + else { + return rawString; + } + } + /** + * Prompts the user for confirmation and returns true if they confirm (y/yes), false otherwise. + * If --yes flag was passed, returns true immediately without prompting. + * If --no flag was passed, returns false immediately without prompting. + */ + promptUserConfirmation(message) { + return __awaiter(this, void 0, void 0, function* () { + // Check for built-in flags first + if (this.flags.yes) { + return true; + } + if (this.flags.no) { + return false; + } + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise((resolve) => { + rl.question(message, (answer) => { + rl.close(); + const normalizedAnswer = answer.trim().toLowerCase(); + resolve(normalizedAnswer === 'y' || normalizedAnswer === 'yes'); + }); + }); + }); + } +} +exports["default"] = CLI; + + +/***/ }), + +/***/ 6690: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports["default"] = SafeString; +/** + * SafeString is a utility function that converts a value to a string. + * It handles the problematic case of plain objects by converting them to JSON. + * It helps with eslint rule https://typescript-eslint.io/rules/no-base-to-string + * @param value - The value to convert to a string. + * @returns The string representation of the value. + */ +function SafeString(value) { + if (value === undefined || value === null) { + return ''; + } + // Handle primitives explicitly so the final fallback never receives an object. + const valueType = typeof value; + if (valueType === 'string') { + return value; + } + if (valueType === 'number' || valueType === 'boolean' || valueType === 'function' || valueType === 'bigint' || valueType === 'symbol') { + const primitive = value; + return String(primitive); + } + if (valueType === 'object') { + if (Array.isArray(value)) { + try { + return JSON.stringify(value); + } + catch (_a) { + return '[object Array]'; + } + } + const obj = value; + const hasCustomToString = obj.toString && obj.toString !== Object.prototype.toString; + if (hasCustomToString) { + return obj.toString(); + } + if (value instanceof Map) { + return '[object Map]'; + } + if (value instanceof Set) { + return '[object Set]'; + } + try { + return JSON.stringify(obj); + } + catch (_b) { + return '[object Object]'; + } + } + return ''; +} + + /***/ }), /***/ 8088: @@ -3593,6 +3943,7 @@ exports.updateAndroid = updateAndroid; exports.generateAndroidVersionCode = generateAndroidVersionCode; const versionUpdater = __importStar(__nccwpck_require__(8982)); const child_process_1 = __nccwpck_require__(2081); +const CLI_1 = __importDefault(__nccwpck_require__(7720)); const fs_1 = __nccwpck_require__(7147); const path_1 = __importDefault(__nccwpck_require__(1017)); const major_1 = __importDefault(__nccwpck_require__(6688)); @@ -3851,6 +4202,14 @@ module.exports = require("path"); /***/ }), +/***/ 4521: +/***/ ((module) => { + +"use strict"; +module.exports = require("readline"); + +/***/ }), + /***/ 4404: /***/ ((module) => { diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 9816a411865d..2cd5e7203fff 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -12752,6 +12752,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); const ActionUtils = __importStar(__nccwpck_require__(6981)); const CONST_1 = __importDefault(__nccwpck_require__(9873)); const GithubUtils_1 = __importDefault(__nccwpck_require__(9296)); +const PromisePool_1 = __importDefault(__nccwpck_require__(6468)); const core = __importStar(__nccwpck_require__(2186)); const github_1 = __nccwpck_require__(5438); const memoize_1 = __importDefault(__nccwpck_require__(9885)); @@ -12789,7 +12790,8 @@ const getCommit = (0, memoize_1.default)(GithubUtils_1.default.octokit.git.getCo * Process deploy checklist comments for a list of PRs */ async function commentOnDeployChecklistPRs(prList, repoName, recentTags, getDeployMessage) { - for (const prNumber of prList) { + const pool = new PromisePool_1.default(8); + const commentPromises = prList.map((prNumber) => pool.add(async () => { try { const { data: pr } = await GithubUtils_1.default.octokit.pulls.get({ owner: CONST_1.default.GITHUB_OWNER, @@ -12831,7 +12833,8 @@ async function commentOnDeployChecklistPRs(prList, repoName, recentTags, getDepl throw error; } } - } + })); + await Promise.all(commentPromises); } async function run() { const prList = ActionUtils.getJSONInput('PR_LIST', { required: true }).map((num) => Number.parseInt(num, 10)); @@ -12886,16 +12889,13 @@ async function run() { } // who closed the last deploy checklist? const deployer = await GithubUtils_1.default.getActorWhoClosedIssue(previousChecklistID); - // Create comment on each pull request (one at a time to avoid throttling issues) + // Create comment on each pull request (up to 8 at a time via PromisePool to avoid throttling issues) const deployMessage = getDeployMessage(deployer, 'Deployed'); - for (const pr of prList) { - await commentPR(pr, deployMessage); - } + const pool = new PromisePool_1.default(8); + await Promise.all(prList.map((pr) => pool.add(() => commentPR(pr, deployMessage)))); console.log(`✅ Added production deploy comment on ${prList.length} App PRs`); // Comment on Mobile-Expensify PRs as well - for (const pr of mobileExpensifyPRList) { - await commentPR(pr, deployMessage, CONST_1.default.MOBILE_EXPENSIFY_REPO); - } + await Promise.all(mobileExpensifyPRList.map((pr) => pool.add(() => commentPR(pr, deployMessage, CONST_1.default.MOBILE_EXPENSIFY_REPO)))); if (mobileExpensifyPRList.length > 0) { console.log(`✅ Added production deploy comment on ${mobileExpensifyPRList.length} Mobile-Expensify PRs`); } @@ -13558,6 +13558,47 @@ class GithubUtils { exports["default"] = GithubUtils; +/***/ }), + +/***/ 6468: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +class PromisePool { + /** + * The maximum number of concurrent async operations. + */ + concurrency; + /** + * The set of currently-executing async operations. + */ + executing = new Set(); + constructor(concurrency = 8) { + this.concurrency = concurrency; + } + /** + * Execute an async task and return a promise with the result. + * If there are more async operations in the pool than allowed when this function is called, + * wait for one to finish before starting another. + */ + async add(task) { + // Recheck after each wait: when many add() callers resume from the same + // Promise.race, only the first few should start; the rest must wait again. + while (this.executing.size >= this.concurrency) { + await Promise.race(this.executing); + } + const p = task(); + this.executing.add(p); + return p.finally(() => { + this.executing.delete(p); + }); + } +} +exports["default"] = PromisePool; + + /***/ }), /***/ 9491: diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts index 80e90142ece2..e0d07d1341ab 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts +++ b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts @@ -3,6 +3,8 @@ import * as ActionUtils from '@github/libs/ActionUtils'; import CONST from '@github/libs/CONST'; import GithubUtils from '@github/libs/GithubUtils'; +import PromisePool from '@scripts/utils/PromisePool'; + import type {RequestError} from '@octokit/types'; import * as core from '@actions/core'; @@ -53,48 +55,53 @@ async function commentOnDeployChecklistPRs( recentTags: Awaited>['data'], getDeployMessage: (deployer: string, deployVerb: string, prTitle?: string) => string, ) { - for (const prNumber of prList) { - try { - const {data: pr} = await GithubUtils.octokit.pulls.get({ - owner: CONST.GITHUB_OWNER, - repo: repoName, - pull_number: prNumber, - }); - - // Find the deployer: either the merger, or for CPs, the tag creator - const isCP = pr.labels.some(({name: labelName}) => labelName === CONST.LABELS.CP_STAGING); - let deployer = pr.merged_by?.login; - if (isCP) { - for (const tag of recentTags) { - const {data: commit} = await getCommit({ - owner: CONST.GITHUB_OWNER, - repo: repoName, - commit_sha: tag.commit.sha, - }); - const prNumForCPMergeCommit = commit.message.match(/Merge pull request #(\d+)[\S\s]*\(cherry picked from commit .*\)/); - if (prNumForCPMergeCommit?.at(1) === String(prNumber)) { - const cpActor = commit.message.match(/.*\(cherry-picked to .* by (.*)\)/)?.at(1); - if (cpActor) { - deployer = cpActor; + const pool = new PromisePool(8); + const commentPromises = prList.map((prNumber) => + pool.add(async () => { + try { + const {data: pr} = await GithubUtils.octokit.pulls.get({ + owner: CONST.GITHUB_OWNER, + repo: repoName, + pull_number: prNumber, + }); + + // Find the deployer: either the merger, or for CPs, the tag creator + const isCP = pr.labels.some(({name: labelName}) => labelName === CONST.LABELS.CP_STAGING); + let deployer = pr.merged_by?.login; + if (isCP) { + for (const tag of recentTags) { + const {data: commit} = await getCommit({ + owner: CONST.GITHUB_OWNER, + repo: repoName, + commit_sha: tag.commit.sha, + }); + const prNumForCPMergeCommit = commit.message.match(/Merge pull request #(\d+)[\S\s]*\(cherry picked from commit .*\)/); + if (prNumForCPMergeCommit?.at(1) === String(prNumber)) { + const cpActor = commit.message.match(/.*\(cherry-picked to .* by (.*)\)/)?.at(1); + if (cpActor) { + deployer = cpActor; + } + break; } - break; } } - } - const title = pr.title; - const deployMessage = deployer ? getDeployMessage(deployer, isCP ? 'Cherry-picked' : 'Deployed', title) : ''; - await commentPR(prNumber, deployMessage, repoName); - } catch (error) { - if ((error as RequestError).status === 404) { - console.log(`Unable to comment on ${repoName} PR #${prNumber}. GitHub responded with 404.`); - } else if (repoName === CONST.MOBILE_EXPENSIFY_REPO && process.env.GITHUB_REPOSITORY !== `${CONST.GITHUB_OWNER}/${CONST.APP_REPO}`) { - console.warn(`Unable to comment on ${repoName} PR #${prNumber} from forked repository. This is expected.`); - } else { - throw error; + const title = pr.title; + const deployMessage = deployer ? getDeployMessage(deployer, isCP ? 'Cherry-picked' : 'Deployed', title) : ''; + await commentPR(prNumber, deployMessage, repoName); + } catch (error) { + if ((error as RequestError).status === 404) { + console.log(`Unable to comment on ${repoName} PR #${prNumber}. GitHub responded with 404.`); + } else if (repoName === CONST.MOBILE_EXPENSIFY_REPO && process.env.GITHUB_REPOSITORY !== `${CONST.GITHUB_OWNER}/${CONST.APP_REPO}`) { + console.warn(`Unable to comment on ${repoName} PR #${prNumber} from forked repository. This is expected.`); + } else { + throw error; + } } - } - } + }), + ); + + await Promise.all(commentPromises); } async function run() { @@ -159,17 +166,14 @@ async function run() { // who closed the last deploy checklist? const deployer = await GithubUtils.getActorWhoClosedIssue(previousChecklistID); - // Create comment on each pull request (one at a time to avoid throttling issues) + // Create comment on each pull request (up to 8 at a time via PromisePool to avoid throttling issues) const deployMessage = getDeployMessage(deployer, 'Deployed'); - for (const pr of prList) { - await commentPR(pr, deployMessage); - } + const pool = new PromisePool(8); + await Promise.all(prList.map((pr) => pool.add(() => commentPR(pr, deployMessage)))); console.log(`✅ Added production deploy comment on ${prList.length} App PRs`); // Comment on Mobile-Expensify PRs as well - for (const pr of mobileExpensifyPRList) { - await commentPR(pr, deployMessage, CONST.MOBILE_EXPENSIFY_REPO); - } + await Promise.all(mobileExpensifyPRList.map((pr) => pool.add(() => commentPR(pr, deployMessage, CONST.MOBILE_EXPENSIFY_REPO)))); if (mobileExpensifyPRList.length > 0) { console.log(`✅ Added production deploy comment on ${mobileExpensifyPRList.length} Mobile-Expensify PRs`); } diff --git a/.github/workflows/androidBump.yml b/.github/workflows/androidBump.yml index 60b67d53586f..73a7911925ee 100644 --- a/.github/workflows/androidBump.yml +++ b/.github/workflows/androidBump.yml @@ -10,7 +10,7 @@ jobs: android_bump: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml index a2e392fba021..12f409698c34 100644 --- a/.github/workflows/authorChecklist.yml +++ b/.github/workflows/authorChecklist.yml @@ -18,7 +18,7 @@ jobs: && github.actor != 'imgbot[bot]' steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Check contributor authorization id: gate diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml index a95e3329c6fd..83a8da1a47ee 100644 --- a/.github/workflows/buildAdHoc.yml +++ b/.github/workflows/buildAdHoc.yml @@ -140,7 +140,7 @@ jobs: needs: [buildWeb, deployWebAdHoc, buildAndroid, buildIOS] steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ inputs.APP_REF }} @@ -180,7 +180,7 @@ jobs: needs: [buildWeb, deployWebAdHoc, buildAndroid, buildIOS] steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ inputs.APP_REF }} diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml index a36d86f5ffec..a2febcdec14c 100644 --- a/.github/workflows/buildAndroid.yml +++ b/.github/workflows/buildAndroid.yml @@ -63,7 +63,7 @@ jobs: PROGUARD_MAPPING_FILENAME: mapping.txt steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: submodules: true ref: ${{ inputs.ref }} diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml index 42a0ad4d4ff3..d1c001c73ad2 100644 --- a/.github/workflows/buildIOS.yml +++ b/.github/workflows/buildIOS.yml @@ -60,7 +60,7 @@ jobs: SOURCEMAP_FILENAME: main.jsbundle.map steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: submodules: true ref: ${{ inputs.ref }} diff --git a/.github/workflows/buildVictoryChartRenderer.yml b/.github/workflows/buildVictoryChartRenderer.yml index 42f14316807e..0fd0c0c99292 100644 --- a/.github/workflows/buildVictoryChartRenderer.yml +++ b/.github/workflows/buildVictoryChartRenderer.yml @@ -25,7 +25,7 @@ jobs: BINARY_FILENAME: victory-chart-renderer-linux-x64 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ inputs.ref }} diff --git a/.github/workflows/buildWeb.yml b/.github/workflows/buildWeb.yml index 9a9ab766f948..fc1a9ec917ad 100644 --- a/.github/workflows/buildWeb.yml +++ b/.github/workflows/buildWeb.yml @@ -39,7 +39,7 @@ jobs: PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ inputs.ref }} diff --git a/.github/workflows/bunTests.yml b/.github/workflows/bunTests.yml index a182c82e01ee..2df7576e070d 100644 --- a/.github/workflows/bunTests.yml +++ b/.github/workflows/bunTests.yml @@ -23,7 +23,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/checkSVGCompression.yml b/.github/workflows/checkSVGCompression.yml index 85d155cd53bf..e48a16098108 100644 --- a/.github/workflows/checkSVGCompression.yml +++ b/.github/workflows/checkSVGCompression.yml @@ -21,7 +21,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml index c5922d1b34a6..d09faa1fb6ba 100644 --- a/.github/workflows/cherryPick.yml +++ b/.github/workflows/cherryPick.yml @@ -101,7 +101,7 @@ jobs: run: echo "CONFLICT_BRANCH_NAME=cherry-pick-${{ inputs.TARGET }}-${{ steps.getPRInfo.outputs.PR_NUMBER }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" - name: Checkout target branch - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ inputs.TARGET }} token: ${{ secrets.OS_BOTIFY_TOKEN }} @@ -211,10 +211,18 @@ jobs: echo "HAS_CONFLICTS=false" >> "$GITHUB_OUTPUT" git commit --amend -m "$(git log -1 --pretty=%B)" -m "(cherry-picked to ${{ inputs.TARGET }} by ${{ github.actor }})" else - echo "😞 PR can't be automerged, there are merge conflicts in the following files:" - git --no-pager diff --name-only --diff-filter=U - git cherry-pick --abort - echo "HAS_CONFLICTS=true" >> "$GITHUB_OUTPUT" + UNMERGED_FILES="$(git diff --name-only --diff-filter=U)" + # A failed -S signing leaves CHERRY_PICK_HEAD with the picked changes still staged, so only a fully clean index and worktree means the pick was truly empty + if [[ -z "$UNMERGED_FILES" ]] && git rev-parse --quiet --verify CHERRY_PICK_HEAD > /dev/null && git diff --cached --quiet && git diff --quiet; then + echo "✅ Cherry-pick resulted in an empty commit, the change is already on ${{ inputs.TARGET }}. Skipping it and continuing with the version bump." + git cherry-pick --skip + echo "HAS_CONFLICTS=false" >> "$GITHUB_OUTPUT" + else + echo "😞 PR can't be automerged, there are merge conflicts in the following files:" + echo "$UNMERGED_FILES" + git cherry-pick --abort + echo "HAS_CONFLICTS=true" >> "$GITHUB_OUTPUT" + fi fi - name: Push changes @@ -231,7 +239,11 @@ jobs: # Update and commit the submodule reference in E/App git add Mobile-Expensify - git commit -m "Update Mobile-Expensify submodule to include cherry-picked PR #${{ steps.getPRInfo.outputs.PR_NUMBER }}" + if git diff --cached --quiet; then + echo "Submodule pointer unchanged, nothing to commit" + else + git commit -m "Update Mobile-Expensify submodule to include cherry-picked PR #${{ steps.getPRInfo.outputs.PR_NUMBER }}" + fi fi # Push E/App changes diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index c82240485906..36ec4993d793 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -15,7 +15,7 @@ jobs: IS_AUTHORIZED: ${{ steps.gate.outputs.IS_AUTHORIZED }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Check contributor authorization id: gate diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 8423d38161dd..9a9142650fa0 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -22,7 +22,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Check contributor authorization id: gate @@ -45,7 +45,7 @@ jobs: - name: Checkout repository if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true' - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: fetch-depth: 1 diff --git a/.github/workflows/createDeployChecklist.yml b/.github/workflows/createDeployChecklist.yml index 3e4b7cc8fafa..ca15a700f21c 100644 --- a/.github/workflows/createDeployChecklist.yml +++ b/.github/workflows/createDeployChecklist.yml @@ -14,7 +14,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ inputs.REF || github.sha }} diff --git a/.github/workflows/createNewVersion.yml b/.github/workflows/createNewVersion.yml index ee7de58bc33f..c893baab222a 100644 --- a/.github/workflows/createNewVersion.yml +++ b/.github/workflows/createNewVersion.yml @@ -54,7 +54,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} - name: Check out - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: main submodules: true diff --git a/.github/workflows/cspell.yml b/.github/workflows/cspell.yml index 84a4fc5a1329..92ba1015f487 100644 --- a/.github/workflows/cspell.yml +++ b/.github/workflows/cspell.yml @@ -9,7 +9,7 @@ jobs: spellcheck: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ade1d8187c28..58dfff9d5163 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -37,7 +37,7 @@ jobs: IOS_VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ inputs.ENVIRONMENT || github.sha }} token: ${{ secrets.OS_BOTIFY_TOKEN }} @@ -215,7 +215,7 @@ jobs: if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -269,7 +269,7 @@ jobs: if: ${{ always() && !cancelled() && needs.prep.outputs.DEPLOY_ENV == 'production' && needs.androidBuild.result != 'failure' && needs.androidUploadGooglePlay.result != 'failure' }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -445,7 +445,7 @@ jobs: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} token: ${{ secrets.OS_BOTIFY_TOKEN }} @@ -525,7 +525,7 @@ jobs: DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -701,7 +701,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -763,7 +763,7 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ needs.prep.outputs.DEPLOY_SHA }} @@ -807,7 +807,7 @@ jobs: ] steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Post Slack message on failure uses: ./.github/actions/composite/announceFailedWorkflowInSlack @@ -935,6 +935,35 @@ jobs: echo "IS_RELEASE_READY=$isReleaseReady" >> "$GITHUB_OUTPUT" echo "IS_RELEASE_READY is $isReleaseReady (VCR result: ${{ needs.victoryChartRendererBuild.result }})" + autoRetestRequestForCP: + name: File retest request for cherry-picked deploy-blocker fixes + runs-on: blacksmith-2vcpu-ubuntu-2404 + # Only for a cherry-pick to staging, once every platform is on staging. + if: ${{ github.repository == 'Expensify/App' && needs.prep.outputs.DEPLOY_ENV == 'staging' && fromJSON(needs.prep.outputs.IS_CHERRY_PICK) && fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} + needs: [prep, checkDeploymentSuccess] + steps: + - name: Checkout + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Load retest webhook from 1Password + id: loadWebhook + # v4.0.0 + uses: 1password/load-secrets-action@92467eb28f72e8255933372f1e0707c567ce2259 + with: + export-env: false + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + SLACK_RETEST_WEBHOOK: op://${{ vars.OP_VAULT }}/Repository-Secrets/SLACK_RETEST_WEBHOOK + + - name: File retest request + run: npx bun scripts/createRetestRequestForCP.ts --deploy-sha=${{ needs.prep.outputs.DEPLOY_SHA }} --deploy-tag=${{ needs.prep.outputs.TAG }} + env: + GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + SLACK_RETEST_WEBHOOK: ${{ steps.loadWebhook.outputs.SLACK_RETEST_WEBHOOK }} + createRelease: runs-on: blacksmith-2vcpu-ubuntu-2404 if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_RELEASE_READY) }} @@ -1078,7 +1107,7 @@ jobs: SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Upload to Sentry for size analysis id: sentry-upload @@ -1107,7 +1136,7 @@ jobs: SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Upload to Sentry for size analysis id: sentry-upload diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml index 1f37fe46aecd..c8fce6d55def 100644 --- a/.github/workflows/deployBlocker.yml +++ b/.github/workflows/deployBlocker.yml @@ -16,7 +16,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Give the issue/PR the Hourly, Engineering labels run: gh issue edit ${{ github.event.issue.number }} --add-label 'Engineering,Hourly' --remove-label 'Daily,Weekly,Monthly' diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 8b69f1307530..1b8145f8cff9 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -33,7 +33,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: fetch-depth: 0 diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml index f4c925f2b2d0..09d27d11d1aa 100644 --- a/.github/workflows/failureNotifier.yml +++ b/.github/workflows/failureNotifier.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'failure' }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Process Failed Jobs uses: ./.github/actions/javascript/failureNotifier diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml index 40582c965310..7d6550605ea1 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -13,7 +13,7 @@ jobs: isValid: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && !fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS) }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: main token: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/formatCodeCovComment.yml b/.github/workflows/formatCodeCovComment.yml index bd6ffe38ff8e..de56663bdee5 100644 --- a/.github/workflows/formatCodeCovComment.yml +++ b/.github/workflows/formatCodeCovComment.yml @@ -13,7 +13,7 @@ jobs: if: github.event.issue.pull_request && github.event.comment.user.login == 'codecov[bot]' steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Format CodeCov Comment uses: ./.github/actions/javascript/formatCodeCovComment with: diff --git a/.github/workflows/generateTranslations.yml b/.github/workflows/generateTranslations.yml index edd572cb5e71..1e88ee73c26c 100644 --- a/.github/workflows/generateTranslations.yml +++ b/.github/workflows/generateTranslations.yml @@ -42,7 +42,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ steps.pr-data.outputs.HEAD_SHA }} diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index cdb7d638e443..91a27c6be62c 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -27,7 +27,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout PR - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4044662d9436..d25c821df370 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: # Only use the elevated OSBotify token on the post-merge `workflow_call` run # (so the auto-commit step below can push to the protected `main` branch). diff --git a/.github/workflows/lockDeploys.yml b/.github/workflows/lockDeploys.yml index d04c232a029e..18a50423bf71 100644 --- a/.github/workflows/lockDeploys.yml +++ b/.github/workflows/lockDeploys.yml @@ -10,7 +10,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Wait for staging deploys to finish uses: ./.github/actions/javascript/awaitStagingDeploys diff --git a/.github/workflows/oxfmt.yml b/.github/workflows/oxfmt.yml index 89ddca914e81..1e57c2f9a693 100644 --- a/.github/workflows/oxfmt.yml +++ b/.github/workflows/oxfmt.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/postDeployComments.yml b/.github/workflows/postDeployComments.yml index dc7db2e28205..e2134ee315ff 100644 --- a/.github/workflows/postDeployComments.yml +++ b/.github/workflows/postDeployComments.yml @@ -89,7 +89,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml index 46cd424a5db0..15183af70f87 100644 --- a/.github/workflows/preDeploy.yml +++ b/.github/workflows/preDeploy.yml @@ -27,7 +27,7 @@ jobs: if: ${{ always() }} steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Exit failed workflow if: ${{ needs.typecheck.result == 'failure' || needs.lint.result == 'failure' || needs.test.result == 'failure' }} @@ -43,7 +43,7 @@ jobs: SHOULD_DEPLOY: ${{ fromJSON(steps.shouldDeploy.outputs.SHOULD_DEPLOY) }} steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Get merged pull request id: getMergedPullRequest diff --git a/.github/workflows/proposalPolice.yml b/.github/workflows/proposalPolice.yml index 3716a83b8ae1..b05f2261c9e9 100644 --- a/.github/workflows/proposalPolice.yml +++ b/.github/workflows/proposalPolice.yml @@ -8,7 +8,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: '!contains(fromJSON(''["OSBotify", "imgbot[bot]", "melvin-bot[bot]", "codecov[bot]"]''), github.actor)' steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 # Checks if the comment is created and follows the template OR # if the comment is edited and if proposal template is followed. diff --git a/.github/workflows/publishReactNativeAndroidArtifacts.yml b/.github/workflows/publishReactNativeAndroidArtifacts.yml index e2f85acd01b8..7e76b4854dee 100644 --- a/.github/workflows/publishReactNativeAndroidArtifacts.yml +++ b/.github/workflows/publishReactNativeAndroidArtifacts.yml @@ -43,7 +43,7 @@ jobs: build_targets: ${{ steps.getArtifactBuildTargets.outputs.BUILD_TARGETS }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: submodules: true ref: ${{ github.event.before || 'main' }} @@ -167,7 +167,7 @@ jobs: cancel-in-progress: true steps: - name: Checkout Code - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ needs.resolveRefs.outputs.APP_REF }} submodules: ${{ matrix.is_hybrid }} diff --git a/.github/workflows/react-compiler-compliance.yml b/.github/workflows/react-compiler-compliance.yml index c3356e29fdc0..677ca1ef8094 100644 --- a/.github/workflows/react-compiler-compliance.yml +++ b/.github/workflows/react-compiler-compliance.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index a86e312eb25e..ae2dda907746 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -12,7 +12,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout baseline branch - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Checkout baseline branch shell: bash @@ -42,7 +42,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode @@ -68,7 +68,7 @@ jobs: needs: [baseline-perf-tests, branch-perf-tests] steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup NodeJS uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml index 394a42e167ec..528b109acdf2 100644 --- a/.github/workflows/remote-build-android.yml +++ b/.github/workflows/remote-build-android.yml @@ -57,7 +57,7 @@ jobs: is_hybrid_build: true steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ needs.resolveRefs.outputs.APP_REF }} submodules: ${{ matrix.is_hybrid_build || false }} diff --git a/.github/workflows/remote-build-ios.yml b/.github/workflows/remote-build-ios.yml index b052fd06b21d..98822fdfa8be 100644 --- a/.github/workflows/remote-build-ios.yml +++ b/.github/workflows/remote-build-ios.yml @@ -61,7 +61,7 @@ jobs: is_hybrid_build: true steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ needs.resolveRefs.outputs.APP_REF }} submodules: ${{ matrix.is_hybrid_build || false }} diff --git a/.github/workflows/reviewerChecklist.yml b/.github/workflows/reviewerChecklist.yml index 4a1370d4cdf7..cee88fe39a67 100644 --- a/.github/workflows/reviewerChecklist.yml +++ b/.github/workflows/reviewerChecklist.yml @@ -9,7 +9,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Filter paths uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 diff --git a/.github/workflows/shellCheck.yml b/.github/workflows/shellCheck.yml index ea81aac694e9..7a23b5b007f9 100644 --- a/.github/workflows/shellCheck.yml +++ b/.github/workflows/shellCheck.yml @@ -13,7 +13,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Lint shell scripts with ShellCheck run: npm run shellcheck diff --git a/.github/workflows/syncVersions.yml b/.github/workflows/syncVersions.yml index 16adf15808d3..20ab342890ed 100644 --- a/.github/workflows/syncVersions.yml +++ b/.github/workflows/syncVersions.yml @@ -13,7 +13,7 @@ jobs: runs-on: blacksmith-6vcpu-macos-latest steps: - name: Check out - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: main submodules: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc48dd5be378..a05f12967e03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: name: test (job ${{ fromJSON(matrix.chunk) }}) steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode @@ -87,7 +87,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 name: Storybook tests steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/testBuildOnPush.yml b/.github/workflows/testBuildOnPush.yml index 071d41b36c8b..ebe7b3debd08 100644 --- a/.github/workflows/testBuildOnPush.yml +++ b/.github/workflows/testBuildOnPush.yml @@ -11,7 +11,7 @@ jobs: timeout-minutes: 120 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Wait for earlier runs to finish (FIFO queue) uses: ./.github/actions/javascript/waitForPreviousRuns @@ -30,7 +30,7 @@ jobs: BUILD_MOBILE: ${{ steps.detectOSBotifyPush.outputs.BUILD_MOBILE || 'true' }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Validate that user is an Expensify employee uses: ./.github/actions/composite/validateActor diff --git a/.github/workflows/translationDryRun.yml b/.github/workflows/translationDryRun.yml index 65ce329a05fa..f0a2ecf41a16 100644 --- a/.github/workflows/translationDryRun.yml +++ b/.github/workflows/translationDryRun.yml @@ -12,7 +12,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 2596904015f3..8bf164abf6b5 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -16,7 +16,7 @@ jobs: if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/unused-styles.yml b/.github/workflows/unused-styles.yml index 7e6f90060eed..e5f2e7c9330d 100644 --- a/.github/workflows/unused-styles.yml +++ b/.github/workflows/unused-styles.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/updateHelpDotRedirects.yml b/.github/workflows/updateHelpDotRedirects.yml index 5237ffb3c281..4b93d4a7cdbe 100644 --- a/.github/workflows/updateHelpDotRedirects.yml +++ b/.github/workflows/updateHelpDotRedirects.yml @@ -22,7 +22,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Create help dot redirect env: diff --git a/.github/workflows/updateProtectedBranch.yml b/.github/workflows/updateProtectedBranch.yml index 4369b948981b..47a188ebd6f2 100644 --- a/.github/workflows/updateProtectedBranch.yml +++ b/.github/workflows/updateProtectedBranch.yml @@ -28,7 +28,7 @@ jobs: fi - name: Checkout source branch - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: ref: ${{ steps.getSourceBranch.outputs.SOURCE_BRANCH }} token: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/validateBuildRequest.yml b/.github/workflows/validateBuildRequest.yml index cba64ae361a8..6363acdfc6c8 100644 --- a/.github/workflows/validateBuildRequest.yml +++ b/.github/workflows/validateBuildRequest.yml @@ -27,7 +27,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Validate that user is an Expensify employee if: ${{ github.event_name == 'workflow_dispatch' }} diff --git a/.github/workflows/validateContributorPR.yml b/.github/workflows/validateContributorPR.yml index 5b14a194edef..1e5329857b4d 100644 --- a/.github/workflows/validateContributorPR.yml +++ b/.github/workflows/validateContributorPR.yml @@ -18,7 +18,7 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Check contributor authorization id: gate diff --git a/.github/workflows/validateDocsRoutes.yml b/.github/workflows/validateDocsRoutes.yml index 11264c2c6d31..7055ef61a831 100644 --- a/.github/workflows/validateDocsRoutes.yml +++ b/.github/workflows/validateDocsRoutes.yml @@ -11,7 +11,7 @@ jobs: if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/validateGithubActions.yml b/.github/workflows/validateGithubActions.yml index e943b5e8bd8c..e6aa2ea04d3c 100644 --- a/.github/workflows/validateGithubActions.yml +++ b/.github/workflows/validateGithubActions.yml @@ -12,7 +12,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/validateMobileExpensifySubmodule.yml b/.github/workflows/validateMobileExpensifySubmodule.yml index ff2361a6f6e1..fd5d1b76409f 100644 --- a/.github/workflows/validateMobileExpensifySubmodule.yml +++ b/.github/workflows/validateMobileExpensifySubmodule.yml @@ -13,10 +13,10 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout App - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Checkout Mobile-Expensify - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 with: repository: Expensify/Mobile-Expensify path: .github/mobile-expensify-repo diff --git a/.github/workflows/validatePatches.yml b/.github/workflows/validatePatches.yml index b93b2597d7ec..59be39399e35 100644 --- a/.github/workflows/validatePatches.yml +++ b/.github/workflows/validatePatches.yml @@ -11,7 +11,7 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Fetch main branch run: git fetch origin --depth=1 main diff --git a/.github/workflows/verifyParserFiles.yml b/.github/workflows/verifyParserFiles.yml index 459b0d9d2f04..599b84ea9d72 100644 --- a/.github/workflows/verifyParserFiles.yml +++ b/.github/workflows/verifyParserFiles.yml @@ -13,7 +13,7 @@ jobs: runs-on: blacksmith-6vcpu-macos-latest steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/verifyPodfile.yml b/.github/workflows/verifyPodfile.yml index 488742b86603..a8527469c85c 100644 --- a/.github/workflows/verifyPodfile.yml +++ b/.github/workflows/verifyPodfile.yml @@ -15,7 +15,7 @@ jobs: runs-on: blacksmith-6vcpu-macos-latest steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Setup Node uses: ./.github/actions/composite/setupNode diff --git a/.github/workflows/verifySignedCommits.yml b/.github/workflows/verifySignedCommits.yml index def15a85a14a..672c4c7442ed 100644 --- a/.github/workflows/verifySignedCommits.yml +++ b/.github/workflows/verifySignedCommits.yml @@ -9,7 +9,7 @@ jobs: verifySignedCommits: runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + - uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Verify signed commits uses: ./.github/actions/javascript/verifySignedCommits diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml index 0c54e0826b4f..9c98079af8dc 100644 --- a/.github/workflows/welcome.yml +++ b/.github/workflows/welcome.yml @@ -10,7 +10,7 @@ jobs: if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' }} steps: - name: Checkout - uses: useblacksmith/checkout@c9796daa2a4bdebdab5bd16be2c09a70cd4e1121 # v1 + uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1 - name: Get merged pull request id: getMergedPullRequest diff --git a/Mobile-Expensify b/Mobile-Expensify index 88ae8e7fceea..24d465bdf36e 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 88ae8e7fceea34488ec4043ec81c291e50c6c7a5 +Subproject commit 24d465bdf36e579e53bb02d353566ed75a68da25 diff --git a/android/app/build.gradle b/android/app/build.gradle index 8d3f2a379646..8e5de9365fe9 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 1009043408 - versionName "9.4.34-8" + versionCode 1009043604 + versionName "9.4.36-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/assets/emojis/index.ts b/assets/emojis/index.ts index 4ce7fbc64fdf..51452615b8c9 100644 --- a/assets/emojis/index.ts +++ b/assets/emojis/index.ts @@ -35,6 +35,7 @@ const findEmojiByHexCode = (hexcode: string): Emoji | undefined => emojiHexcodeT const localeEmojis: LocaleEmojis = { en: undefined, es: undefined, + fr: undefined, }; const importEmojiLocale = (locale: FullySupportedLocale) => { diff --git a/assets/images/product-illustrations/illustration_agents-ice-cream.svg b/assets/images/product-illustrations/illustration_agents-ice-cream.svg new file mode 100644 index 000000000000..752665de979c --- /dev/null +++ b/assets/images/product-illustrations/illustration_agents-ice-cream.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index deccb27ae793..fdd73f8f0c9b 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -65,7 +65,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" 104 +"../../src/ROUTES.ts" "@typescript-eslint/no-deprecated/getUrlWithBackToParam" 106 "../../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 @@ -80,8 +80,7 @@ "../../src/components/AnchorForAttachmentsOnly/index.tsx" "no-restricted-syntax" 1 "../../src/components/AnchorForCommentsOnly/index.tsx" "no-restricted-syntax" 1 "../../src/components/AnimatedFlatListWithCellRenderer.tsx" "@typescript-eslint/no-deprecated/Animated.createAnimatedComponent" 1 -"../../src/components/AnimatedFlatListWithCellRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/AnimatedFlatListWithCellRenderer.tsx" "react-hooks/refs" 2 +"../../src/components/AnimatedFlatListWithCellRenderer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/AnimatedSubmitButton/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AnimatedSubmitButton/index.tsx" "no-restricted-imports" 1 "../../src/components/AnimatedSubmitButton/index.tsx" "react-hooks/refs" 6 @@ -101,6 +100,7 @@ "../../src/components/Attachments/AttachmentView/index.tsx" "no-restricted-imports" 1 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "no-restricted-syntax" 1 "../../src/components/AutoCompleteSuggestions/AutoCompleteSuggestionsPortal/TransparentOverlay/TransparentOverlay.tsx" "react-hooks/refs" 1 +"../../src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/AutoCompleteSuggestions/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/AutoSubmitModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Avatar.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -115,8 +115,6 @@ "../../src/components/Button/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Button/validateSubmitShortcut/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ButtonComposed/Button.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/ButtonWithDropdownMenu/index.tsx" "react-hooks/preserve-manual-memoization" 1 -"../../src/components/ButtonWithDropdownMenu/index.tsx" "react-hooks/refs" 3 "../../src/components/ButtonWithDropdownMenu/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/CategoryPicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Charts/BarChart/BarChartContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -179,7 +177,7 @@ "../../src/components/FlatList/FlatList/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/FlatList/FlatList/index.tsx" "react-hooks/refs" 1 "../../src/components/FlatList/hooks/useFlatListHandle.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../src/components/FlatList/hooks/useFlatListScrollKey.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/FlatList/hooks/useFlatListScrollKey.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/FloatingGPSButton/index.native.tsx" "no-restricted-imports" 1 "../../src/components/FocusModeNotification.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/FocusTrap/FocusTrapContainerElement/index.web.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -322,7 +320,7 @@ "../../src/components/PDFView/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PDFView/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ParentNavigationSubtitle.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Picker/BasePicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/Picker/BasePicker.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/Picker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PlanTypeSelector.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/PopoverMenu/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -355,10 +353,7 @@ "../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "rulesdir/no-useOnyx-dependencies-arg" 2 "../../src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx" "no-restricted-imports" 1 -"../../src/components/ReportActionItem/MoneyRequestReportPreview/ApproveActionButton.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 -"../../src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/components/ReportActionItem/MoneyRequestReportPreview/ReportPreviewActionButton.tsx" "no-restricted-imports" 1 -"../../src/components/ReportActionItem/MoneyRequestReportPreview/SubmitActionButton.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewActionDecision.ts" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/components/ReportActionItem/TaskPreview.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ReportActionItem/TaskView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -367,7 +362,7 @@ "../../src/components/ReportActionItem/receiptHoverUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/RoomHeaderAvatars.tsx" "no-restricted-syntax" 2 "../../src/components/Rule/RuleBooleanBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/Rule/TextBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/components/Rule/TextBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/ScreenShareRequestModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/ScreenWrapper/ScreenWrapperContainer.tsx" "react-hooks/refs" 2 "../../src/components/Search/FilterComponents/AdvancedFilters/AmountFilterContent.tsx" "no-restricted-imports" 1 @@ -379,6 +374,7 @@ "../../src/components/Search/FilterComponents/DateFilterBase.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/Search/FilterComponents/DatePresetFilterBase.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/Search/FilterComponents/ReportField/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Search/FilterComponents/SingleSelect.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/FilterComponents/TypeSelector.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/FilterDropdowns/ActionButtons.tsx" "no-restricted-imports" 1 "../../src/components/Search/FilterDropdowns/CardSelectPopup.tsx" "react-hooks/set-state-in-effect" 1 @@ -409,7 +405,7 @@ "../../src/components/Search/SearchList/ListItem/TaskListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/TaskListItem.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/components/Search/SearchList/ListItem/TaskListItemRow.tsx" "no-restricted-imports" 1 -"../../src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx" "no-restricted-imports" 1 "../../src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 19 "../../src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemNarrow.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -443,13 +439,14 @@ "../../src/components/Search/index.tsx" "react-hooks/refs" 5 "../../src/components/Search/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/components/SelectionButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/SelectionList/BaseSelectionList.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/ListItem/BaseListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/ListItem/SpendRuleListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/ListItem/SplitListItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/components/Footer.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SelectionList/components/Footer.tsx" "no-restricted-imports" 1 -"../../src/components/SelectionList/hooks/useFlattenedSections.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/SelectionList/hooks/useFlattenedSections.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SelectionList/utils/getListboxRole/index.web.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SettlementButton/AnimatedSettlementButton.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SettlementButton/AnimatedSettlementButton.tsx" "no-restricted-imports" 1 @@ -466,15 +463,15 @@ "../../src/components/StatePicker/StateSelectorModal.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/StatePicker/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/StatusBadge.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/SubStepForms/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/SubStepForms/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SubStepForms/AgreementsFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../src/components/SubStepForms/ConfirmationStep.tsx" "no-restricted-imports" 1 "../../src/components/SubStepForms/CountryFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SubStepForms/DateOfBirthStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../src/components/SubStepForms/DocusignFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/components/SubStepForms/DocusignFullStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/SubStepForms/DocusignFullStep.tsx" "no-restricted-imports" 1 -"../../src/components/SubStepForms/FullNameStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../src/components/SubStepForms/PushRowFieldsStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/components/SubStepForms/FullNameStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/components/SubStepForms/PushRowFieldsStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/SubStepForms/RegistrationNumberStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/SwipeableView/index.native.tsx" "react-hooks/refs" 2 "../../src/components/SymbolButton.tsx" "no-restricted-syntax" 1 @@ -482,6 +479,7 @@ "../../src/components/Table/Table.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/components/Table/TableContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/components/Table/middlewares/filtering.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/Tables/WorkspaceCategoryRulesTable/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/Tables/WorkspaceCompanyCardsTable/WorkspaceCompanyCardsTableRow.tsx" "no-restricted-imports" 1 "../../src/components/Tables/WorkspaceCompanyCardsTable/index.tsx" "no-restricted-imports" 1 @@ -524,6 +522,7 @@ "../../src/components/Tooltip/PopoverAnchorTooltip.tsx" "react-hooks/refs" 7 "../../src/components/TransactionItemRow/DataCells/ChatBubbleCell.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/components/TransactionItemRow/DataCells/MerchantCell.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/TransactionItemRow/EditableCell/usePopoverEditState.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/UpdateAppModal.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/components/UploadFile.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx" "no-restricted-imports" 1 @@ -539,7 +538,8 @@ "../../src/components/ZeroWidthView/index.tsx" "no-restricted-syntax" 2 "../../src/components/createOnyxContext.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/components/withCurrentUserPersonalDetails.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/components/withNavigationFallback.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/components/withNavigationFallback.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/components/withNavigationTransitionEnd.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/withToggleVisibilityView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/components/withViewportOffsetTop.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useAccountIndicatorChecks.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -581,11 +581,12 @@ "../../src/hooks/useHtmlPaste/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/hooks/useImportSpreadsheetConfirmModal.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useInitial.ts" "react-hooks/refs" 4 +"../../src/hooks/useInitialSelection.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useIsBlockedToAddFeed.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useIsOwnWorkspaceChatRef.ts" "react-hooks/refs" 2 "../../src/hooks/useIsPaidPolicyAdmin.ts" "no-restricted-imports" 1 "../../src/hooks/useKeyboardShortcut.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/hooks/useLazyAsset.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../src/hooks/useLazyAsset.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useLazyAsset.ts" "react-hooks/set-state-in-effect" 1 "../../src/hooks/useLifecycleActions.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/hooks/useListKeyboardNav.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -611,7 +612,6 @@ "../../src/hooks/usePolicyForTransaction.ts" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/hooks/usePreferredCurrency.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/usePrevious.ts" "react-hooks/refs" 1 -"../../src/hooks/useProactiveAppReview.ts" "react-hooks/purity" 1 "../../src/hooks/useReceiptScanDrop.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useReportPrimaryAction.ts" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/hooks/useReportScrollManager/index.native.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -636,9 +636,9 @@ "../../src/hooks/useSingleExecution/index.native.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/hooks/useSingleExecution/index.native.ts" "react-hooks/refs" 1 "../../src/hooks/useSplitEffectivePolicy.ts" "rulesdir/no-useOnyx-dependencies-arg" 1 -"../../src/hooks/useStepFormSubmit.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/hooks/useStableIndexedHandler.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useStepFormSubmit.ts" "no-restricted-syntax" 1 -"../../src/hooks/useSubPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/hooks/useSubPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/hooks/useSubStep/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useSubStep/index.ts" "react-hooks/refs" 2 "../../src/hooks/useTackInputFocus/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -652,6 +652,7 @@ "../../src/hooks/useTripTransactions.ts" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/hooks/useViolations.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/hooks/useWindowDimensions/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../src/hooks/useWorkletStateMachine/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/languages/flattenObject.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/API/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/Accessibility/moveAccessibilityFocus/types.ts" "@typescript-eslint/no-deprecated/ElementRef" 1 @@ -660,7 +661,7 @@ "../../src/libs/Avatars/InitialAvatars.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Avatars/PresetAvatarCatalog.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/BankAccountUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/CardFeedUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/libs/CardFeedUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/libs/CardUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 14 "../../src/libs/CategoryOptionListUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/Clipboard/index.ts" "@typescript-eslint/no-deprecated/document.execCommand" 1 @@ -813,13 +814,13 @@ "../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-deprecated/reportAction.sequenceNumber" 1 "../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-deprecated/reportAction?.originalMessage" 2 "../../src/libs/ReportActionsUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 111 -"../../src/libs/ReportActionsUtils.ts" "rulesdir/no-onyx-connect" 3 +"../../src/libs/ReportActionsUtils.ts" "rulesdir/no-onyx-connect" 2 "../../src/libs/ReportNameUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 3 "../../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/getPolicy" 27 -"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 28 +"../../src/libs/ReportUtils.ts" "@typescript-eslint/no-deprecated/translateLocal" 9 "../../src/libs/ReportUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 34 "../../src/libs/ReportUtils.ts" "rulesdir/no-onyx-connect" 16 "../../src/libs/SearchAutocompleteUtils.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 @@ -902,12 +903,11 @@ "../../src/libs/actions/IOU/Split.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/IOU/Split.ts" "no-restricted-syntax" 3 "../../src/libs/actions/IOU/SplitExpenseItems.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/libs/actions/IOU/SplitTransactionUpdate.ts" "@typescript-eslint/no-deprecated/getPolicyTagsData" 1 "../../src/libs/actions/IOU/SplitTransactionUpdate.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 "../../src/libs/actions/IOU/TrackExpense.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/IOU/TrackExpense.ts" "no-restricted-syntax" 2 "../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "@typescript-eslint/no-deprecated/buildNextStepNew" 1 -"../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "@typescript-eslint/no-deprecated/getPolicyTagsData" 11 +"../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "@typescript-eslint/no-deprecated/getPolicyTagsData" 2 "../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "no-restricted-syntax" 1 "../../src/libs/actions/IOU/index.ts" "@typescript-eslint/no-deprecated/getPolicyTagsData" 1 @@ -919,7 +919,6 @@ "../../src/libs/actions/Link.ts" "no-restricted-syntax" 3 "../../src/libs/actions/MapboxToken.ts" "no-restricted-syntax" 2 "../../src/libs/actions/MergeAccounts.ts" "no-restricted-syntax" 1 -"../../src/libs/actions/MergeTransaction.ts" "@typescript-eslint/no-deprecated/getPolicyTagsData" 1 "../../src/libs/actions/MergeTransaction.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/MergeTransaction.ts" "no-restricted-imports" 1 "../../src/libs/actions/MergeTransaction.ts" "no-restricted-syntax" 1 @@ -940,7 +939,7 @@ "../../src/libs/actions/Policy/DistanceRate.ts" "no-restricted-syntax" 2 "../../src/libs/actions/Policy/Member.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/Policy/Member.ts" "no-restricted-imports" 1 -"../../src/libs/actions/Policy/Member.ts" "no-restricted-syntax" 8 +"../../src/libs/actions/Policy/Member.ts" "no-restricted-syntax" 7 "../../src/libs/actions/Policy/Member.ts" "rulesdir/no-onyx-connect" 1 "../../src/libs/actions/Policy/PerDiem.ts" "no-restricted-syntax" 1 "../../src/libs/actions/Policy/Plan.ts" "no-restricted-syntax" 1 @@ -949,7 +948,6 @@ "../../src/libs/actions/Policy/Policy.ts" "@typescript-eslint/no-unsafe-type-assertion" 8 "../../src/libs/actions/Policy/Policy.ts" "no-restricted-imports" 2 "../../src/libs/actions/Policy/Policy.ts" "no-restricted-syntax" 10 -"../../src/libs/actions/Policy/Policy.ts" "rulesdir/no-onyx-connect" 1 "../../src/libs/actions/Policy/ReportField.ts" "no-restricted-imports" 1 "../../src/libs/actions/Policy/ReportField.ts" "no-restricted-syntax" 4 "../../src/libs/actions/Policy/Rules.ts" "no-restricted-syntax" 3 @@ -965,7 +963,7 @@ "../../src/libs/actions/Report/index.ts" "@typescript-eslint/no-deprecated/buildNextStepNew" 3 "../../src/libs/actions/Report/index.ts" "@typescript-eslint/no-deprecated/reportAction.originalMessage" 1 "../../src/libs/actions/Report/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 19 -"../../src/libs/actions/Report/index.ts" "no-restricted-syntax" 8 +"../../src/libs/actions/Report/index.ts" "no-restricted-syntax" 7 "../../src/libs/actions/Report/index.ts" "rulesdir/no-onyx-connect" 4 "../../src/libs/actions/ReportLayout.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/libs/actions/ReportLayout.ts" "no-restricted-syntax" 1 @@ -1304,7 +1302,9 @@ "../../src/pages/inbox/report/actionContents/ReimbursementQueuedContent.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/pages/inbox/report/shouldUseEmojiPickerSelection/index.web.ts" "no-restricted-syntax" 1 "../../src/pages/inbox/report/useActiveDraftReportAction.ts" "rulesdir/no-useOnyx-dependencies-arg" 2 +"../../src/pages/inbox/report/useDebouncedSaveDraft.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/withReportAndPrivateNotesOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/inbox/report/withReportAndReportActionOrNotFound.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/report/withReportOrNotFound.tsx" "react-hooks/refs" 3 "../../src/pages/inbox/sidebar/FABPopoverContent/FABFocusableMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/inbox/sidebar/FABPopoverContent/menuItems/CreateReportMenuItem.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1316,10 +1316,10 @@ "../../src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/inbox/sidebar/SidebarLinksData.tsx" "react-hooks/refs" 1 "../../src/pages/inbox/sidebar/SignInButton.tsx" "no-restricted-imports" 1 -"../../src/pages/iou/DynamicSplitExpenseEditPage.tsx" "no-restricted-imports" 1 -"../../src/pages/iou/DynamicSplitExpenseEditPage.tsx" "rulesdir/no-useOnyx-dependencies-arg" 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" "no-restricted-imports" 1 +"../../src/pages/iou/SplitExpenseEditPage.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 "../../src/pages/iou/SplitExpensePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/SplitExpensePage.tsx" "no-restricted-imports" 1 "../../src/pages/iou/SplitExpensePage.tsx" "react-hooks/set-state-in-effect" 3 @@ -1392,12 +1392,10 @@ "../../src/pages/media/AttachmentModalScreen/routes/hooks/useReportAttachmentModalType.ts" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/AboutPage/AboutPage.tsx" "react-hooks/refs" 1 "../../src/pages/settings/Agents/AgentsListRow.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Agents/AgentsPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Agents/Fields/EditPromptPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Copilot/CopilotPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/ExitSurvey/DynamicExitSurveyReasonPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/PaymentCard/ChangeCurrency/index.tsx" "no-restricted-syntax" 1 "../../src/pages/settings/Profile/AgentAIPromptSection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1408,7 +1406,6 @@ "../../src/pages/settings/Profile/Avatar/EditUserAvatarContent.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx" "react-hooks/refs" 3 "../../src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx" "react-hooks/set-state-in-effect" 3 -"../../src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/Profile/CustomStatus/StatusPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Profile/CustomStatus/StatusPage.tsx" "react-hooks/set-state-in-effect" 1 @@ -1430,16 +1427,8 @@ "../../src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateMagicCodePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/settings/Security/DeviceManagementPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Security/LockAccount/LockAccountPageBase.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Security/TwoFactorAuth/DisabledPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Security/TwoFactorAuth/DynamicTwoFactorAuthPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyOldPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Security/TwoFactorAuth/VerifyPage.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 -"../../src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Subscription/CardAuthenticationModal/index.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Subscription/CardSection/CardSectionButton/index.tsx" "no-restricted-imports" 1 @@ -1448,10 +1437,7 @@ "../../src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx" "no-restricted-syntax" 1 "../../src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Subscription/SubscriptionPlan/ComparePlansModal.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Subscription/SubscriptionPlan/SubscriptionPlanCardActionButton.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Subscription/SubscriptionPlan/index.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/ActivatePhysicalCardPageBase.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/BankAccountPurposePage/substeps/CountrySelection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Wallet/CardDetailsActionButtons.tsx" "no-restricted-imports" 1 @@ -1463,7 +1449,6 @@ "../../src/pages/settings/Wallet/ExpensifyCardPage/index.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 2 "../../src/pages/settings/Wallet/ExpensifyCardPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/ExpensifyCardPage/index.tsx" "rulesdir/no-useOnyx-dependencies-arg" 1 -"../../src/pages/settings/Wallet/ImportTransactionsPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx" "@typescript-eslint/no-deprecated/useSubStep" 1 "../../src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/AddressStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/substeps/AddressStep.tsx" "react-hooks/set-state-in-effect" 1 @@ -1473,7 +1458,6 @@ "../../src/pages/settings/Wallet/PaymentMethodListItem.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/PersonalCardDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/settings/Wallet/PersonalCardDetailsPage.tsx" "no-restricted-imports" 1 -"../../src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx" "no-restricted-imports" 1 "../../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/PersonalCardWarning.tsx" "no-restricted-imports" 1 @@ -1481,12 +1465,10 @@ "../../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 -"../../src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeIntro.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/ReportVirtualCardFraudConfirmationPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/ShareBankAccount/ShareBankAccount.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/Wallet/TravelCVVPage/TravelCVVPage.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 2 -"../../src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx" "no-restricted-imports" 1 "../../src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/settings/Wallet/UpdatePersonalBankAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/settings/Wallet/WalletPage/index.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1528,14 +1510,12 @@ "../../src/pages/workspace/WorkspacesListPage.tsx" "rulesdir/no-useOnyx-dependencies-arg" 3 "../../src/pages/workspace/accounting/AccountingContext/default.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/ClaimOfferPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/accounting/ClaimOfferPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/PolicyAccountingPage.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/accounting/certinia/import/CertiniaDimensionMappingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/certinia/prerequisites/CertiniaPrerequisitesStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/accounting/certinia/prerequisites/CertiniaPrerequisitesStep.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/intacct/advanced/DynamicSageIntacctAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/export/DynamicSageIntacctNonReimbursableExpensesDestinationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/export/SageIntacctDatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1544,7 +1524,6 @@ "../../src/pages/workspace/accounting/intacct/import/SageIntacctAddUserDimensionPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/intacct/import/SageIntacctMappingsTypePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/intacct/import/SageIntacctToggleMappingsPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/accounting/intacct/import/SageIntacctUserDimensionsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenSetupContent.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/subPages/NetSuiteTokenSetupContent.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/netsuite/advanced/DynamicNetSuiteAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1565,16 +1544,13 @@ "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/ConfirmCustomSegmentList.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomListMappingStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/subPages/CustomSegmentMappingStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectSelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectsPage.tsx" "no-restricted-syntax" 2 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomersOrProjectsPage.tsx" "rulesdir/no-default-id-values" 1 "../../src/pages/workspace/accounting/netsuite/import/NetSuiteImportMappingPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx" "rulesdir/no-default-id-values" 2 -"../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx" "react-hooks/set-state-in-effect" 1 -"../../src/pages/workspace/accounting/qbd/RequireQuickBooksDesktopPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAccountingMethodPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopCompanyCardExpenseAccountSelectCardPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/accounting/qbd/export/DynamicQuickbooksDesktopOutOfPocketExpenseEntitySelectPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1630,7 +1606,6 @@ "../../src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardAddWorkEmailPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardExpensifyCardPromotionBanner.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardFeedSelectorPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1644,13 +1619,8 @@ "../../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/companyCards/assignCard/ConfirmationStep.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx" "react-hooks/preserve-manual-memoization" 1 -"../../src/pages/workspace/downgrade/DowngradeIntro.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx" "no-restricted-imports" 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 "../../src/pages/workspace/expensifyCard/DynamicExpensifyCardLimitTypePage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 @@ -1662,10 +1632,8 @@ "../../src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/expensifyCard/issueNew/LimitTypeStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/expensifyCard/issueNew/spendRules/SetSpendRulesStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/hr/HRApprovalModePageBase.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/hr/HRProviderCard.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/hr/HRProviderCard.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/hr/utils.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/members/DynamicWorkspaceOwnerChangeErrorPage.tsx" "no-restricted-imports" 1 @@ -1680,7 +1648,6 @@ "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/members/WorkspaceOwnerChangeCheck.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceOwnerChangeCheck.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx" "react-hooks/set-state-in-effect" 2 "../../src/pages/workspace/receiptPartners/DynamicEditInviteReceiptPartnerPolicyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1693,10 +1660,7 @@ "../../src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/rules/PolicyRulesPageRevamp.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/rules/RulesProhibitedDefaultPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/rules/RulesRequireFieldsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "react-hooks/set-state-in-effect" 1 @@ -1707,13 +1671,12 @@ "../../src/pages/workspace/travel/ReviewingRequest.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/travel/WorkspaceTravelInvoicingExportPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 3 -"../../src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/travel/WorkspaceTravelInvoicingSettlementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/workspace/upgrade/GenericFeaturesView.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/upgrade/UpgradeIntro.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/withPolicy.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/withPolicyAndFullscreenLoading.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/workspace/withPolicyConnections.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 @@ -1832,7 +1795,7 @@ "../../tests/actions/IOUTest/SendInvoiceTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 7 "../../tests/actions/IOUTest/SplitSelfDMTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/actions/IOUTest/SplitTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 20 -"../../tests/actions/IOUTest/TrackExpenseTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 +"../../tests/actions/IOUTest/TrackExpenseTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/actions/IOUTest/TrackExpenseTest.ts" "no-restricted-imports" 1 "../../tests/actions/IOUTest/UpdateMoneyRequestTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/actions/IOUTest/UpdateMoneyRequestTest.ts" "no-restricted-imports" 1 @@ -1854,10 +1817,10 @@ "../../tests/actions/ReplaceOptimisticReportWithActualReportTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/actions/ReportFieldTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/actions/ReportFieldTest.ts" "no-restricted-imports" 1 -"../../tests/actions/ReportPreviewActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 29 +"../../tests/actions/ReportPreviewActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 28 "../../tests/actions/ReportPreviewActionUtilsTest.ts" "no-restricted-imports" 2 "../../tests/actions/ReportTest.ts" "@typescript-eslint/no-deprecated/buildNextStepNew" 1 -"../../tests/actions/ReportTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 58 +"../../tests/actions/ReportTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 50 "../../tests/actions/ReportTest.ts" "no-restricted-imports" 1 "../../tests/actions/SessionTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 12 "../../tests/actions/SubscriptionTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -2088,7 +2051,7 @@ "../../tests/unit/ReportActionsUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 36 "../../tests/unit/ReportLayoutUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/ReportNameUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 23 -"../../tests/unit/ReportPrimaryActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 35 +"../../tests/unit/ReportPrimaryActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 40 "../../tests/unit/ReportPrimaryActionUtilsTest.ts" "no-restricted-imports" 1 "../../tests/unit/ReportSecondaryActionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 31 "../../tests/unit/ReportSecondaryActionUtilsTest.ts" "no-restricted-imports" 2 @@ -2299,7 +2262,6 @@ "../../tests/unit/useTransactionViolationsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 21 "../../tests/unit/useWorkspacesTabIndicatorStatusTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../tests/unit/waitForPreviousRunsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../tests/unit/withAgentAccessDenied.test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/utils/TestHelper.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../tests/utils/collections/card.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/utils/collections/paymentMethods.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/contributingGuides/OBSERVABILITY_METRICS.md b/contributingGuides/OBSERVABILITY_METRICS.md index 7c4003294222..8a81d4b7dba3 100644 --- a/contributingGuides/OBSERVABILITY_METRICS.md +++ b/contributingGuides/OBSERVABILITY_METRICS.md @@ -154,10 +154,11 @@ This document lists all implemented telemetry metrics in the Expensify App. - User sees: Their message appears in chat - Technical: Message text rendered in report ([`src/pages/home/report/comment/TextCommentFragment.tsx`](https://github.com/Expensify/App/blob/8f123f449f1a4533830b18a1040c9a5f1949821d/src/pages/home/report/comment/TextCommentFragment.tsx#L70)) **Span ID**: Based on reportID -**Attributes**: `report_id`, `message_length`, `canceled_by_skeleton` +**Attributes**: `report_id`, `message_length`, `canceled_by_skeleton`, `send_message_source` **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. ## Failure Rates diff --git a/contributingGuides/SEQUENTIAL_QUEUE.md b/contributingGuides/SEQUENTIAL_QUEUE.md index a542110887cb..6caf262a294a 100644 --- a/contributingGuides/SEQUENTIAL_QUEUE.md +++ b/contributingGuides/SEQUENTIAL_QUEUE.md @@ -251,7 +251,7 @@ These are **two distinct deferral mechanisms** that are easy to confuse. **Problem `queueFlushedData` solves.** Apply a small piece of data **only after a full drain**: mark the app as loaded only once the queue has actually emptied, not mid-drain. -**How `queueFlushedData` works.** It is a **distinct, Onyx-persisted** buffer (`QUEUE_FLUSHED_DATA`), separate from the in-memory `QueuedOnyxUpdates`. `SequentialQueue.saveQueueFlushedData` appends a successfully-processed request's `queueFlushedData` field; the queue applies it via `Onyx.update` and clears it only when fully drained (after `flushOnyxUpdatesQueue`). Its sole producer is `App.getOnyxDataForOpenOrReconnect` (`OPEN_APP` / `ReconnectApp`), currently carrying exactly one entry: a merge of `HAS_LOADED_APP = true`. +**How `queueFlushedData` works.** It is a **distinct, Onyx-persisted** buffer (`QUEUE_FLUSHED_DATA`), separate from the in-memory `QueuedOnyxUpdates`. `SequentialQueue.saveQueueFlushedData` appends a request's `queueFlushedData` field only when the response's `jsonCode` is `CONST.JSON_CODE.SUCCESS`; a resolved-but-failed response (`HttpUtils.xhr` resolves application-level failures instead of rejecting them) does not save it, so it cannot wrongly mark `HAS_LOADED_APP` true on the next boot. The queue applies it via `Onyx.update` and clears it only when fully drained (after `flushOnyxUpdatesQueue`). Its sole producer is `App.getOnyxDataForOpenOrReconnect` (`OPEN_APP` / `ReconnectApp`), currently carrying exactly one entry: a merge of `HAS_LOADED_APP = true`. **Sharp edges.** - Both apply **only** when the queue reaches fully-empty. Under sustained WRITE pressure neither applies, so `HAS_LOADED_APP` never flips and the buffers accumulate. diff --git a/cspell.json b/cspell.json index 1b8055f4a77a..d5cf1d79cb0e 100644 --- a/cspell.json +++ b/cspell.json @@ -395,6 +395,7 @@ "USAA", "USCA", "USDVBBA", + "VCLOG", "Unassigning", "Uncapitalize", "Undelete", @@ -433,6 +434,7 @@ "aaroon", "abytes", "accountid", + "addrlen", "achreimburse", "actool", "adbd", @@ -440,6 +442,7 @@ "airshipconfig", "airside", "alrt", + "apikey", "americanexpress", "americanexpressfd", "americanexpressfdx", @@ -566,8 +569,11 @@ "domhandler", "domparser", "dont", + "DONTWAIT", + "NONBLOCK", "dotlottie", "dsyms", + "dylib", "durationMillis", "e2edelta", "ecash", @@ -701,7 +707,11 @@ "lastiPhoneLogin", "lastname", "lefthook", + "libc", + "Libc", + "libc's", "libexec", + "libstuff", "licence", "lightningcss", "linecap", @@ -889,6 +899,10 @@ "sdkmanager", "seamless", "seguiemj", + "sendto", + "sockfd", + "socklen", + "syscalls", "serveo", "setuptools", "sharee", @@ -908,8 +922,11 @@ "skia", "skip_codesigning", "soloader", + "sockaddr", + "Sockaddr", "spreadsheetml", "srgb", + "ssize", "stackoverflow", "startdate", "spki", @@ -1059,7 +1076,8 @@ "Prefetcher", "knip", "lottiefiles", - "jsitooling" + "jsitooling", + "Refetched" ], "ignorePaths": [ ".gitignore", diff --git a/docs/articles/Unlisted/Classic-Submit-Classic-Track-vs-Submit.md b/docs/articles/Unlisted/Classic-Submit-Classic-Track-vs-Submit.md new file mode 100644 index 000000000000..8d7f6a2f49d1 --- /dev/null +++ b/docs/articles/Unlisted/Classic-Submit-Classic-Track-vs-Submit.md @@ -0,0 +1,193 @@ +--- +title: Difference between Classic Track, Classic Submit, Submit plan, Collect, and Control +description: Learn the difference between Classic Track and Classic Submit plans in Expensify Classic and newer workspace-based plans like Submit, Collect, and Control. +keywords: classic submit vs submit plan, classic track vs collect expensify, expensify personal vs shared workspace, submit vs collect vs control expensify, group workspace expensify +internalScope: Audience is members confused about plan types, support agents, and onboarding users. Covers differences between Classic Track, Classic Submit, Submit plan, Collect, and Control workspaces. Does not cover detailed setup steps or billing configuration for paid plans +--- + +# Difference between Classic Track, Classic Submit, Submit plan, Collect, and Control + +Expensify includes both legacy personal workspace plans and newer shared workspace plans. If you're unsure which one you're using, this guide explains the differences between **Classic Track**, **Classic Submit**, the **Submit plan**, **Collect**, and **Control**. + +--- + +## Which plan am I on in Expensify + +If you are asking: +- "what plan am I on" +- "which plan am I on" +- "what workspace type do I have" +- "am I on collect or control" +- "is this a personal or shared workspace" + +Use this guide: + +- If you **cannot invite members**, you are using a **personal workspace (Classic Track or Classic Submit)** +- If you **can invite members**, you are using a **shared workspace (Submit, Collect, or Control)** +- If your workspace appears under **Settings > Workspaces > Individual**, it is a **personal (individual) workspace** +- If your workspace appears under **Workspaces**, it is a **shared (group) workspace** + +On web, use the navigation tabs on the left to open **Settings > Workspaces**. +On mobile, tap the hamburger menu in the top-left corner, then select **Workspaces**. + +--- + +## Understand what Classic Track and Classic Submit plans are + +**Classic Track** and **Classic Submit** are personal workspace plans used in Expensify Classic. + +These plans: +- Are personal workspaces created for individual use +- Are managed in **Expensify Classic** +- Appear under **Settings > Workspaces > Individual** + +These plans are still used today. They're created for all users and may still be used in certain situations: +- If you are not paying for a personal workspace +- If you are subscribed via Apple In-App Purchase (IAP) + +In these plans: +- You manage your own expenses in a personal workspace +- The workspace is not designed for shared collaboration + +Classic Track and Classic Submit plans are not shared workspaces and do not support multiple members collaborating in the same workspace. + +--- + +## Understand what the Submit plan workspace is + +The **Submit plan** is a shared workspace designed for employees to submit expenses without requiring full company setup. + +This plan: +- Exists in **New Expensify** +- Allows multiple members to join the same workspace +- Enables basic collaboration across members + +--- + +## Identify whether you are using a Classic Track, Classic Submit, or Submit plan + +You are using a **Classic Track** or **Classic Submit** plan if: +- You manage your workspace in **Expensify Classic** +- Your workspace appears under **Settings > Workspaces > Individual** +- You refer to your plan as **Classic Track** or **Classic Submit** + +You are using the **Submit plan** if: +- You are working in a shared workspace designed for submitting expenses without requiring full company setup +- You can invite teammates to collaborate in that workspace +- Other members can join the same workspace and use the same configuration + +Classic Track and Classic Submit plans are not the same as the Submit plan. Classic plans are personal workspaces in Expensify Classic, while the Submit plan is a shared workspace. + +--- + +## Compare Classic, Submit, Collect, and Control workspaces + +| Feature | Classic Track / Classic Submit | Submit plan | Collect | Control | +|--------|-------------------------------|-------------|---------|---------| +| Workspace type | Personal workspace | Shared workspace | Shared workspace | Shared workspace | +| Where it is managed | Expensify Classic | New Expensify | New Expensify or Expensify Classic | New Expensify or Expensify Classic | +| Workspace location | Settings > Workspaces > Individual | Workspaces | New: Workspaces
Classic: Settings > Workspaces > Group | New: Workspaces
Classic: Settings > Workspaces > Group | +| Number of members | One member | Multiple members | Multiple members | Multiple members | +| Collaboration | Not supported | Basic collaboration | Full collaboration | Full collaboration | +| Inviting teammates | Not supported | Supported | Supported | Supported | +| Submitting to a manager | Classic Submit only | Supported | Supported | Supported | +| Approvals | Not supported | Limited (upgrade required) | Supported | Advanced approvals | +| Payments and reimbursements | Not supported | Not supported | Supported | Supported | +| Accounting integrations | Not supported | Not supported | Supported | Supported | +| Typical use case | Personal expense tracking or submission | Employees submitting expenses without company setup | Businesses managing team expenses | Businesses needing advanced controls and policy enforcement | + +Control workspaces include all Collect features, with additional controls, functionality, and pricing. + +--- + +## Understand how workspaces may change from Classic Track or Classic Submit to Collect + +If you were using a paid **Classic Track** or **Classic Submit** plan, your workspace may be converted to a **Collect workspace** in one of the following ways: + +- You upgrade your workspace to access additional features +- Your workspace is migrated as part of Expensify’s transition to workspace-based plans + +This does not happen to all users and depends on how the workspace is used and billed. + +In both cases: +- Your data remains the same +- Your workspace gains access to additional features like approvals and integrations +- Your workspace becomes a shared workspace + +Classic Track and Classic Submit plans follow the same migration and upgrade paths. + +--- + +# FAQ + +## Why can’t I invite people to my workspace? + +You’re likely using a **Classic Track** or **Classic Submit** plan, which is a personal workspace that does not support shared collaboration, or you aren't an admin on a Collect or Control workspace. + +--- + +## What is an individual workspace in Expensify? + +An **individual workspace** is the same as a **personal workspace**. + +It includes: +- Classic Track +- Classic Submit + +It is used by one member and does not support collaboration. + +--- +## What is a group workspace in Expensify? + +A **group workspace** is the same as a **shared workspace**. + +It includes: +- Submit +- Collect +- Control + +It allows multiple members and shared settings. + +--- + +## What is a submit-only policy? + +A **submit-only policy** typically refers to the **Submit plan**. + +It allows: +- Submitting expenses +- Basic collaboration + +It does not include: +- Approvals +- Reimbursements +- Accounting integrations + +--- + +## What is the difference between Submit, Collect, and Control? + +- **Submit**: Basic expense submission (submit-only policy) +- **Collect**: Full expense management with approvals and reimbursements +- **Control**: Advanced controls, policies, and approvals +- + +--- + +--- + +## Can I switch from a Classic Track or Classic Submit plan to the Submit plan? + +You can create a new Submit workspace from the pricing page or during onboarding. Classic Track and Classic Submit workspaces cannot be converted to Submit workspaces. + +--- + +## Can I still use Classic Track or Classic Submit? + +Yes. **Classic Track** and **Classic Submit** plans are still available and may still be created in certain situations: +- If you are not paying for a personal workspace +- If you are subscribed via Apple In-App Purchase (IAP) + +However, newer features and improvements focus on workspace-based plans such as Submit, Collect, and Control. + +The **Submit plan** is different from **Collect** and **Control**. While all are shared workspaces, the Submit plan is designed for employees to submit expenses without requiring full company setup. diff --git a/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md b/docs/articles/Unlisted/Upcoming-Integrations-And-Accounting.md index 0f38ad00e095..e7c26fa841bb 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 Certinia, Rillet, Puzzle.io, Microsoft Dynamics 365 Business Central, NetSuite REST, and HR imports. Not for public search indexing. -keywords: upcoming, coming soon, planned, roadmap, Certinia, FinancialForce, 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, 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 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. -- **Certinia integration** – Native connection between New Expensify and Certinia (formerly FinancialForce), allowing direct export of expense data into Certinia without manual workarounds. - **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. diff --git a/docs/articles/expensify-classic/connections/certinia/Connect-To-Certinia.md b/docs/articles/expensify-classic/connections/certinia/Connect-To-Certinia.md index 9a728d7602bd..8086deff1eb9 100644 --- a/docs/articles/expensify-classic/connections/certinia/Connect-To-Certinia.md +++ b/docs/articles/expensify-classic/connections/certinia/Connect-To-Certinia.md @@ -14,7 +14,7 @@ Certinia (formerly FinancialForce) is a cloud-based financial management solutio Before connecting Expensify to Certinia, complete the following setup steps: 1. **Install the Expensify bundle in Certinia** - - [PSA/SRP Installer](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t2M000002J0BHD%252Fpackaging%252FinstallPackage.apexp%253Fp0%253D04t2M000002J0BH) + - [PSA/SRP Installer](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t2M000002J0BM) - [FFA Installer](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t4p000001UQVj) 2. **Verify Contact Details in Certinia** diff --git a/docs/articles/new-expensify/ai-agents/Create-and-Use-Custom-Agents.md b/docs/articles/new-expensify/ai-agents/Create-and-Use-Custom-Agents.md index 76bb359220be..9e4d59f54db6 100644 --- a/docs/articles/new-expensify/ai-agents/Create-and-Use-Custom-Agents.md +++ b/docs/articles/new-expensify/ai-agents/Create-and-Use-Custom-Agents.md @@ -115,6 +115,15 @@ Deleting an agent closes its Expensify account. 4. Click **Delete agent**. 5. Confirm the deletion. +You can also delete an agent while [Copiloting into its account](#how-to-copilot-into-an-agents-account): + +1. In the navigation tabs (on the left on web, on the bottom on mobile), click **Account**. +2. Click **Security**. +3. Click **Close account**. +4. In the **Delete agent?** confirmation, click **Delete**. + +Deleting the agent from the **Security** page also ends the Copilot session and returns you to your own account. This step requires an internet connection. + Deleting an agent can't be undone. --- @@ -176,6 +185,13 @@ On Collect plans, workspaces support a single approver. An agent can be used as Yes. When you create an agent, it's automatically added as a full-access Copilot on your own account, giving it delegated access to your personal context so it can manage your expenses and reports on your behalf. You don't need to add it by hand. You can review or remove this access at any time in the **Copilot: Delegated Access** section under **Account > Security**. [Learn how to manage Copilot access](/articles/new-expensify/settings/Manage-Copilot-Access). +## Why does the Security page look different when I Copilot into an agent? + +When you Copilot into an agent's account and open **Account > Security**, some options are adjusted because you're managing an agent rather than a real member's account: + +- **Device management** and **Merge accounts** are hidden, since they don't apply to an agent. +- **Close account** deletes the agent and ends the Copilot session instead of opening the standard close-account flow. + ## Can an agent make mistakes? Yes. Agents follow natural-language instructions and are powered by AI, so they may occasionally behave unexpectedly. Write clear, specific instructions and review what an agent does. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/Learn-How-Billing-and-Subscriptions-Work.md b/docs/articles/new-expensify/billing-and-subscriptions/Learn-How-Billing-and-Subscriptions-Work.md index c6ee1f13feee..16996780784b 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/Learn-How-Billing-and-Subscriptions-Work.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/Learn-How-Billing-and-Subscriptions-Work.md @@ -48,7 +48,7 @@ Your monthly Expensify bill is calculated based on: - The number of billable members - Whether your organization qualifies for reduced pricing through the Expensify Card -To learn more, see [How the Expensify Card Discount Works](/docs/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing#how-the-expensify-card-discount-works). +To learn more, see [How the Expensify Card Discount Works](/articles/new-expensify/billing-and-subscriptions/explore-plans-subscriptions-and-pricing/Understand-Expensify-Pricing#how-the-expensify-card-discount-works). ## Who pays the monthly bill diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Request-Tax-Exemption.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Request-Tax-Exemption.md index ff2b9178556c..19c0cd882ada 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Request-Tax-Exemption.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Request-Tax-Exemption.md @@ -39,6 +39,10 @@ After your request is approved, sales tax will no longer be applied to future Ex # FAQ +## How long does tax-exemption review take? + +Review can take up to 5 business days. Sales tax may continue to apply to bills issued while your request is under review. + ## What happens to bills that included tax before my exemption was approved? If you were charged sales tax before your tax-exempt status was approved, contact Concierge or your Account Manager to request a refund of the tax amount. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership.md index 44b3862be8fe..b0b8002f423a 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Transfer-Workspace-Ownership.md @@ -91,3 +91,7 @@ You can take over an Annual subscription only if you're a Workspace Admin on eve Billing history only includes subscription charges made while you're the Workspace owner. Charges paid by the previous Workspace owner won't appear in your billing history. If you need to review previous subscription charges, ask the previous Workspace owner to follow the steps to [make billing receipts visible to other Workspace Admins](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Find-Your-Expensify-Billing-Receipt#how-can-i-make-billing-receipts-visible-to-other-workspace-admins). + +## How can I confirm the workspace ownership transfer worked? + +Click the navigation tabs (on the left on web, on the bottom on mobile), then select **Workspaces**. In the **Owner** column, verify that your name appears as the owner of the workspace. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Cancel-an-Annual-Subscription.md b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Cancel-an-Annual-Subscription.md index 5ec866c93ba1..3a1e272b6e21 100644 --- a/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Cancel-an-Annual-Subscription.md +++ b/docs/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Cancel-an-Annual-Subscription.md @@ -10,15 +10,15 @@ platform: new # Cancel an Annual Subscription -If you're a Workspace owner, you can ask Expensify to end your workspace's Annual subscription and continue on Pay-per-use billing instead. Canceling the Annual subscription ends the 12-month commitment but doesn't delete your workspace or interrupt access. Your workspace continues on Pay-per-use billing. +If you're a Workspace owner, you can request to end your workspace's Annual subscription and continue on Pay-per-use billing instead. Canceling the Annual subscription ends the 12-month commitment but doesn't delete your workspace or interrupt access. Your workspace continues on Pay-per-use billing. -Selecting **Cancel subscription** submits a *request* to cancel. It does not cancel your subscription on its own. Because an Annual subscription is a 12-month commitment, requests are reviewed to determine whether they qualify for cancellation, and not all requests are approved. +Most Annual subscriptions can't be canceled before the end of the 12-month commitment. Selecting **Cancel subscription** submits a _request_ that Expensify reviews. Only requests that meet the cancellation criteria are approved. For more details, see [When You Can Change Your Subscription](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-subscription/Learn-About-Plan-and-Subscription-Management#when-you-can-change-your-subscription). --- -## Who can cancel an Annual subscription +## Who can request Annual subscription cancellation Only the Workspace owner can request a cancellation, and only when the workspace is on an **Annual** subscription. **Cancel subscription** appears under **Account > Subscription** after your free trial ends and once a payment card has been added or you've been billed at least once. 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 e249fba38cd6..b2a435220256 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 @@ -48,6 +48,10 @@ If you have an Annual subscription, changes to your plan may be restricted. To l Yes. You can upgrade a workspace to Control at any time by returning to the **Plan type** setting. +## What happens to my bill when I downgrade to Collect? + +Your new plan's rate applies to your next monthly bill. The current billing period isn't pro-rated or refunded. If you have an outstanding balance, resolve it before downgrading—see [Fix a Billing Issue](/articles/new-expensify/billing-and-subscriptions/manage-your-subscription-and-billing/manage-billing/Fix-a-Billing-Issue). + ## Why is my Plan type locked? This happens when you're on the Control plan with an Annual subscription. You've committed to your active members on Control until the subscription term ends, so you can't downgrade to Collect before that date. The locked message shows exactly when your term ends. diff --git a/docs/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend.md b/docs/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend.md index 7a5d6e4b1054..d3597007cc64 100644 --- a/docs/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend.md +++ b/docs/articles/new-expensify/concierge-ai/How-Concierge-Analyzes-Spend.md @@ -57,9 +57,12 @@ When Concierge analyzes spend proactively, it sends a message to the #admins roo Insights are shared periodically and may vary in timing, format, and level of detail. -You can ask Concierge your own expense questions in the #admins room for a workspace, or in your Concierge chat. +You can ask Concierge your own expense questions anywhere Concierge is available. Concierge scopes the answer to where you're chatting: - In the #admins room, answers are scoped to that workspace. - In your Concierge chat, questions can span all of the workspaces you have access to. + - In a workspace chat, answers are scoped to that workspace and the member who owns the chat. + - In an expense report, answers are scoped to that report's workspace and submitter. + - In an expense thread, Concierge can answer questions about that specific expense, such as "Did this merchant increase their prices compared to previous months?" [Learn how to find the #admins room for a workspace](/articles/new-expensify/chat/Expensify-Chat-Rooms-for-Admins). @@ -100,4 +103,4 @@ You can ask direct lookup questions about your expense data, such as who spent w ## Where can I ask Concierge about my expenses? -You can ask in the #admins room for a workspace, where answers are scoped to that workspace, or in your Concierge chat, where questions can span all of the workspaces you have access to. Concierge respects your access level, so you'll only see data you're allowed to view. +You can ask anywhere Concierge is available: the #admins room, your Concierge chat, workspace chats, expense reports, and expense threads. Concierge scopes each answer to where you're chatting—for example, questions in the #admins room and workspace chats are scoped to that workspace, while questions in your Concierge chat can span all of the workspaces you have access to. Concierge respects your access level, so you'll only see data you're allowed to view. diff --git a/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md b/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md new file mode 100644 index 000000000000..53e2208557d9 --- /dev/null +++ b/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md @@ -0,0 +1,143 @@ +--- +title: Configure Certinia +description: Configure import, export, and advanced sync settings for Expensify's Certinia (FinancialForce) integration in New Expensify. +keywords: [New Expensify, Certinia settings, FinancialForce, import configuration, export preferences, auto-sync, dimensions, FFA, PSA, SRP] +internalScope: Audience is Workspace Admins. Covers configuring Certinia import, export, and advanced sync settings, does not cover connecting Certinia or troubleshooting. +order: 2 +--- + + +After connecting Certinia, set up how data flows between Expensify and Certinia using the **Import**, **Export**, and **Advanced** settings. Some options differ depending on whether you use the **FFA** or **PSA/SRP** module — those differences are noted below. + +To open these settings, go to **Workspaces > [Workspace Name] > Accounting** from the navigation tabs (on the left on web, on the bottom on mobile), then select **Import**, **Export**, or **Advanced** under the Certinia connection. + +--- + +## How to configure Certinia import settings + +From **Workspaces > [Workspace Name] > Accounting**, click **Import** under the Certinia connection. + +## How to import categories from Certinia + +How categories are imported depends on your module: + +- **FFA – Chart of Accounts:** Your **Prepaid Expense Type** and **Profit & Loss** accounts are imported and used as expense categories. +- **SRP – Expense Type GLA Mappings:** Expense Type GLA Mappings are imported as expense categories. +- **PSA:** PSA does not import or export categories. + +Disable any unnecessary categories under **Workspaces > [Workspace Name] > Categories**. Every expense must have a category to export successfully. + +## How to import dimensions from Certinia (FFA) + +Expensify imports up to four dimension levels. For each dimension, choose how it's imported: + +- **Do not map:** Certinia defaults apply and no data is imported. +- **Tags:** Employees select the dimension from the **Tags** section per expense. +- **Report Fields:** Employees apply the dimension at the report level. + +Manage these under **Workspaces > [Workspace Name] > Tags** and **Workspaces > [Workspace Name] > Reports**. + +## How to import projects and assignments from Certinia (PSA/SRP) + +Import **Projects**, **Assignments**, or **Projects & Assignments** as tags: + +- **Milestones** are optional. +- If only projects are imported, the account is derived from the project. +- If assignments are imported, both the account and project are derived from the assignment. + +> **Note:** To use a project without an assignment, enable **Allow Expenses Without Assignment** in Certinia. + +## How to import tax rates from Certinia + +Toggle on **Tax** to import tax rates from Certinia and apply them to expenses. Set default rates per category under **Workspaces > [Workspace Name] > Categories**. + +--- + +## How to configure Certinia export settings + +From **Workspaces > [Workspace Name] > Accounting**, click **Export** under the Certinia connection. + +## How to set the preferred exporter for Certinia + +Assign a **Preferred Exporter**. This member is responsible for exporting reports and receives any export error notifications. Any Workspace Admin can export reports, but Concierge auto-exports on behalf of the Preferred Exporter. + +## How to set the Certinia export status + +Choose whether reports export as **Complete** or **In Progress**. + +## How to set the Certinia export date + +Choose which date Expensify uses when creating records in Certinia: + +- **Date of last expense** +- **Submitted date** +- **Exported date** + +## How reimbursable and non-reimbursable reports export to Certinia + +Both reimbursable and non-reimbursable reports export as: + +- **Payable Invoices** (FFA), or +- **Expense Reports** (PSA/SRP) + +If a report contains both reimbursable and non-reimbursable expenses, Expensify creates separate payable invoices or expense reports for each type. + +## How to set a default vendor for Certinia (FFA) + +Select a vendor from your Certinia FFA account. This vendor is assigned to non-reimbursable payable invoices. + +--- + +## How to configure Certinia advanced sync settings + +From **Workspaces > [Workspace Name] > Accounting**, click **Advanced** under the Certinia connection. + +## How to enable auto-sync for Certinia + +We recommend enabling **Auto-sync** to keep your data up to date. Auto-sync performs daily updates to your coding and automatically exports reports upon final approval: + +- **Non-reimbursable expenses:** Export immediately after final approval. +- **Reimbursable expenses:** Export when the report is reimbursed or marked as reimbursed. + +## How to sync reimbursed reports with Certinia + +Keep reimbursement status in sync between Expensify and Certinia for reports that have been paid. + +## How to export tax as non-billable in Certinia + +Decide whether tax amounts are billed to clients when exporting billable expenses. + +## How foreign currency (multi-currency) export works in Certinia (PSA/SRP) + +When employees submit expenses in multiple currencies, Certinia may display up to three currencies per report: + +- **Summary Total Reimbursement Amount:** Uses the project currency. +- **Amount field on the expense line:** Uses the Expensify Workspace default report currency. +- **Reimbursable Amount on the expense line:** Uses the submitter's resource currency. + +--- + +# FAQ + +## What happens if a report fails to export to Certinia? + +If a report isn't exported: + +- The **Preferred Exporter** receives an email with error details. +- The error is recorded in the **report's comments**. +- The report appears in the exporter's Expensify inbox as **Awaiting Export**. + +Fix the error, then have a Workspace Admin manually export the report. + +## Will enabling Auto-sync affect previously approved reports? + +No. Enabling Auto-sync does not affect previously approved or reimbursed reports. If approved reports haven't been exported, export them manually or mark them as manually entered. + +## How do reports map to records in Certinia? + +- **FFA (Payable Invoices):** Account Name = the account linked to the submitter's email, Reference 1 = the report URL, Invoice Description = the report title. +- **PSA/SRP (Expense Reports):** Expense Report Name = the report title, Resource = the submitter's email, Description = the report URL, Approver = the Expensify report approver. + +## How do I export tax? + +Manage Expensify tax rates under **Workspaces > [Workspace Name] > Tax**. The tax amount calculated on each expense is exported to Certinia. diff --git a/docs/articles/new-expensify/connections/certinia/Connect-To-Certinia.md b/docs/articles/new-expensify/connections/certinia/Connect-To-Certinia.md new file mode 100644 index 000000000000..a59d0907ad0c --- /dev/null +++ b/docs/articles/new-expensify/connections/certinia/Connect-To-Certinia.md @@ -0,0 +1,112 @@ +--- +title: Connect to Certinia +description: Connect Certinia (formerly FinancialForce) to New Expensify to streamline expense reporting, approvals, and accounting export. +keywords: [New Expensify, Certinia integration, FinancialForce, connect Certinia, Salesforce, FFA, PSA, SRP] +internalScope: Audience is Workspace Admins. Covers connecting Certinia (FFA and PSA/SRP) to New Expensify, does not cover configuring import/export settings or troubleshooting. +order: 1 +--- + + +Connect your Expensify Workspace to Certinia (formerly FinancialForce) to automate expense syncing, approvals, and accounting export. Certinia is a cloud-based financial management solution built on Salesforce, and Expensify supports both the **FFA** (Financial Force Accounting) and **PSA/SRP** (Professional Services Automation) modules. This guide walks you through installing the Expensify bundle, preparing your Certinia account, and finalizing the connection in New Expensify. + +**Note:** The Certinia integration is only available on the **Control** plan. + +**Before you begin, make sure:** + +- You can log into Certinia (Salesforce) as an administrator +- A Certinia user and contact exists that matches your primary email in Expensify +- Each employee who will submit reports has a Certinia contact whose email matches their Expensify account email + +--- + +## How to install the Expensify bundle in Certinia + +Install the package that matches your Certinia module. You only need the bundle for the module you use. + +- **FFA:** [FFA Installer](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t4p000001UQVj) +- **PSA/SRP:** [PSA/SRP Installer](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t2M000002J0BM) + +Follow the Salesforce prompts to complete the installation. + +--- + +## How to verify contact details in Certinia + +1. Confirm there is a Certinia user and contact whose email matches your **primary email** in Expensify. +2. Create contacts for every employee who will submit expense reports. +3. Make sure each contact's email exactly matches their Expensify account email. + +--- + +## How to complete PSA/SRP setup before connecting + +> Skip this step if you use **FFA only**. + +If you use Certinia PSA/SRP, complete the following before connecting in Expensify: + +## Configure Permission Controls + +1. Go to **Permission Controls** in Certinia and create a new permission control. +2. Set yourself (the exporter) as the user. +3. Select the resource (the report submitter). +4. Grant all available permissions. + +## Configure Project Permissions + +1. Go to **Projects > [Select a Project] > Project Attributes**. +2. Click **Edit** and enable **Allow Expenses Without Assignment**. +3. Confirm the setting is checked under the Project Attributes section, then save. + +## Set up Expense Types (SRP only) + +1. Go to **Main Menu > + > Expense Type GLA Mappings**. +2. Click **New** to add and configure expense type mappings. + +--- + +## How to connect to Certinia in New Expensify + +1. From the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [Workspace Name] > Accounting**. +2. Select **Certinia > Connect to Certinia**. +3. Log in to your Certinia (Salesforce) account when prompted. +4. Follow the on-screen prompts to authorize the connection. +5. Select the Certinia **company** to use for importing and exporting data. + +Once connected, continue to [Configure Certinia](/articles/new-expensify/connections/certinia/Configure-Certinia) to set up import, export, and advanced settings. + +--- + +## How to connect to a Certinia sandbox + +If you want to test the integration against a Certinia **sandbox** instead of your production account, use the sandbox connection option. The configuration experience is identical to production once connected — only the connection endpoints and bundle differ. + +1. Install the sandbox version of the Expensify bundle in your Certinia sandbox (the sandbox uses different bundle install links and OAuth endpoints than production). +2. From the navigation tabs, go to **Workspaces > [Workspace Name] > Accounting**. +3. Select **Certinia > Connect to Certinia Sandbox**. +4. Log in to your Certinia **sandbox** account and follow the on-screen prompts. +5. Select the company to use. + +Once connected, continue to [Configure Certinia](/articles/new-expensify/connections/certinia/Configure-Certinia) to set up import, export, and advanced settings. + +--- + +# FAQ + +## What's the difference between FFA and PSA/SRP? + +- **FFA (Financial Force Accounting)** is used for general accounting. Expenses export as **Payable Invoices**, and you map dimensions and a chart of accounts. +- **PSA/SRP (Professional Services Automation)** is used by project-based organizations. Expenses export as **Expense Reports**, and you import projects and assignments. + +Expensify automatically tailors the available configuration options to the module you connect. + +## Do employees who submit reports need a Certinia license? + +Employees who only submit expense reports do not need Certinia access — but each must have a Certinia **contact** whose email matches their Expensify account email so their reports can map correctly on export. + +## How do I disconnect Certinia? + +1. From the navigation tabs, go to **Workspaces > [Workspace Name] > Accounting**. +2. Select the three dots **(⋮)** next to the Certinia connection. +3. Click **Disconnect** and confirm. + +Disconnecting clears all imported options from Expensify. diff --git a/docs/articles/new-expensify/connections/certinia/Troubleshooting/Certinia-FAQ.md b/docs/articles/new-expensify/connections/certinia/Troubleshooting/Certinia-FAQ.md new file mode 100644 index 000000000000..3aa6ae5738c3 --- /dev/null +++ b/docs/articles/new-expensify/connections/certinia/Troubleshooting/Certinia-FAQ.md @@ -0,0 +1,96 @@ +--- +title: Certinia Integration FAQ and Troubleshooting +description: Troubleshoot the Certinia (FinancialForce) integration in New Expensify — export failures, manual export rules, company card mapping, project limitations, and disconnecting. +keywords: [New Expensify, Certinia, FinancialForce, export not working, manual export, company card export, project status, disconnect Certinia, FFA, PSA, SRP] +internalScope: Audience is Workspace Admins and Domain Admins. Covers common Certinia export and troubleshooting questions, does not cover connecting or configuring Certinia or CER### error codes. +order: 3 +--- + + +This article covers common issues with the Certinia (formerly FinancialForce) integration in New Expensify. It's intended for Workspace Admins and Domain Admins using FFA or PSA/SRP. + +For specific Certinia error codes (the `CER###` errors that appear in a report's comments), see the [Certinia error code articles](/expensify-classic/hubs/connections/certinia/Troubleshooting). + +--- + +# FAQ + +## Why is my report not automatically exporting to Certinia? + +An error is preventing the report from exporting automatically. You can find the error in several ways: + +- The **Preferred Exporter** (set in your Export settings) receives an email with the error details. +- The error appears in the report's comments. +- Automatic exports keep failing until the error is resolved. + +**How to resolve:** + +1. Open the affected report. +2. Review the error message in the comments. +3. Make the required corrections. +4. Have a Workspace Admin manually export the report. + +--- + +## Why am I unable to manually export a report to Certinia? + +Only reports in **Approved**, **Done**, or **Paid** status can be exported. If the report is in **Draft** status, selecting export may show an empty screen. + +**How to resolve:** + +1. Submit the report if it's in Draft status. +2. Have an approver approve the report if it's Outstanding. +3. Once the report is Approved, Done, or Paid, a Workspace Admin can manually export it. + +--- + +## Why are company card expenses exporting to the wrong account? + +Company card expenses may export to the wrong account if the card export mapping is incorrect or the exporter doesn't have the right permissions. + +**Verify the company card export mapping:** + +1. Confirm the correct export account is mapped for the affected card under your company card settings. +2. Confirm the expenses display the **Card + Lock icon**, which means they're mapped correctly. +3. Confirm the **Preferred Exporter** is a **Domain Admin**. + +If the Preferred Exporter is not a Domain Admin, exports default to the fallback company card account set in the Workspace configuration. + +--- + +## Are there export limitations based on projects in Certinia? (PSA/SRP) + +Yes. Project settings in Certinia can prevent expenses from exporting. + +**Expenses can be exported when:** + +- **Project Status** = Active or In Progress +- **Assignment Status** = Closed, Active, or Completed +- **Closed for Expense Entry** = Unchecked + +**Expenses cannot be exported when:** + +- **Project Status** = Closed +- **Closed for Expense Entry** = Checked + +**How to resolve:** + +1. Check the Project Status in Salesforce. If it's Closed, expenses can't be entered or exported. If Active or In Progress, continue. +2. Verify the **Closed for Expense Entry** setting. If checked, uncheck it to allow expense exports. +3. Manually test expense entry in Salesforce. If manual entry works, the Expensify–Certinia export should also work. + +--- + +## Does assignment status affect expense exports? + +No. Assignment Status in Certinia (Closed, Active, or Completed) does not affect expense entry or export. Only **Project Status** and the **Closed for Expense Entry** setting affect export eligibility. + +--- + +## How do I disconnect the Certinia connection? + +1. From the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [Workspace Name] > Accounting**. +2. Select the three dots **(⋮)** next to the Certinia connection. +3. Click **Disconnect** and confirm. + +Disconnecting removes the active integration and clears all imported options from Expensify. diff --git a/docs/articles/new-expensify/expensify-card/Approve-UK-EU-Expensify-Card-Transactions-Using-3DS-Authentication.md b/docs/articles/new-expensify/expensify-card/Approve-UK-EU-Expensify-Card-Transactions-Using-3DS-Authentication.md new file mode 100644 index 000000000000..9e740d94b762 --- /dev/null +++ b/docs/articles/new-expensify/expensify-card/Approve-UK-EU-Expensify-Card-Transactions-Using-3DS-Authentication.md @@ -0,0 +1,137 @@ +--- +title: Approve UK/EU Expensify Card Transactions Using 3DS Authentication +description: Learn how to approve or deny UK/EU Expensify Card transactions that require 3-D Secure (3DS) authentication, including biometric setup and supported methods. +keywords: [New Expensify, Expensify Card UK, EU, 3DS, 3-D Secure, Visa Secure, transaction approval, biometric authentication, passkey, online transactions] +internalScope: Audience is UK and EU Expensify Card cardholders. Covers approving and denying transactions that require 3DS authentication, the approval time limit, enabling biometric authentication, managing authentication settings, and supported authentication methods. Does not cover US Expensify Card transactions, card setup, or spending limits. +--- + +# Approve UK/EU Expensify Card Transactions Using 3DS Authentication + +When you use the Expensify Card in the UK or EU, some online transactions require additional verification before they can be completed. + +Expensify uses **[Visa Secure](https://www.visa.co.uk/products/visa-secure.html)** to protect online transactions. Visa Secure is built on top of the **3-D Secure (3DS)** protocol, which is commonly required for eCommerce transactions in the UK and Europe. This additional verification helps protect your account from unauthorized purchases. + +When a transaction requires verification, you'll receive a notification and must approve or deny the transaction in Expensify. + +## How to approve a UK/EU Expensify Card transaction + +When a transaction requires approval, you'll receive a Concierge message with the transaction details. + +To approve the transaction: + +1. Open Expensify from the notification, or open the app manually. +2. Review the transaction details, including: + - Merchant name + - Transaction amount +3. Tap **Approve**. +4. Follow the prompts to authenticate using one of the following methods: + - Face ID or fingerprint (biometrics) + - Device passcode or PIN + - Passkey (on web or supported devices) +5. Wait for confirmation that the transaction has been approved. + +Once approved, the transaction will automatically proceed with the merchant. + +## How to deny a UK/EU Expensify Card transaction + +If you don't recognize the transaction: + +1. Open Expensify from the notification. +2. Review the transaction details. +3. Tap **Deny**. + +You do not need to authenticate to deny a transaction. + +After denying the transaction: + +- The transaction is immediately declined. +- If you suspect fraud, we recommend taking the following actions: + - [Cancel your Expensify Card](/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card). + - [Lock your Expensify account if you believe your account may have been compromised](/articles/new-expensify/settings/Report-Suspicious-Activity). + - Contact Concierge for assistance. + +## What happens if you don't approve a UK/EU Expensify Card transaction in time? + +You have approximately **8 minutes** to approve a transaction. + +If you don't approve it within that time: + +- The transaction is automatically declined. +- You'll need to retry the purchase with the merchant. + +## How to enable biometric authentication for UK/EU Expensify Card approvals + +You'll be prompted to enable biometric authentication the first time you approve a transaction or test authentication. + +To enable biometrics: + +1. Enter the verification code sent to your email (magic code). +2. Follow the on-screen prompts. +3. Confirm using: + - Face ID + - Fingerprint authentication + - Device credentials + +Once enabled, future transaction approvals are faster and won't require an email verification code. + +## How to manage authentication settings for the UK/EU Expensify Card + +To manage biometric authentication or passkeys: + +1. Go to **Account**. +2. Select **Security**. +3. Open **Face/Fingerprint & Passkey**. + +From here, you can revoke authentication access for any/all devices you have previously enabled. + +## What authentication methods are supported for UK/EU Expensify Cards? + +To meet UK and EU security requirements, Expensify supports the following authentication methods: + +- **Biometrics:** Face ID or fingerprint +- **Device credentials:** Passcode or PIN +- **Passkeys:** Supported on web and mobile browsers + +# FAQ + +## Why do I need to approve some transactions but not others? + +UK and EU regulations require additional verification for many online payments. Some transactions qualify for exemptions and are automatically approved, while others require manual verification. + +## Can I approve transactions on the web? + +Yes, but only if passkeys are enabled on your account. Otherwise, you'll need to use the mobile app to approve transactions. + +## Can someone else approve transactions on my behalf? + +Yes. If you have a copilot configured on your account, they can approve transactions on your behalf, provided they have also enabled biometric authentication. See [Copilot access](/articles/new-expensify/settings/Copilot-Access). + +## What happens if biometric authentication fails? + +If authentication fails: + +- You can try again. +- If authentication fails multiple times, the transaction may be denied. +- You may need to re-enable biometric authentication in your device settings. + +## Why am I not receiving 3DS transaction approval requests? + +If you're not receiving 3DS (3-D Secure) transaction approval requests: + +1. Go to **Account**. +2. Tap **Troubleshoot**. +3. Select **Clear cache and restart**. + +If the issue persists, contact Concierge for further assistance. + +## Can I approve transactions on multiple devices? + +Yes. You can approve transactions from any device where you're signed in and have authentication enabled. + +## What should I do if I don't receive a notification? + +Open Expensify manually. If a transaction is awaiting approval, you'll see the approval prompt when the app opens. + +## Can I turn off transaction approvals? + +No. 3DS authentication is required for UK/EU Expensify Cards and cannot be disabled. diff --git a/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md b/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md index 8d6249bfdd17..4a0fe02a484d 100644 --- a/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md +++ b/docs/articles/new-expensify/expensify-card/Cardholder-Settings-and-Features.md @@ -92,6 +92,8 @@ A virtual card is a secure, flexible way to manage online spending: 2. Click your Expensify Card. 3. Click **Reveal** to view the card number, expiration date, CVV, and billing address. +If any of your personal details are missing, you'll be prompted to add them before your card details can be revealed. Click **Add details**, then enter your legal name, date of birth, address, and phone number. After you confirm these details, authenticate to reveal your card. US cardholders confirm with a magic code sent to their email, while UK and EU cardholders confirm with biometrics or a passkey. + --- # Add the Card to Your Digital Wallet diff --git a/docs/articles/new-expensify/expensify-card/Expensify-Card-Perks.md b/docs/articles/new-expensify/expensify-card/Expensify-Card-Perks.md index 8017c5393755..d2b259d230fd 100644 --- a/docs/articles/new-expensify/expensify-card/Expensify-Card-Perks.md +++ b/docs/articles/new-expensify/expensify-card/Expensify-Card-Perks.md @@ -7,6 +7,8 @@ keywords: [New Expensify, card rewards, Expensify Card perks] From cash-back rewards to discounts on popular services, the Expensify Card offers benefits that make it more than just a corporate card. In this article, we’ll highlight the various perks available to cardholders, how to access them, and how they can benefit your business operations. Whether you're looking to cut costs, streamline workflows, or unlock valuable savings, the Expensify Card perks are tailored to meet your needs. +**Note:** The perks below apply to the U.S. Expensify Card. The UK/EU Expensify Card does not currently offer cash back. See [Set Up and Manage the Expensify Card in the UK and EU](/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-UK-EU) for details on the UK/EU card. + --- # Expensify Perks diff --git a/docs/articles/new-expensify/expensify-card/Expensify-Card-Spend-Rules.md b/docs/articles/new-expensify/expensify-card/Expensify-Card-Spend-Rules.md index e447a3842eb8..60393523a6ae 100644 --- a/docs/articles/new-expensify/expensify-card/Expensify-Card-Spend-Rules.md +++ b/docs/articles/new-expensify/expensify-card/Expensify-Card-Spend-Rules.md @@ -1,17 +1,17 @@ --- title: Expensify Card Spend Rules -description: Learn how Workspace Admins use Expensify Card Spend rules to approve or decline card transactions in real time, including Allow and Block restriction types, default protections, and rule management. -keywords: [New Expensify, Expensify Card Spend rule, block card transactions, allow list, decline transaction, merchant restrictions, spend category, permitted currencies, currency restrictions, default protections, Workspace Admin] -internalScope: Audience is Workspace Admins. Covers creating and managing Expensify Card Spend rules and understanding default protections. Does not cover post-submission expense rules, card limits, or cardholder-side card management. +description: Learn how Workspace Admins use Expensify Card Spend rules to approve or decline card transactions in real time based on currency, amount, and merchant. +keywords: [New Expensify, Expensify Card Spend rule, block card transactions, allow list, decline transaction, merchant restrictions, merchant types, permitted currencies, max amount, default protections, Workspace Admin] +internalScope: Audience is Workspace Admins. Covers creating and managing Expensify Card Spend rules, including permitted currencies, max amount, and merchant restrictions, and understanding default protections. Does not cover post-submission expense rules, card limits, or cardholder-side card management. --- # Expensify Card Spend Rules -Expensify Card Spend Rules let Workspace Admins approve or decline card transactions in real time before a transaction is authorized. Use Spend Rules to control where cards can be used by restricting merchants, spend categories, transaction amounts, and currencies. +Expensify Card Spend Rules let Workspace Admins approve or decline card transactions in real time before a transaction is authorized. Use Spend rules to control where cards can be used by restricting permitted currencies, transaction amounts, merchants, and merchant types. --- -## Who can use Expensify Card Spend Rules +## Who can use Expensify Card Spend rules - Workspace Admins can create, edit, and delete Expensify Card Spend rules. - Cardholders can view Expensify Card Spend rules that apply to their cards but cannot modify them. @@ -51,57 +51,53 @@ Cards without Spend rules are governed only by the default protections and any a --- -## What restriction types are available for Expensify Card Spend rules +## What you can configure in an Expensify Card Spend rule -When creating a Spend rule, choose one of two restriction types: +A Spend rule is made up of the settings below. You can use any combination of them. -**Allow** +**Permitted currencies** -Use Allow when a card should only be used for specific merchants or spend categories. +Use **Permitted currencies** to control which currencies a card can be charged in. The default is **All currencies**. You can instead select specific currencies to allow. -Transactions are approved when they: -- Match an allowed merchant or spend category -- Do not exceed the maximum amount +- The card's settlement currency is always permitted, even when you select specific currencies. +- Charges in a currency that is not permitted are declined. +- To set specific currencies, the selected cards must settle in the same currency. -Examples include: -- Travel-only cards -- Subscription cards -- Benefits cards -- Vendor-specific purchasing cards +**Max amount** -**Block** +Enter a maximum transaction amount. Any charge over this amount is declined, regardless of the merchant and merchant type restrictions. -Use Block when a card should work broadly but certain spending should be restricted. +To set a max amount, the selected cards must settle in the same currency. -Transactions are declined when they: -- Match a blocked merchant -- Match a blocked spend category -- Exceed the maximum amount +**Restrict merchants** -Examples include: -- Blocking specific merchants -- Restricting subscription services -- Preventing transactions above a defined amount +Use **Restrict merchants** to control which merchants and merchant types a card can be used at. Choose one of three options: + +- **Off:** No merchant restrictions apply. A charge is approved as long as it's in a permitted currency and doesn't exceed the max amount. +- **Allow:** Only charges at a merchant or merchant type you allow are approved (and only when they're in a permitted currency and don't exceed the max amount). Use this when a card should only be used at specific merchants or merchant types, such as travel-only, subscription, benefits, or vendor-specific cards. +- **Block:** Charges at a merchant or merchant type you block are declined. All other charges are approved, as long as they're in a permitted currency and don't exceed the max amount. Use this when a card should work broadly but certain merchants or merchant types should be restricted. + +When **Allow** is selected, you configure **Allowed merchants** and **Allowed merchant types**. When **Block** is selected, you configure **Blocked merchants** and **Blocked merchant types**. + +--- ## How to create Expensify Card Spend rules 1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [Workspace Name]**. 2. Click **Rules**. -3. Under **Spend**, click **Add Spend rule**. -4. Select one or more cards to apply the rule to. -5. Under **Restriction type**, select **Allow** or **Block**. -6. Configure one or more of the following: - - **Merchant:** Add merchants using **Contains** or **Exact match**. - - **Spend category:** Select one or more spend categories. +3. Under **Spend**, click **Add spend rule**. +4. Under **Cards**, click **Choose cards** and select one or more cards to apply the rule to. +5. Under **Spend rules**, configure one or more of the following: + - **Permitted currencies:** Keep **All currencies** or select specific currencies. - **Max amount:** Enter a maximum transaction amount. - - **Permitted currencies:** Allow all currencies or restrict spending to specific currencies. -7. Click **Save**. + - **Restrict merchants:** Select **Off**, **Allow**, or **Block**. When you select **Allow** or **Block**, set the merchants and merchant types to allow or block. +6. Click **Save rule**. -Spend rules takes effect immediately and are applied to future transactions on the selected cards. +Spend rules take effect immediately and are applied to future transactions on the selected cards. @@ -112,37 +108,37 @@ Purpose: Shows admins the rule configuration fields. After a rule is created, you can review, change, or remove it. 1. Go to **Workspaces > [Workspace Name] > Rules**. -2. Locate the card's Spend Rule. -3. Select the rule to update its merchants, spend categories, maximum amount, permitted currencies, or mode. -4. Click **Save**. +2. Locate the card's Spend rule. +3. Select the rule to update its permitted currencies, max amount, merchants, or merchant types or mode. +4. Click **Save rule**. To remove a rule: -1. Open the Spend Rule. -2. Click **Delete**. +1. Open the Spend rule. +2. Click **Delete rule**. After deletion, the card is governed only by default protections and any applicable card limits. --- -## How to create Expensify Card Spend rule while issuing a card +## How to create an Expensify Card Spend rule while issuing a card You can apply an Expensify Card Spend rule during card issuance so spending controls are active before the card is used. [Learn how to set up and manage Expensify Cards](/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card). 1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [Workspace Name] > Expensify Card** and begin issuing a new card. -2. On the **Set card rules** step, enable **Add a spend rule** +2. On the **Set card rules** step, enable **Add spend rule**. 3. Choose **Copy existing** to reuse an existing rule, or **Create new** to create a new one. -4. Configure the Spend rule and select the desired **Restriction type**. +4. Configure the Spend rule's permitted currencies, max amount, and merchant restrictions. 5. Complete the card issuance process. -The Spend Rules are applied when the card is issued and is enforced from the card's first transaction. +The Spend rule is applied when the card is issued and is enforced from the card's first transaction. --- -## How to find a card or Spend Rule using search +## How to find a card or Spend rule using search -Use search to quickly locate a card or Spend Rule. +Use search to quickly locate a card or Spend rule. 1. Go to **Workspaces > [Workspace Name] > Rules**. 2. Use the search field to filter the list by card or rule. @@ -157,11 +153,15 @@ No. Spend rules apply only to Expensify Card transactions. They do not apply to ## Can a card have more than one Spend rule? -No. Each card can have only one Spend Rule. A Spend Rule uses either the **Allow** or **Block** restriction type. +No. Each card can have only one Spend rule. + +## Can I set a max amount or specific currencies for cards that settle in different currencies? + +No. To set a max amount or select specific permitted currencies, the selected cards must settle in the same currency. The card's settlement currency is always permitted. -## Can a Spend Rule override the default protections? +## Can a Spend rule override the default protections? -No. Default protections (such as ATMs and gambling) are part of the Expensify Card program. They are always enforced and cannot be edited or overridden by a Spend Rule. +No. Default protections (such as ATMs and gambling) are part of the Expensify Card program. They are always enforced and cannot be edited or overridden by a Spend rule. ## Why is a card visible in more than one workspace showing the same Spend rule? diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-UK-EU.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-UK-EU.md new file mode 100644 index 000000000000..db9727279d4f --- /dev/null +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-UK-EU.md @@ -0,0 +1,138 @@ +--- +title: Set Up and Manage the Expensify Card in the UK and EU +description: Learn how to set up and use the Expensify Card in the UK and EU, including eligibility, supported countries, and step-by-step setup instructions. +keywords: [New Expensify, Expensify Card UK, EU, Global Reimbursements, GBP, EUR, card setup, issue cards, Smart Limit, virtual cards, physical cards, supported countries, Visa Secure] +internalScope: Audience is Workspace Admins in the UK and EU. Covers eligibility, supported countries, enabling the Expensify Card, linking a settlement account, issuing virtual and physical cards, spending limits, PIN management, foreign exchange details, and Visa Secure. Does not cover US Expensify Card setup or non-GBP/EUR settlement accounts. +--- + +# Set Up and Manage the Expensify Card in the UK and EU + +The Expensify Card is available for companies in the United Kingdom and select European countries. This guide walks you through eligibility and how to set up and start using the card in GBP or EUR. + +Workspace Admins and Card Admins can issue virtual and physical Expensify Cards to members and manage spending in GBP or EUR. + +## Who can set up the Expensify Card in the UK and EU? + +Workspace Admins can set up the Expensify Card for companies in the UK and EU, as long as they meet the following requirements: + +- Have a GBP or EUR business bank account connected in Expensify +- Are located in the UK or supported EU country +- Have [enabled Global Reimbursement](/articles/new-expensify/wallet-and-payments/Enable-Global-Reimbursement) + +If your company hasn't yet connected a GBP or EUR business bank account, follow one of these guides: + +- [Connect a GBP bank account](/articles/new-expensify/wallet-and-payments/Global-Reimbursement-United-Kingdom) +- [Connect a EUR bank account](/articles/new-expensify/wallet-and-payments/Global-Reimbursement-Europe) + +--- + +## Where the Expensify Card is supported + +The Expensify Card can be used anywhere Visa is accepted. + +However, only companies registered in the following countries can enable the Expensify Card for their workspace: + +- Belgium +- Denmark +- Finland +- Gibraltar +- Ireland +- Latvia +- Lithuania +- Luxembourg +- Netherlands +- Poland +- Spain +- Sweden +- United Kingdom +- United States (please refer to the [US onboarding process](/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card)) + +--- + +## How to enable the Expensify Card on your workspace + +Once you meet the requirements to set up the Expensify Card in the UK and EU, you can enable it on your GBP or EUR workspace. + +**Enable the Expensify Card on your workspace** + +Once you have completed the prerequisites, you can enable the Expensify Card on your GBP or EUR workspace. + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name] > More features**. +2. In the **Spend** section, enable **Expensify Card**. +3. Provide the last three months of business bank statements. These statements help determine your eligible spending limit. +4. Concierge will review your request and reach out to complete an onboarding form. +5. Once enabled, **Expensify Card** will appear in the workspace navigation menu. + +**Link a bank account to settle the Expensify Card** + +Link a GBP or EUR business bank account that will be used to pay the card balance either daily or monthly. + +> **Important:** This must be the same bank account shown in the statements provided during onboarding. + +1. From the navigation tabs (on the left on web, and at the bottom on mobile), select **Workspaces > [Workspace Name] > Expensify Card**. +2. Click **Issue new card**. +3. Select the GBP or EUR business bank account that will be used to settle all Expensify Card transactions. + +**Issue Expensify Cards to members** + +After onboarding is complete and a spending limit has been assigned to your account, you can begin issuing cards to members. + +1. From the navigation tabs (on the left on web, and at the bottom on mobile), select **Workspaces > [Workspace Name] > Expensify Card**. +2. Click **Issue new card**. +3. Select the member. +4. Choose **Virtual** or **Physical**. +5. Choose a limit type: + - **Smart limit** – Spend up to a threshold before approval is required. + - **Monthly limit** – Limit renews each month. + - **Fixed limit** – Spend until the limit is reached. + - **Single-use (virtual only)** – Expires after one transaction. +6. Enter the spending limit. +7. *(Optional for virtual cards)* Enable **Set expiration date** and define a **Start date** and **End date**. + - When enabled, both dates are required. The card activates at **12:00 AM local time** on the Start date and expires at **11:59 PM local time** on the End date. + - When disabled, the card does not expire automatically. +8. Name the card for easier tracking. +9. Click **Issue card** to confirm. + +**Activate the card (physical cards only)** + +Once the member receives their physical card: + +1. Go to **Settings > Account > Wallet**. +2. Follow the activation steps. +3. After activation, a 4-digit PIN will be displayed. + +You can manage your PIN from your card settings. See the FAQ below for details. + +# FAQ + +## What do I do if I forget my PIN? + +1. Navigate to **Account > Wallet**. +2. Select your card. +3. Click **Reveal PIN**. +4. Follow the prompts to confirm your identity. + +Once confirmed, your PIN will be displayed. + +## How do I change my PIN? + +1. Navigate to **Account > Wallet**. +2. Select your card. +3. Click **Change PIN**. +4. Follow the prompts to confirm your identity. + +Once confirmed, you can update your PIN. + +## What exchange rate is used for foreign currency purchases? + +Expensify uses the [Visa FX calculator](https://www.visa.co.uk/support/consumer/travel-support/exchange-rate-calculator.html) to determine exchange rates for purchases made in currencies other than GBP or EUR. + +> **Note:** Set the bank fee to **0%** when using the calculator. The Expensify Card does not charge foreign exchange fees. + +## Does the Expensify Card support Visa Secure? + +Yes. The Expensify Card protects online transactions with Visa Secure. For more information, review the [Visa Secure FAQs]({{site.url}}/assets/Files/Visa-secure-faq-expensify.pdf). + +## How do I get help? + +If you need help with eligibility, verification, spending limits, or issuing cards, message **Concierge** directly from the Expensify app. diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md index 255b3c65afd8..095c6cfafaff 100644 --- a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card.md @@ -7,6 +7,8 @@ internalScope: Applies to Workspace Admins and Card Admins. Covers Expensify Car Workspace Admins can enable and issue Expensify Visa® Commercial Cards to manage company spending with real-time controls and flexibility across employees and subscriptions. +**Note:** This guide covers the Expensify Card for companies with a U.S. business bank account. If your company is based in the UK or EU, see [Set Up and Manage the Expensify Card in the UK and EU](/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-UK-EU) instead. Cash back and other perks described below apply to the U.S. Expensify Card only. + **Card Admins** can also issue and manage Expensify Cards — including setting limits, freezing or unfreezing cards, and adjusting card settings — once the Expensify Card is enabled on the workspace. **The Expensify Card offers powerful spend control tools, including:** @@ -100,6 +102,19 @@ 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%"} +## 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. + +Each card shows one of the following statuses: + +- **Pending order** — A physical card has been issued but not yet ordered by the cardholder. +- **Shipped** — A physical card has shipped and is on its way to the cardholder. +- **Active** — The card is ready to use. Virtual cards are immediately **Active** once issued. +- **Inactive** — The card is frozen or otherwise not currently usable. + +**Note:** The **Status** column is hidden on narrow and medium screens. Widen your browser window or view the list on a larger screen to see it. + --- ## How to apply Expensify Card cash back to your Expensify bill diff --git a/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md b/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md index f00d89c54eaf..3bf0ecdd4980 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md +++ b/docs/articles/new-expensify/reports-and-expenses/Accounting-Search-Shortcuts.md @@ -17,7 +17,7 @@ Workspace Admins and Auditors can use these shortcuts to validate totals, track Workspace Admins and Auditors with: - Approvals turned on -- Either reimbursements enabled or at least one card feed connected +- Either reimbursements enabled, at least one card feed connected, or Expensify Cards enabled --- @@ -61,7 +61,7 @@ An aging report is a breakdown of expenses by how long they have been in a given Some search shortcuts only appear when the required features are enabled: - **Cash accruals** requires reimbursements - - **Card accruals** and **Card statements** require a card feed + - **Card accruals** and **Card statements** require a connected card feed or Expensify Cards - **Bank reconciliation** requires a validated business bank account. If a feature isn’t enabled, its shortcut won’t appear. diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-an-Expense.md b/docs/articles/new-expensify/reports-and-expenses/Create-an-Expense.md index c63fe53845c3..90d7c783ad16 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Create-an-Expense.md +++ b/docs/articles/new-expensify/reports-and-expenses/Create-an-Expense.md @@ -183,6 +183,10 @@ Expensify uses AI to detect suspicious or non-human receipts: This feature helps prevent policy violations and ensures accurate expense tracking from the moment of upload. +## What happens to receipts that are still uploading when I sign out on mobile? + +If you manually sign out of the Expensify mobile app while receipts are still uploading, Expensify prompts you to save the pending receipt images to your device's photos before signing out. + ## Why can't I forward receipts to receipts@expensify.com? If your company uses **Proofpoint Hosted Email Security**, you may be unable to forward receipts to receipts@expensify.com. This is caused by an issue on Proofpoint's side. Contact Proofpoint for resolution. In the meantime, you can upload receipts in the Expensify app or on the web, or text a receipt photo to 47777 (US numbers only) after [adding your phone number as a contact method](https://new.expensify.com/settings/profile/contact-methods). diff --git a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md index 7865ab211b71..15ab8fa62e4f 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md +++ b/docs/articles/new-expensify/reports-and-expenses/Create-and-Submit-Reports.md @@ -181,7 +181,7 @@ The **Submit** button only appears once your report includes at least one valid ## Why can’t I submit a report with pending Expensify Card transactions? -If all transactions on your report are pending Expensify Card transactions, you'll see the **Submit** button but clicking it will display an **Unable to submit report** error. Pending transactions may take a few days to post. After at least one transaction has posted, you can submit the report. +If all transactions on your report are pending Expensify Card transactions, the report can't be submitted yet. The **Submit** button won't appear, and the report isn't counted in your **Submit** to-do. Pending transactions may take a few days to post. After at least one transaction has posted, the **Submit** button appears again and you can submit the report. ## Can I remove an expense after submitting? diff --git a/docs/articles/new-expensify/reports-and-expenses/Edit-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Edit-Expenses.md index 38e829390c3f..11471497d848 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Edit-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Edit-Expenses.md @@ -27,7 +27,7 @@ Expenses on Approved reports must be unapproved before they can be edited. Expen 4. Make your update. 5. Click **Save**. -**Tip (desktop only):** You can also edit an expense directly from the table without opening it. Click a **date**, **merchant**, **description**, **category**, **tag**, or **amount** cell to edit the value inline. Click outside the cell or press Enter to save. +**Tip (desktop only):** You can also edit an expense directly from the table without opening it. Hover over a **date**, **merchant**, **description**, **category**, **tag**, or **amount** cell (or use the Tab key to move to it), then click the pencil edit icon that appears to edit the value inline. Click outside the cell or press Enter to save. Clicking anywhere else in the cell opens the expense instead. --- 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 a5e13b8ac6c6..e900bcb37f1d 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 @@ -50,7 +50,7 @@ Each row represents an expense and includes: ## Available Actions on the Table - **Click an expense** to view or edit it in the right-hand panel -- **Edit a cell directly (desktop only):** Click a **date**, **merchant**, **description**, **category**, **tag**, or **amount** cell to edit the value inline without opening the expense details. Click outside the cell or press Enter to save. +- **Edit a cell directly (desktop only):** Hover over a **date**, **merchant**, **description**, **category**, **tag**, or **amount** cell (or use the Tab key to move to it), then click the pencil edit icon that appears to edit the value inline without opening the expense details. Click outside the cell or press Enter to save. Clicking anywhere else in the cell opens the expense instead. - **Select multiple expenses** using checkboxes, then apply bulk actions such as: - **Move to another report:** When you need to have multiple reports or need to break up expenses across multiple weeks or months. - **Download:** For exporting to a CSV file for analysis or to share with your accountant. diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md index 59dac769c49a..5a22d9570ebf 100644 --- a/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Find-and-Resolve-Flagged-Duplicate-Expenses.md @@ -1,13 +1,13 @@ --- title: How to Find and Resolve Flagged Duplicate Expenses description: Learn how Expensify detects duplicate expenses and how to resolve them when they are flagged and placed on hold. -keywords: [New Expensify, duplicate expense, review duplicates, expense on hold, resolve duplicate, flagged expense, Fix badge, duplicate detection, keep all, keep this one, held expense, Inbox Fix badge, find duplicate expenses, where to find duplicates] +keywords: [New Expensify, duplicate expense, review duplicates, expense on hold, resolve duplicate, flagged expense, Fix badge, duplicate detection, keep all, keep this one, held expense, Inbox Fix badge, find duplicate expenses, where to find duplicates, automatic duplicate review, Concierge resolved the duplicate, duplicate warning removed] internalScope: Audience is members and approvers on Collect or Control plans. Covers how to locate reports with duplicate expense violations and resolve flagged duplicates. Does not cover manually merging expenses or preventing duplicates. --- # How to find and resolve flagged duplicate expenses -Expensify automatically detects potential duplicate expenses by flagging entries that share the same date and amount. When a duplicate is detected, the expense is placed on hold and marked with a **Fix** badge. +Expensify automatically detects potential duplicate expenses by comparing key details across your expenses, such as the amount, currency, and date. When a potential duplicate is detected, the expense is placed on hold and marked with a **Fix** badge. You can locate and resolve these duplicates directly from the report or chat where the expense lives. @@ -23,6 +23,30 @@ Duplicate detection is available to all members on Collect and Control plans. Bo --- +## How Expensify identifies potential duplicate expenses + +Expensify compares the amount, currency, and date of your expenses to identify potential duplicates. When an expense has a scanned receipt, Expensify also compares additional details when they are available, such as the merchant, the order or invoice number, the last 4 digits of the card used, and the merchant's zip code. + +Using these additional details helps reduce false positives, so two separate purchases made at the same merchant on the same day are less likely to be flagged incorrectly. + +Hotel and invoice expenses are matched using a date range instead of a single date: + +- For hotels, Expensify can match an expense using the reservation check-in date, the check-out date, or the transaction date, since hotels are often paid at check-in or check-out. +- For invoices, Expensify can match an expense up to the invoice due date, since invoices can be paid any time before they are due. + +--- + +## How automatic duplicate review works + +After an expense is flagged as a **Potential duplicate**, Expensify may automatically review it in the background to confirm whether the expenses are the same purchase. + +- If Expensify determines the expenses are clearly not duplicates, the violation is automatically dismissed and you'll see a note that Concierge resolved the duplicate, along with an **Explain** link describing why. +- If Expensify is not confident the expenses are different, the **Potential duplicate** flag remains so you can resolve it manually. + +You can always resolve duplicates manually, whether or not they were reviewed automatically. + +--- + ## How to find duplicate expenses using the Fix badge 1. In the navigation tabs (on the left on web, at the bottom on mobile), select **Inbox**. @@ -75,8 +99,12 @@ Yes, you can edit a duplicate expense as long as it is in the Unreported, Draft ## Will two SmartScanned receipts from the same day with the same amount be flagged? -Yes, unless: +Often, but not always. Expensify also compares additional receipt details, such as the merchant, order number, card last 4, and zip code, so two separate purchases with different details may not be flagged. Expenses are also not flagged when: - The expenses were split from a single expense. - They were imported from a credit card. - They came from matching email receipts with different timestamps. +## Why was a duplicate warning removed automatically? + +Expensify may automatically review flagged duplicates in the background. If it determines the expenses are clearly not the same purchase, it dismisses the violation and adds a note that Concierge resolved the duplicate, with an **Explain** link describing why. If Expensify is not confident, the flag stays so you can resolve it manually. + diff --git a/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md b/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md index 1c9106182445..152ec911420d 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md +++ b/docs/articles/new-expensify/reports-and-expenses/Managing-Expenses-in-a-Report.md @@ -93,16 +93,38 @@ After it’s unapproved: ## How to view and use the expense table -Each report includes a table showing all attached expenses. +Each report includes an expense table showing the following default columns: -Each report includes an expense table showing: + - Receipt - Date - Merchant - - Category + - Reimbursable + - Card + - Description + - Total - Amount - - Workspace violations (if applicable) - -Additional columns such as **Attendees** and **Per attendee** can be enabled via the **Columns** picker when attendee tracking is available. + - Category + - Workspace violations, if applicable + +Additional columns can be enabled from the **Columns** picker: + + - Attendees + - Billable + - Category GL code + - Custom field 1 + - Custom field 2 + - Exchange rate + - International reimbursement IDs + - MCC + - Per attendee + - Posted + - Purchase amount + - Tag + - Tag GL code + - Tax + - Tax code + - Tax rate + - Withdrawal ID Clicking a row opens the full expense details in a side panel (web) or details screen (mobile). @@ -119,11 +141,12 @@ Comments update live for everyone with access to the report. On desktop, you can edit certain expense fields directly in the table without opening the expense details: -1. Click a **date**, **merchant**, **description**, **category**, or **amount** cell in the expense table. -2. Edit the value using the inline editor that appears (a text input, date picker, or category picker depending on the field). -3. Click outside the cell or press Enter to save your changes. +1. Hover over a **date**, **merchant**, **description**, **category**, or **amount** cell in the expense table, or use the Tab key to move to it. +2. Click the pencil edit icon that appears in the cell. +3. Edit the value using the inline editor that appears (a text input, date picker, or category picker depending on the field). +4. Click outside the cell or press Enter to save your changes. -**Note:** Inline editing is only available on desktop (wide layout). On mobile, tap the expense row to open the full details screen. +**Note:** Clicking anywhere else in the cell or row (outside the pencil edit icon) opens the expense instead of editing inline. Inline editing is only available on desktop (wide layout). On mobile, tap the expense row to open the full details screen. --- 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 f90d4c12c4d4..1b5d8d9e0fbc 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 @@ -95,6 +95,7 @@ You can use the following operators to filter reports: - `total:` – total amount with relative comparisons - `withdrawn:` – ACH withdrawal date - `withdrawal-type:` – reimbursement, expensify-card, or central-travel-invoicing +- `paid-status:` – how the report was paid: `markedAsPaid`, `withdrawing`, or `confirmed`. Combine multiple values with commas, e.g. `paid-status:markedAsPaid,confirmed` - `action:` – blocking report action, e.g. `action:approve` **Example query:** diff --git a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md index a25000ef7d5a..cf5c64e26a5c 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md +++ b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md @@ -1,7 +1,7 @@ --- title: Using Spend in New Expensify description: Learn how to use Spend in New Expensify to search, filter, customize columns, and save searches for expenses, invoices, trips, and chats. -keywords: [Spend, New Expensify, report filters, search commands, custom columns, saved searches, share saved search, group expenses, invoices, expenses, chats, trips, reimbursement tracking, view expenses, customize report view, reporting table columns] +keywords: [Spend, New Expensify, report filters, search commands, custom columns, saved searches, share saved search, group expenses, invoices, expenses, chats, trips, reimbursement tracking, view expenses, customize report view, reporting table columns, Tag GL code, Category GL code] ---
@@ -67,12 +67,14 @@ You can choose from a wide range of columns, including: - **Avatar** – The profile image of the report submitter - **Date** – When the report or expense was created - **Submitted** – The date the report was submitted for approval +- **Paid status** – How a paid report was settled: **Marked as paid**, **Withdrawing**, or **Confirmed** - **Total** – The total amount of the report or expense - **Workspace** – The workspace the report belongs to - **Action** – Shows available actions like approve or reject - **MCC** – The Merchant Category Code from the transaction - **Tax code** – The tax code applied to the expense - **Category GL code** – The general ledger (GL) code from the expense's category +- **Tag GL code** – The general ledger (GL) code from the expense's tag - **Custom field 1** – The Custom field 1 value set for the report submitter - **Custom field 2** – The Custom field 2 value set for the report submitter - **International reimbursement IDs** – The reference IDs for international reimbursements on the report diff --git a/docs/articles/new-expensify/settings/Account-Settings.md b/docs/articles/new-expensify/settings/Account-Settings.md index 6c372c882a44..09e8fb8d3002 100644 --- a/docs/articles/new-expensify/settings/Account-Settings.md +++ b/docs/articles/new-expensify/settings/Account-Settings.md @@ -13,7 +13,7 @@ Expensify allows you to personalize your experience by customizing your profile, You can update the following profile settings: - **Profile Photo** – Upload or change your photo. -- **Customized Avatar** – Select an avatar or letter instead of a profile photo. +- **Customized Avatar** – Select an avatar instead of a profile photo. - **Status** – Add a custom message and emoji to show your current status. - **Pronouns** – Display your preferred pronouns on your profile. - **Language** – Update your account to your preferred language. @@ -53,7 +53,7 @@ To choose an avatar instead of a photo: 1. In the navigation tabs, click **Account > Profile**. 2. Click the pencil icon next to your profile image. 3. Scroll down to **Or choose a custom avatar**. -4. Select an avatar or letter in your desired colored palette. +4. Select an avatar in your desired colored palette. 5. Click **Save**. ## Timezone @@ -114,7 +114,7 @@ High contrast mode increases the visual contrast of UI elements, making text and 2. Click **Theme**. 3. Toggle **High contrast mode** on. -You can also enable high contrast mode from from the [login page](https://new.expensify.com/Home) by selecting **Enable high contrast**. +You can also enable high contrast mode from the [login page](https://new.expensify.com/) by selecting **Enable high contrast**. ## How to update personal information diff --git a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md index 5793fdd9b054..04e6cae1ede5 100644 --- a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md +++ b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md @@ -41,21 +41,22 @@ To invite someone to your workspace: You can filter the member list by role to quickly find specific groups of members. 1. Go to **Workspaces > [Workspace Name] > Members**. -2. Click the role filter dropdown at the top of the member list. -3. Select one of the available roles: - - **All members** – Shows all workspace members (default). - - **Approvers** – Shows only members who are designated approvers. - - **Workspace admins** – Shows only members with the Workspace admin role (not available on Submit workspaces). - - **Card admins** – Shows only members with the Card admin role (Control workspaces only). +2. Click **Filters**. +3. Select one or more of the available roles: + - **Workspace Admins** – Members with the Admin role. + - **Approvers** – Members who are designated approvers. + - **Card Admins** – Members with the Card Admin role (Control workspaces only). + - **People Admins** – Members with the People Admin role (Control workspaces only). - **Payments admins** – Shows only members with the Payments admin role (Control workspaces only). - - **Auditors** – Shows only members with the Auditor role (Control workspaces only). - - **Editors** – Shows only members with the Editor role (Submit workspaces only). + - **Auditors** – Members with the Auditor role (Control workspaces only). + - **Editors** – Members with the Editor role (Submit workspaces only). + - **Members** – Members with the Member role. -The member list updates immediately to show only members matching the selected role. You can also combine the role filter with the search bar to narrow results further. +Each selected role appears in the filter bar, and the member list updates immediately to show only members matching the selected roles. To remove a filter, click the **X**. You can also combine role filters with the search bar to narrow results further. -The roles available in the filter depend on your workspace type, so the options change if you switch your workspace plan. If you’ve filtered by a role that is no longer available after a plan change, the filter automatically resets to **All members**. +The roles available depend on your workspace type, so the options change if you switch your workspace plan. -If no members match the selected filter, an empty state is displayed with the message: "No members match this filter." +If no members match the selected filters, an empty state is displayed with the message: "No members match this filter." --- diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index aaf594f498a8..a4910aeeb21b 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -42,6 +42,8 @@ When a rate has a **Start date** or **End date**, Expensify uses it to apply the 4. Update any of the available settings: - Name - Rate + - **Start date** + - **End date** - Tax rate - Tax reclaimable amount 5. Click **Save**. @@ -101,7 +103,7 @@ Yes. When **Distance Rates** is enabled, the Workspace must always have at least 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 dDistance expense, Expensify selects the rate whose **Start date** and **End date** range includes the expense date. +- 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. diff --git a/docs/new-expensify/hubs/connections/certinia/Troubleshooting/Authentication-and-Login-errors.html b/docs/new-expensify/hubs/connections/certinia.html similarity index 100% rename from docs/new-expensify/hubs/connections/certinia/Troubleshooting/Authentication-and-Login-errors.html rename to docs/new-expensify/hubs/connections/certinia.html diff --git a/docs/new-expensify/hubs/connections/certinia/Troubleshooting/Export-Errors.html b/docs/new-expensify/hubs/connections/certinia/Troubleshooting/Export-Errors.html deleted file mode 100644 index 86641ee60b7d..000000000000 --- a/docs/new-expensify/hubs/connections/certinia/Troubleshooting/Export-Errors.html +++ /dev/null @@ -1,5 +0,0 @@ ---- -layout: default ---- - -{% include section.html %} diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index e4c65afb5c6e..3749503da22e 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.4.34 + 9.4.36 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.4.34.8 + 9.4.36.4 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index d62551bbdccb..5deb106d5a92 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.34 + 9.4.36 CFBundleVersion - 9.4.34.8 + 9.4.36.4 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 58f1e7dcf7eb..f85ea6930f3d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3693,7 +3693,7 @@ PODS: - React-Core - RNFS (2.20.0): - React-Core - - RNGestureHandler (2.28.0): + - RNGestureHandler (2.32.0): - boost - DoubleConversion - fast_float @@ -4837,7 +4837,7 @@ SPEC CHECKSUMS: GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa - hermes-engine: 1a37d030ed54575ddb733c6484f4b70fbcda5d87 + hermes-engine: a042a38ee8fd790f69526e722127b3d187cfbc52 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -4953,7 +4953,7 @@ SPEC CHECKSUMS: RNFBAnalytics: 2f451df50833a890974d55a93557da878a85b29e RNFBApp: db9c2e6d36fe579ab19b82c0a4a417ff7569db7e RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 - RNGestureHandler: 5d0f1950e26f5f71085eaacf1598ea38e9549ac0 + RNGestureHandler: e580deecf0c372b61e073437ac37f62511265777 RNGoogleSignin: 89877c73f0fbf6af2038fbdb7b73b5a25b8330cc RNLiveMarkdown: a368618d21adf1269daf840645f0fc1b6b141003 RNLocalize: 05e367a873223683f0e268d0af9a8a8e6aed3b26 diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 108f4a253604..98c08b74ade9 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.4.34 + 9.4.36 CFBundleVersion - 9.4.34.8 + 9.4.36.4 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 6d83ed94e2e3..733eabf97766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.4.34-8", + "version": "9.4.36-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.4.34-8", + "version": "9.4.36-4", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -112,7 +112,7 @@ "react-native-device-info": "10.3.1", "react-native-draggable-flatlist": "^4.0.3", "react-native-fs": "^2.20.0", - "react-native-gesture-handler": "2.28.0", + "react-native-gesture-handler": "2.32.0", "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^7.1.2", @@ -122,7 +122,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.35.0", "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.86", + "react-native-onyx": "3.0.88", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", @@ -18304,7 +18304,6 @@ }, "node_modules/@types/react-test-renderer": { "version": "19.1.0", - "dev": true, "license": "MIT", "dependencies": { "@types/react": "*" @@ -35692,12 +35691,13 @@ } }, "node_modules/react-native-gesture-handler": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", - "integrity": "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz", + "integrity": "sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==", "license": "MIT", "dependencies": { "@egjs/hammerjs": "^2.0.17", + "@types/react-test-renderer": "^19.1.0", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, @@ -36004,9 +36004,9 @@ } }, "node_modules/react-native-onyx": { - "version": "3.0.86", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.86.tgz", - "integrity": "sha512-P10auM8ZZHZrZmu/reg0EiuNq4AhZN3hmDoV5+QdEUsm2j5t2XCKWmU73ogFpL6fLCUQaxvzMmLEL84+nIhb2Q==", + "version": "3.0.88", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.88.tgz", + "integrity": "sha512-wCNHe+Kc6DX3yYVZxrITZdunu74P2x0XE7t8FGkpjEQdCRnlJhCTxn4NvxWZkURFxn5IRvIlVCGU8OdZhFNoOg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", diff --git a/package.json b/package.json index c38548709563..e288cf3d4529 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.4.34-8", + "version": "9.4.36-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.", @@ -186,7 +186,7 @@ "react-native-device-info": "10.3.1", "react-native-draggable-flatlist": "^4.0.3", "react-native-fs": "^2.20.0", - "react-native-gesture-handler": "2.28.0", + "react-native-gesture-handler": "2.32.0", "react-native-google-places-autocomplete": "2.5.6", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^7.1.2", @@ -196,7 +196,7 @@ "react-native-localize": "^3.5.4", "react-native-nitro-modules": "0.35.0", "react-native-nitro-sqlite": "9.6.0", - "react-native-onyx": "3.0.86", + "react-native-onyx": "3.0.88", "react-native-pager-view": "8.0.0", "react-native-pdf": "7.0.2", "react-native-permissions": "^5.4.0", diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 8216ddc44aac..d696b7db8a05 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -291,3 +291,45 @@ - Upstream PR/issue: https://github.com/facebook/react-native/pull/56680 / https://github.com/facebook/react-native/commit/aadbe965792bd900ca70412d6704b76e339d1aca - E/App issue: https://github.com/Expensify/App/issues/92412 - PR introducing patch: 🛑 + +### [react-native+0.85.3+038+nested-text-border-width.patch](react-native+0.85.3+038+nested-text-border-width.patch) + +- Reason: + + ``` + Adds borderWidth / per-side border width and per-side border color support for nested + backgrounds on iOS and Android. On the C++ side, borderColor, borderTopColor, borderRightColor, + borderBottomColor, borderLeftColor, borderWidth, borderTopWidth, borderRightWidth, + borderBottomWidth, and borderLeftWidth fields are added to TextAttributes and wired through + BaseTextProps and conversions. borderWidth acts as a fallback for unset individual widths; + borderColor acts as a fallback for unset individual colors. On Android, + ReactBackgroundDrawSpan is extended to draw per-side stroked borders (with corner arcs matching + the border radius) using separate Paint strokes for each side. On iOS, + RCTTextLayoutManagerWithBorderRadius draws per-side stroked borders using CGContext stroke + paths, with corner arcs on first/last lines matching the fill's rounded-rect shape. + ``` + +- Upstream PR/issue: 🛑 +- E/App issue: https://github.com/Expensify/App/issues/57556 +- PR introducing patch: https://github.com/Expensify/App/pull/94332 + +### [react-native+0.85.3+039+nested-text-padding.patch](react-native+0.85.3+039+nested-text-padding.patch) + +- Reason: + + ``` + Adds horizontal padding (paddingLeft / paddingRight / paddingHorizontal) support for nested + spans that have a border, so the border does not touch the text. On the C++ side, + paddingLeft and paddingRight fields are added to TextAttributes, resolved from paddingLeft / + paddingRight / paddingHorizontal props in BaseTextProps; TextInput explicitly clears them to + avoid leaking view-level padding into text fragments. On Android, zero-width spacer spans + (ReactInlinePaddingSpan) are inserted before/after bordered fragments to reserve horizontal + advance, and ReactBackgroundDrawSpan extends its fill/border box by the padding amount. On + iOS, NSKern attributes and firstLineHeadIndent reserve advance around bordered spans, an + NSLayoutManager delegate indents soft-wrapped lines that begin with a bordered span, and + RCTTextLayoutManager inflates the measured size to account for the reserved space. + ``` + +- Upstream PR/issue: 🛑 +- E/App issue: https://github.com/Expensify/App/issues/57556 +- PR introducing patch: https://github.com/Expensify/App/pull/94332 diff --git a/patches/react-native/react-native+0.85.3+038+nested-text-border-width.patch b/patches/react-native/react-native+0.85.3+038+nested-text-border-width.patch new file mode 100644 index 000000000000..1dc989f99bdf --- /dev/null +++ b/patches/react-native/react-native+0.85.3+038+nested-text-border-width.patch @@ -0,0 +1,1079 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt +index 84066528abb..86dd1fc7386 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt +@@ -79,6 +79,54 @@ public class TextAttributeProps private constructor() { + public var borderBottomRightRadius: Float = Float.NaN + private set + ++ public var isBorderTopColorSet: Boolean = false ++ private set ++ ++ public var borderTopColor: Int? = null ++ private set(color) { ++ isBorderTopColorSet = (color != null) ++ if (color != null) { field = color } ++ } ++ ++ public var isBorderRightColorSet: Boolean = false ++ private set ++ ++ public var borderRightColor: Int? = null ++ private set(color) { ++ isBorderRightColorSet = (color != null) ++ if (color != null) { field = color } ++ } ++ ++ public var isBorderBottomColorSet: Boolean = false ++ private set ++ ++ public var borderBottomColor: Int? = null ++ private set(color) { ++ isBorderBottomColorSet = (color != null) ++ if (color != null) { field = color } ++ } ++ ++ public var isBorderLeftColorSet: Boolean = false ++ private set ++ ++ public var borderLeftColor: Int? = null ++ private set(color) { ++ isBorderLeftColorSet = (color != null) ++ if (color != null) { field = color } ++ } ++ ++ public var borderTopWidth: Float = Float.NaN ++ private set ++ ++ public var borderRightWidth: Float = Float.NaN ++ private set ++ ++ public var borderBottomWidth: Float = Float.NaN ++ private set ++ ++ public var borderLeftWidth: Float = Float.NaN ++ private set ++ + public var opacity: Float = Float.NaN + private set + +@@ -391,6 +439,14 @@ public class TextAttributeProps private constructor() { + public const val TA_KEY_BORDER_TOP_RIGHT_RADIUS: Int = 31 + public const val TA_KEY_BORDER_BOTTOM_LEFT_RADIUS: Int = 32 + public const val TA_KEY_BORDER_BOTTOM_RIGHT_RADIUS: Int = 33 ++ public const val TA_KEY_BORDER_TOP_COLOR: Int = 34 ++ public const val TA_KEY_BORDER_RIGHT_COLOR: Int = 35 ++ public const val TA_KEY_BORDER_BOTTOM_COLOR: Int = 36 ++ public const val TA_KEY_BORDER_LEFT_COLOR: Int = 37 ++ public const val TA_KEY_BORDER_TOP_WIDTH: Int = 38 ++ public const val TA_KEY_BORDER_RIGHT_WIDTH: Int = 39 ++ public const val TA_KEY_BORDER_BOTTOM_WIDTH: Int = 40 ++ public const val TA_KEY_BORDER_LEFT_WIDTH: Int = 41 + + public const val UNSET: Int = -1 + +@@ -453,6 +509,18 @@ public class TextAttributeProps private constructor() { + result.borderBottomLeftRadius = entry.doubleValue.toFloat() + TA_KEY_BORDER_BOTTOM_RIGHT_RADIUS -> + result.borderBottomRightRadius = entry.doubleValue.toFloat() ++ TA_KEY_BORDER_TOP_COLOR -> result.borderTopColor = entry.intValue ++ TA_KEY_BORDER_RIGHT_COLOR -> result.borderRightColor = entry.intValue ++ TA_KEY_BORDER_BOTTOM_COLOR -> result.borderBottomColor = entry.intValue ++ TA_KEY_BORDER_LEFT_COLOR -> result.borderLeftColor = entry.intValue ++ TA_KEY_BORDER_TOP_WIDTH -> ++ result.borderTopWidth = entry.doubleValue.toFloat() ++ TA_KEY_BORDER_RIGHT_WIDTH -> ++ result.borderRightWidth = entry.doubleValue.toFloat() ++ TA_KEY_BORDER_BOTTOM_WIDTH -> ++ result.borderBottomWidth = entry.doubleValue.toFloat() ++ TA_KEY_BORDER_LEFT_WIDTH -> ++ result.borderLeftWidth = entry.doubleValue.toFloat() + } + } + +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +index ec6b8de6350..e3f19473554 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +@@ -268,27 +268,44 @@ internal object TextLayoutManager { + ?.let { SetSpanOperation(start, end, it) } + ?.let { ops.add(it) } + } +- if (textAttributes.isBackgroundColorSet) { +- textAttributes.backgroundColor +- ?.let { +- val hasBorderRadius = !textAttributes.borderTopLeftRadius.isNaN() || +- !textAttributes.borderTopRightRadius.isNaN() || +- !textAttributes.borderBottomLeftRadius.isNaN() || +- !textAttributes.borderBottomRightRadius.isNaN() +- if (hasBorderRadius) { +- ReactBackgroundDrawSpan( +- it, +- PixelUtil.toPixelFromDIP(textAttributes.borderTopLeftRadius.takeIf { r -> !r.isNaN() } ?: 0f), +- PixelUtil.toPixelFromDIP(textAttributes.borderTopRightRadius.takeIf { r -> !r.isNaN() } ?: 0f), +- PixelUtil.toPixelFromDIP(textAttributes.borderBottomLeftRadius.takeIf { r -> !r.isNaN() } ?: 0f), +- PixelUtil.toPixelFromDIP(textAttributes.borderBottomRightRadius.takeIf { r -> !r.isNaN() } ?: 0f), +- ) +- } else { +- ReactBackgroundColorSpan(it) +- } +- } +- ?.let { SetSpanOperation(start, end, it) } +- ?.let { ops.add(it) } ++ if (textAttributes.isBackgroundColorSet || ++ textAttributes.isBorderTopColorSet || textAttributes.isBorderRightColorSet || ++ textAttributes.isBorderBottomColorSet || textAttributes.isBorderLeftColorSet) { ++ val hasBorderRadius = !textAttributes.borderTopLeftRadius.isNaN() || ++ !textAttributes.borderTopRightRadius.isNaN() || ++ !textAttributes.borderBottomLeftRadius.isNaN() || ++ !textAttributes.borderBottomRightRadius.isNaN() ++ val hasBorderWidth = !textAttributes.borderTopWidth.isNaN() || ++ !textAttributes.borderRightWidth.isNaN() || ++ !textAttributes.borderBottomWidth.isNaN() || ++ !textAttributes.borderLeftWidth.isNaN() ++ val hasBorderColor = textAttributes.isBorderTopColorSet || ++ textAttributes.isBorderRightColorSet || ++ textAttributes.isBorderBottomColorSet || ++ textAttributes.isBorderLeftColorSet ++ val bgColor = textAttributes.backgroundColor ?: android.graphics.Color.TRANSPARENT ++ val span = if (hasBorderRadius || hasBorderWidth || hasBorderColor) { ++ ReactBackgroundDrawSpan( ++ bgColor, ++ PixelUtil.toPixelFromDIP(textAttributes.borderTopLeftRadius.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(textAttributes.borderTopRightRadius.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(textAttributes.borderBottomLeftRadius.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(textAttributes.borderBottomRightRadius.takeIf { r -> !r.isNaN() } ?: 0f), ++ if (textAttributes.isBorderTopColorSet) textAttributes.borderTopColor else null, ++ if (textAttributes.isBorderRightColorSet) textAttributes.borderRightColor else null, ++ if (textAttributes.isBorderBottomColorSet) textAttributes.borderBottomColor else null, ++ if (textAttributes.isBorderLeftColorSet) textAttributes.borderLeftColor else null, ++ PixelUtil.toPixelFromDIP(textAttributes.borderTopWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(textAttributes.borderRightWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(textAttributes.borderBottomWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(textAttributes.borderLeftWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ ) ++ } else if (textAttributes.isBackgroundColorSet) { ++ textAttributes.backgroundColor?.let { ReactBackgroundColorSpan(it) } ++ } else { ++ null ++ } ++ span?.let { ops.add(SetSpanOperation(start, end, it)) } + } + if (!textAttributes.opacity.isNaN()) { + ops.add(SetSpanOperation(start, end, ReactOpacitySpan(textAttributes.opacity))) +@@ -454,25 +471,43 @@ internal object TextLayoutManager { + ) + } + +- if (fragment.props.isBackgroundColorSet) { +- val bgSpan = +- fragment.props.backgroundColor?.let { +- val hasBorderRadius = !fragment.props.borderTopLeftRadius.isNaN() || +- !fragment.props.borderTopRightRadius.isNaN() || +- !fragment.props.borderBottomLeftRadius.isNaN() || +- !fragment.props.borderBottomRightRadius.isNaN() +- if (hasBorderRadius) { +- ReactBackgroundDrawSpan( +- it, +- PixelUtil.toPixelFromDIP(fragment.props.borderTopLeftRadius.takeIf { r -> !r.isNaN() } ?: 0f), +- PixelUtil.toPixelFromDIP(fragment.props.borderTopRightRadius.takeIf { r -> !r.isNaN() } ?: 0f), +- PixelUtil.toPixelFromDIP(fragment.props.borderBottomLeftRadius.takeIf { r -> !r.isNaN() } ?: 0f), +- PixelUtil.toPixelFromDIP(fragment.props.borderBottomRightRadius.takeIf { r -> !r.isNaN() } ?: 0f), +- ) +- } else { +- ReactBackgroundColorSpan(it) +- } +- } ++ if (fragment.props.isBackgroundColorSet || ++ fragment.props.isBorderTopColorSet || fragment.props.isBorderRightColorSet || ++ fragment.props.isBorderBottomColorSet || fragment.props.isBorderLeftColorSet) { ++ val hasBorderRadius = !fragment.props.borderTopLeftRadius.isNaN() || ++ !fragment.props.borderTopRightRadius.isNaN() || ++ !fragment.props.borderBottomLeftRadius.isNaN() || ++ !fragment.props.borderBottomRightRadius.isNaN() ++ val hasBorderWidth = !fragment.props.borderTopWidth.isNaN() || ++ !fragment.props.borderRightWidth.isNaN() || ++ !fragment.props.borderBottomWidth.isNaN() || ++ !fragment.props.borderLeftWidth.isNaN() ++ val hasBorderColor = fragment.props.isBorderTopColorSet || ++ fragment.props.isBorderRightColorSet || ++ fragment.props.isBorderBottomColorSet || ++ fragment.props.isBorderLeftColorSet ++ val bgColor = fragment.props.backgroundColor ?: android.graphics.Color.TRANSPARENT ++ val bgSpan = if (hasBorderRadius || hasBorderWidth || hasBorderColor) { ++ ReactBackgroundDrawSpan( ++ bgColor, ++ PixelUtil.toPixelFromDIP(fragment.props.borderTopLeftRadius.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(fragment.props.borderTopRightRadius.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(fragment.props.borderBottomLeftRadius.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(fragment.props.borderBottomRightRadius.takeIf { r -> !r.isNaN() } ?: 0f), ++ if (fragment.props.isBorderTopColorSet) fragment.props.borderTopColor else null, ++ if (fragment.props.isBorderRightColorSet) fragment.props.borderRightColor else null, ++ if (fragment.props.isBorderBottomColorSet) fragment.props.borderBottomColor else null, ++ if (fragment.props.isBorderLeftColorSet) fragment.props.borderLeftColor else null, ++ PixelUtil.toPixelFromDIP(fragment.props.borderTopWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(fragment.props.borderRightWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(fragment.props.borderBottomWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(fragment.props.borderLeftWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ ) ++ } else if (fragment.props.isBackgroundColorSet) { ++ fragment.props.backgroundColor?.let { ReactBackgroundColorSpan(it) } ++ } else { ++ null ++ } + spannable.setSpan(bgSpan, start, end, spanFlags) + } + +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactBackgroundDrawSpan.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactBackgroundDrawSpan.kt +index 326b049ce2e..80f8d389475 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactBackgroundDrawSpan.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactBackgroundDrawSpan.kt +@@ -14,11 +14,15 @@ import android.text.Layout + import androidx.annotation.ColorInt + + /** +- * A [DrawCommandSpan] that draws a rounded-rectangle background behind inline text spans. When +- * borderRadius is 0, this draws a plain rectangle identical to [ReactBackgroundColorSpan]. ++ * A [DrawCommandSpan] that draws a rounded-rectangle background and optional per-side border ++ * behind inline text spans. + * + * For multiline spans, only the outer corners are rounded: left corners on the first line, right +- * corners on the last line. ++ * corners on the last line. Border sides follow the same rule: the left border edge is drawn only ++ * on the first line and the right border edge only on the last line; top and bottom edges are drawn ++ * on every line. ++ * ++ * Each border side has its own color (null = don't draw that side). + */ + internal class ReactBackgroundDrawSpan( + @ColorInt private val backgroundColor: Int, +@@ -26,17 +30,34 @@ internal class ReactBackgroundDrawSpan( + private val borderTopRightRadius: Float, + private val borderBottomLeftRadius: Float, + private val borderBottomRightRadius: Float, ++ @ColorInt private val borderTopColor: Int?, ++ @ColorInt private val borderRightColor: Int?, ++ @ColorInt private val borderBottomColor: Int?, ++ @ColorInt private val borderLeftColor: Int?, ++ private val borderTopWidth: Float, ++ private val borderRightWidth: Float, ++ private val borderBottomWidth: Float, ++ private val borderLeftWidth: Float, + ) : DrawCommandSpan() { + +- private val paint = ++ private val fillPaint = + Paint().apply { + color = backgroundColor + isAntiAlias = true + style = Paint.Style.FILL + } + ++ private val borderPaint = ++ Paint().apply { ++ isAntiAlias = true ++ style = Paint.Style.STROKE ++ strokeCap = Paint.Cap.BUTT ++ strokeJoin = Paint.Join.MITER ++ } ++ + private val rectF = RectF() +- private val path = Path() ++ private val fillPath = Path() ++ private val borderPath = Path() + + override fun onPreDraw(start: Int, end: Int, canvas: Canvas, layout: Layout) { + if (start >= end) return +@@ -52,7 +73,7 @@ internal class ReactBackgroundDrawSpan( + // Otherwise use the actual character position of the span start. + val left = + if (start <= lineStart) { +- layout.getLineLeft(line) ++ layout.getPrimaryHorizontal(lineStart) + } else { + layout.getPrimaryHorizontal(start) + } +@@ -73,45 +94,119 @@ internal class ReactBackgroundDrawSpan( + val top = baseline + fm.ascent + val bottom = baseline + fm.descent + +- val actualLeft = minOf(left, right) +- val actualRight = maxOf(left, right) ++ rectF.set(minOf(left, right), top, maxOf(left, right), bottom) + +- rectF.set(actualLeft, top, actualRight, bottom) ++ val isFirstLine = line == startLine ++ val isLastLine = line == endLine + +- val hasAnyRadius = borderTopLeftRadius != 0f || borderTopRightRadius != 0f || +- borderBottomLeftRadius != 0f || borderBottomRightRadius != 0f +- if (!hasAnyRadius) { +- canvas.drawRect(rectF, paint) +- } else { +- val isFirstLine = line == startLine +- val isLastLine = line == endLine +- drawSelectiveRoundRect(canvas, rectF, isFirstLine, isLastLine) +- } ++ drawFill(canvas, rectF, isFirstLine, isLastLine) ++ drawBorder(canvas, rectF, isFirstLine, isLastLine) + } + } + +- private fun drawSelectiveRoundRect( ++ private fun drawFill( + canvas: Canvas, + rect: RectF, + isFirstLine: Boolean, + isLastLine: Boolean, + ) { +- // First line rounds left corners, last line rounds right corners. +- val topLeft = if (isFirstLine) borderTopLeftRadius else 0f +- val bottomLeft = if (isFirstLine) borderBottomLeftRadius else 0f +- val topRight = if (isLastLine) borderTopRightRadius else 0f +- val bottomRight = if (isLastLine) borderBottomRightRadius else 0f ++ val hasAnyRadius = borderTopLeftRadius != 0f || borderTopRightRadius != 0f || ++ borderBottomLeftRadius != 0f || borderBottomRightRadius != 0f ++ if (!hasAnyRadius) { ++ canvas.drawRect(rect, fillPaint) ++ return ++ } + +- val radii = ++ fillPath.reset() ++ fillPath.addRoundRect( ++ rect, + floatArrayOf( +- topLeft, topLeft, +- topRight, topRight, +- bottomRight, bottomRight, +- bottomLeft, bottomLeft, +- ) +- +- path.reset() +- path.addRoundRect(rect, radii, Path.Direction.CW) +- canvas.drawPath(path, paint) ++ if (isFirstLine) borderTopLeftRadius else 0f, ++ if (isFirstLine) borderTopLeftRadius else 0f, ++ if (isLastLine) borderTopRightRadius else 0f, ++ if (isLastLine) borderTopRightRadius else 0f, ++ if (isLastLine) borderBottomRightRadius else 0f, ++ if (isLastLine) borderBottomRightRadius else 0f, ++ if (isFirstLine) borderBottomLeftRadius else 0f, ++ if (isFirstLine) borderBottomLeftRadius else 0f, ++ ), ++ Path.Direction.CW, ++ ) ++ canvas.drawPath(fillPath, fillPaint) ++ } ++ ++ private fun drawBorder( ++ canvas: Canvas, ++ rect: RectF, ++ isFirstLine: Boolean, ++ isLastLine: Boolean, ++ ) { ++ val tl = if (isFirstLine) borderTopLeftRadius else 0f ++ val bl = if (isFirstLine) borderBottomLeftRadius else 0f ++ val tr = if (isLastLine) borderTopRightRadius else 0f ++ val br = if (isLastLine) borderBottomRightRadius else 0f ++ ++ val x = rect.left ++ val y = rect.top ++ val w = rect.width() ++ val h = rect.height() ++ ++ // Top border ++ if (borderTopColor != null && borderTopWidth > 0f) { ++ borderPaint.color = borderTopColor ++ borderPaint.strokeWidth = borderTopWidth ++ val fromX = x + (if (isFirstLine) tl else 0f) ++ val toX = x + w - (if (isLastLine) tr else 0f) ++ canvas.drawLine(fromX, y, toX, y, borderPaint) ++ } ++ ++ // Bottom border ++ if (borderBottomColor != null && borderBottomWidth > 0f) { ++ borderPaint.color = borderBottomColor ++ borderPaint.strokeWidth = borderBottomWidth ++ val fromX = x + (if (isFirstLine) bl else 0f) ++ val toX = x + w - (if (isLastLine) br else 0f) ++ canvas.drawLine(fromX, y + h, toX, y + h, borderPaint) ++ } ++ ++ // Left border (with TL and BL corner arcs), drawn on the first line only ++ if (isFirstLine && borderLeftColor != null && borderLeftWidth > 0f) { ++ borderPaint.color = borderLeftColor ++ borderPaint.strokeWidth = borderLeftWidth ++ borderPath.reset() ++ if (tl > 0f) { ++ // TL arc: from (x+tl, y) counterclockwise to (x, y+tl) ++ borderPath.moveTo(x + tl, y) ++ borderPath.arcTo(RectF(x, y, x + 2f * tl, y + 2f * tl), 270f, -90f) ++ } else { ++ borderPath.moveTo(x, y) ++ } ++ borderPath.lineTo(x, y + h - bl) ++ if (bl > 0f) { ++ // BL arc: from (x, y+h-bl) counterclockwise to (x+bl, y+h) ++ borderPath.arcTo(RectF(x, y + h - 2f * bl, x + 2f * bl, y + h), 180f, -90f) ++ } ++ canvas.drawPath(borderPath, borderPaint) ++ } ++ ++ // Right border (with TR and BR corner arcs), drawn on the last line only ++ if (isLastLine && borderRightColor != null && borderRightWidth > 0f) { ++ borderPaint.color = borderRightColor ++ borderPaint.strokeWidth = borderRightWidth ++ borderPath.reset() ++ if (tr > 0f) { ++ // TR arc: from (x+w-tr, y) clockwise to (x+w, y+tr) ++ borderPath.moveTo(x + w - tr, y) ++ borderPath.arcTo(RectF(x + w - 2f * tr, y, x + w, y + 2f * tr), 270f, 90f) ++ } else { ++ borderPath.moveTo(x + w, y) ++ } ++ borderPath.lineTo(x + w, y + h - br) ++ if (br > 0f) { ++ // BR arc: from (x+w, y+h-br) clockwise to (x+w-br, y+h) ++ borderPath.arcTo(RectF(x + w - 2f * br, y + h - 2f * br, x + w, y + h), 0f, 90f) ++ } ++ canvas.drawPath(borderPath, borderPaint) ++ } + } + } +diff --git a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp +index 6eb899f38a9..eb0d85b4b69 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp +@@ -42,6 +42,36 @@ void TextAttributes::apply(TextAttributes textAttributes) { + borderBottomRightRadius = textAttributes.borderBottomRightRadius.has_value() + ? textAttributes.borderBottomRightRadius + : borderBottomRightRadius; ++ borderColor = textAttributes.borderColor ++ ? textAttributes.borderColor ++ : borderColor; ++ borderTopColor = textAttributes.borderTopColor ++ ? textAttributes.borderTopColor ++ : borderTopColor; ++ borderRightColor = textAttributes.borderRightColor ++ ? textAttributes.borderRightColor ++ : borderRightColor; ++ borderBottomColor = textAttributes.borderBottomColor ++ ? textAttributes.borderBottomColor ++ : borderBottomColor; ++ borderLeftColor = textAttributes.borderLeftColor ++ ? textAttributes.borderLeftColor ++ : borderLeftColor; ++ borderWidth = textAttributes.borderWidth.has_value() ++ ? textAttributes.borderWidth ++ : borderWidth; ++ borderTopWidth = textAttributes.borderTopWidth.has_value() ++ ? textAttributes.borderTopWidth ++ : borderTopWidth; ++ borderRightWidth = textAttributes.borderRightWidth.has_value() ++ ? textAttributes.borderRightWidth ++ : borderRightWidth; ++ borderBottomWidth = textAttributes.borderBottomWidth.has_value() ++ ? textAttributes.borderBottomWidth ++ : borderBottomWidth; ++ borderLeftWidth = textAttributes.borderLeftWidth.has_value() ++ ? textAttributes.borderLeftWidth ++ : borderLeftWidth; + + // Font + fontFamily = !textAttributes.fontFamily.empty() ? textAttributes.fontFamily +@@ -161,7 +191,17 @@ bool TextAttributes::operator==(const TextAttributes& rhs) const { + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, +- borderBottomRightRadius) == ++ borderBottomRightRadius, ++ borderColor, ++ borderTopColor, ++ borderRightColor, ++ borderBottomColor, ++ borderLeftColor, ++ borderWidth, ++ borderTopWidth, ++ borderRightWidth, ++ borderBottomWidth, ++ borderLeftWidth) == + std::tie( + rhs.foregroundColor, + rhs.backgroundColor, +@@ -189,7 +229,17 @@ bool TextAttributes::operator==(const TextAttributes& rhs) const { + rhs.borderTopLeftRadius, + rhs.borderTopRightRadius, + rhs.borderBottomLeftRadius, +- rhs.borderBottomRightRadius) && ++ rhs.borderBottomRightRadius, ++ rhs.borderColor, ++ rhs.borderTopColor, ++ rhs.borderRightColor, ++ rhs.borderBottomColor, ++ rhs.borderLeftColor, ++ rhs.borderWidth, ++ rhs.borderTopWidth, ++ rhs.borderRightWidth, ++ rhs.borderBottomWidth, ++ rhs.borderLeftWidth) && + floatEquality(maxFontSizeMultiplier, rhs.maxFontSizeMultiplier) && + floatEquality(opacity, rhs.opacity) && + floatEquality(fontSize, rhs.fontSize) && +@@ -242,6 +292,38 @@ SharedDebugStringConvertibleList TextAttributes::getDebugProps() const { + "borderBottomRightRadius", + borderBottomRightRadius, + textAttributes.borderBottomRightRadius), ++ debugStringConvertibleItem( ++ "borderColor", borderColor, textAttributes.borderColor), ++ debugStringConvertibleItem( ++ "borderTopColor", borderTopColor, textAttributes.borderTopColor), ++ debugStringConvertibleItem( ++ "borderRightColor", ++ borderRightColor, ++ textAttributes.borderRightColor), ++ debugStringConvertibleItem( ++ "borderBottomColor", ++ borderBottomColor, ++ textAttributes.borderBottomColor), ++ debugStringConvertibleItem( ++ "borderLeftColor", ++ borderLeftColor, ++ textAttributes.borderLeftColor), ++ debugStringConvertibleItem( ++ "borderWidth", borderWidth, textAttributes.borderWidth), ++ debugStringConvertibleItem( ++ "borderTopWidth", borderTopWidth, textAttributes.borderTopWidth), ++ debugStringConvertibleItem( ++ "borderRightWidth", ++ borderRightWidth, ++ textAttributes.borderRightWidth), ++ debugStringConvertibleItem( ++ "borderBottomWidth", ++ borderBottomWidth, ++ textAttributes.borderBottomWidth), ++ debugStringConvertibleItem( ++ "borderLeftWidth", ++ borderLeftWidth, ++ textAttributes.borderLeftWidth), + + // Font + debugStringConvertibleItem( +diff --git a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h +index 2eaa3bc26f3..af278a3921a 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h ++++ b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h +@@ -47,6 +47,16 @@ class TextAttributes : public DebugStringConvertible { + std::optional borderTopRightRadius{}; + std::optional borderBottomLeftRadius{}; + std::optional borderBottomRightRadius{}; ++ SharedColor borderColor{}; ++ SharedColor borderTopColor{}; ++ SharedColor borderRightColor{}; ++ SharedColor borderBottomColor{}; ++ SharedColor borderLeftColor{}; ++ std::optional borderWidth{}; ++ std::optional borderTopWidth{}; ++ std::optional borderRightWidth{}; ++ std::optional borderBottomWidth{}; ++ std::optional borderLeftWidth{}; + + // Font + std::string fontFamily{""}; +@@ -148,7 +158,17 @@ struct hash { + textAttributes.borderTopLeftRadius, + textAttributes.borderTopRightRadius, + textAttributes.borderBottomLeftRadius, +- textAttributes.borderBottomRightRadius); ++ textAttributes.borderBottomRightRadius, ++ textAttributes.borderColor, ++ textAttributes.borderTopColor, ++ textAttributes.borderRightColor, ++ textAttributes.borderBottomColor, ++ textAttributes.borderLeftColor, ++ textAttributes.borderWidth, ++ textAttributes.borderTopWidth, ++ textAttributes.borderRightWidth, ++ textAttributes.borderBottomWidth, ++ textAttributes.borderLeftWidth); + } + }; + } // namespace std +diff --git a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +index ad8f927259f..978eea38d20 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h ++++ b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +@@ -1031,6 +1031,14 @@ constexpr static MapBuffer::Key TA_KEY_BORDER_TOP_LEFT_RADIUS = 30; + constexpr static MapBuffer::Key TA_KEY_BORDER_TOP_RIGHT_RADIUS = 31; + constexpr static MapBuffer::Key TA_KEY_BORDER_BOTTOM_LEFT_RADIUS = 32; + constexpr static MapBuffer::Key TA_KEY_BORDER_BOTTOM_RIGHT_RADIUS = 33; ++constexpr static MapBuffer::Key TA_KEY_BORDER_TOP_COLOR = 34; ++constexpr static MapBuffer::Key TA_KEY_BORDER_RIGHT_COLOR = 35; ++constexpr static MapBuffer::Key TA_KEY_BORDER_BOTTOM_COLOR = 36; ++constexpr static MapBuffer::Key TA_KEY_BORDER_LEFT_COLOR = 37; ++constexpr static MapBuffer::Key TA_KEY_BORDER_TOP_WIDTH = 38; ++constexpr static MapBuffer::Key TA_KEY_BORDER_RIGHT_WIDTH = 39; ++constexpr static MapBuffer::Key TA_KEY_BORDER_BOTTOM_WIDTH = 40; ++constexpr static MapBuffer::Key TA_KEY_BORDER_LEFT_WIDTH = 41; + + // constants for ParagraphAttributes serialization + constexpr static MapBuffer::Key PA_KEY_MAX_NUMBER_OF_LINES = 0; +@@ -1257,6 +1265,49 @@ inline MapBuffer toMapBuffer(const TextAttributes &textAttributes) + textAttributes.borderBottomRightRadius.value_or(fallback)); + } + } ++ { ++ const bool hasAnyColor = textAttributes.borderColor || ++ textAttributes.borderTopColor || ++ textAttributes.borderRightColor || ++ textAttributes.borderBottomColor || ++ textAttributes.borderLeftColor; ++ const bool hasAnyWidth = textAttributes.borderWidth.has_value() || ++ textAttributes.borderTopWidth.has_value() || ++ textAttributes.borderRightWidth.has_value() || ++ textAttributes.borderBottomWidth.has_value() || ++ textAttributes.borderLeftWidth.has_value(); ++ if (hasAnyColor || hasAnyWidth) { ++ // Resolve per-side colors against the uniform fallback; only send when non-null. ++ auto resolveColor = [&](const SharedColor& perSide) -> SharedColor { ++ return perSide ? perSide : textAttributes.borderColor; ++ }; ++ if (const auto c = resolveColor(textAttributes.borderTopColor)) { ++ builder.putInt(TA_KEY_BORDER_TOP_COLOR, toAndroidRepr(c)); ++ } ++ if (const auto c = resolveColor(textAttributes.borderRightColor)) { ++ builder.putInt(TA_KEY_BORDER_RIGHT_COLOR, toAndroidRepr(c)); ++ } ++ if (const auto c = resolveColor(textAttributes.borderBottomColor)) { ++ builder.putInt(TA_KEY_BORDER_BOTTOM_COLOR, toAndroidRepr(c)); ++ } ++ if (const auto c = resolveColor(textAttributes.borderLeftColor)) { ++ builder.putInt(TA_KEY_BORDER_LEFT_COLOR, toAndroidRepr(c)); ++ } ++ const Float fallback = textAttributes.borderWidth.value_or(0.0f); ++ builder.putDouble( ++ TA_KEY_BORDER_TOP_WIDTH, ++ textAttributes.borderTopWidth.value_or(fallback)); ++ builder.putDouble( ++ TA_KEY_BORDER_RIGHT_WIDTH, ++ textAttributes.borderRightWidth.value_or(fallback)); ++ builder.putDouble( ++ TA_KEY_BORDER_BOTTOM_WIDTH, ++ textAttributes.borderBottomWidth.value_or(fallback)); ++ builder.putDouble( ++ TA_KEY_BORDER_LEFT_WIDTH, ++ textAttributes.borderLeftWidth.value_or(fallback)); ++ } ++ } + return builder.build(); + } + +diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp +index 803cbfe2661..73d21c90cfb 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp +@@ -252,6 +252,66 @@ static TextAttributes convertRawProp( + "borderBottomRightRadius", + sourceTextAttributes.borderBottomRightRadius, + defaultTextAttributes.borderBottomRightRadius); ++ textAttributes.borderColor = convertRawProp( ++ context, ++ rawProps, ++ "borderColor", ++ sourceTextAttributes.borderColor, ++ defaultTextAttributes.borderColor); ++ textAttributes.borderTopColor = convertRawProp( ++ context, ++ rawProps, ++ "borderTopColor", ++ sourceTextAttributes.borderTopColor, ++ defaultTextAttributes.borderTopColor); ++ textAttributes.borderRightColor = convertRawProp( ++ context, ++ rawProps, ++ "borderRightColor", ++ sourceTextAttributes.borderRightColor, ++ defaultTextAttributes.borderRightColor); ++ textAttributes.borderBottomColor = convertRawProp( ++ context, ++ rawProps, ++ "borderBottomColor", ++ sourceTextAttributes.borderBottomColor, ++ defaultTextAttributes.borderBottomColor); ++ textAttributes.borderLeftColor = convertRawProp( ++ context, ++ rawProps, ++ "borderLeftColor", ++ sourceTextAttributes.borderLeftColor, ++ defaultTextAttributes.borderLeftColor); ++ textAttributes.borderWidth = convertRawProp( ++ context, ++ rawProps, ++ "borderWidth", ++ sourceTextAttributes.borderWidth, ++ defaultTextAttributes.borderWidth); ++ textAttributes.borderTopWidth = convertRawProp( ++ context, ++ rawProps, ++ "borderTopWidth", ++ sourceTextAttributes.borderTopWidth, ++ defaultTextAttributes.borderTopWidth); ++ textAttributes.borderRightWidth = convertRawProp( ++ context, ++ rawProps, ++ "borderRightWidth", ++ sourceTextAttributes.borderRightWidth, ++ defaultTextAttributes.borderRightWidth); ++ textAttributes.borderBottomWidth = convertRawProp( ++ context, ++ rawProps, ++ "borderBottomWidth", ++ sourceTextAttributes.borderBottomWidth, ++ defaultTextAttributes.borderBottomWidth); ++ textAttributes.borderLeftWidth = convertRawProp( ++ context, ++ rawProps, ++ "borderLeftWidth", ++ sourceTextAttributes.borderLeftWidth, ++ defaultTextAttributes.borderLeftWidth); + + return textAttributes; + } +@@ -374,6 +434,26 @@ void BaseTextProps::setProp( + defaults, value, textAttributes, borderBottomLeftRadius, "borderBottomLeftRadius"); + REBUILD_FIELD_SWITCH_CASE( + defaults, value, textAttributes, borderBottomRightRadius, "borderBottomRightRadius"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderColor, "borderColor"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderTopColor, "borderTopColor"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderRightColor, "borderRightColor"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderBottomColor, "borderBottomColor"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderLeftColor, "borderLeftColor"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderWidth, "borderWidth"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderTopWidth, "borderTopWidth"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderRightWidth, "borderRightWidth"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderBottomWidth, "borderBottomWidth"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, borderLeftWidth, "borderLeftWidth"); + } + } + +@@ -611,6 +691,72 @@ void BaseTextProps::appendTextAttributesProps( + ? textAttributes.borderBottomRightRadius.value() + : folly::dynamic(nullptr); + } ++ ++ if (textAttributes.borderColor != oldProps->textAttributes.borderColor) { ++ result["borderColor"] = textAttributes.borderColor ++ ? *textAttributes.borderColor ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.borderTopColor != oldProps->textAttributes.borderTopColor) { ++ result["borderTopColor"] = textAttributes.borderTopColor ++ ? *textAttributes.borderTopColor ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.borderRightColor != ++ oldProps->textAttributes.borderRightColor) { ++ result["borderRightColor"] = textAttributes.borderRightColor ++ ? *textAttributes.borderRightColor ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.borderBottomColor != ++ oldProps->textAttributes.borderBottomColor) { ++ result["borderBottomColor"] = textAttributes.borderBottomColor ++ ? *textAttributes.borderBottomColor ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.borderLeftColor != ++ oldProps->textAttributes.borderLeftColor) { ++ result["borderLeftColor"] = textAttributes.borderLeftColor ++ ? *textAttributes.borderLeftColor ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.borderWidth != oldProps->textAttributes.borderWidth) { ++ result["borderWidth"] = textAttributes.borderWidth.has_value() ++ ? textAttributes.borderWidth.value() ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.borderTopWidth != oldProps->textAttributes.borderTopWidth) { ++ result["borderTopWidth"] = textAttributes.borderTopWidth.has_value() ++ ? textAttributes.borderTopWidth.value() ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.borderRightWidth != ++ oldProps->textAttributes.borderRightWidth) { ++ result["borderRightWidth"] = textAttributes.borderRightWidth.has_value() ++ ? textAttributes.borderRightWidth.value() ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.borderBottomWidth != ++ oldProps->textAttributes.borderBottomWidth) { ++ result["borderBottomWidth"] = textAttributes.borderBottomWidth.has_value() ++ ? textAttributes.borderBottomWidth.value() ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.borderLeftWidth != ++ oldProps->textAttributes.borderLeftWidth) { ++ result["borderLeftWidth"] = textAttributes.borderLeftWidth.has_value() ++ ? textAttributes.borderLeftWidth.value() ++ : folly::dynamic(nullptr); ++ } + } + + #endif +diff --git a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +index 585e06eb7f0..466efb284d9 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm ++++ b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +@@ -189,19 +189,52 @@ NSMutableDictionary *RCTNSTextAttributesFromTextAttri + textAttributes.borderTopRightRadius.has_value() || + textAttributes.borderBottomLeftRadius.has_value() || + textAttributes.borderBottomRightRadius.has_value(); +- +- if (textAttributes.backgroundColor || !isnan(textAttributes.opacity)) { +- UIColor *bgColor = RCTEffectiveBackgroundColorFromTextAttributes(textAttributes); +- if (hasAnyRadius) { +- // Store color alongside radii and skip NSBackgroundColorAttributeName so +- // NSLayoutManager doesn't draw a flat rect before our per-line rounded one. +- const CGFloat fallback = textAttributes.borderRadius.value_or(0.0f); ++ const bool hasAnyBorderWidth = textAttributes.borderWidth.has_value() || ++ textAttributes.borderTopWidth.has_value() || ++ textAttributes.borderRightWidth.has_value() || ++ textAttributes.borderBottomWidth.has_value() || ++ textAttributes.borderLeftWidth.has_value(); ++ const bool hasBorderColor = textAttributes.borderColor || ++ textAttributes.borderTopColor || ++ textAttributes.borderRightColor || ++ textAttributes.borderBottomColor || ++ textAttributes.borderLeftColor; ++ const bool useCustomDraw = hasAnyRadius || hasAnyBorderWidth || hasBorderColor; ++ ++ if (textAttributes.backgroundColor || !isnan(textAttributes.opacity) || useCustomDraw) { ++ const bool hasBackground = textAttributes.backgroundColor || !isnan(textAttributes.opacity); ++ UIColor *bgColor = hasBackground ++ ? RCTEffectiveBackgroundColorFromTextAttributes(textAttributes) ++ : [UIColor clearColor]; ++ if (useCustomDraw) { ++ // Store color alongside radii and per-side border info; skip NSBackgroundColorAttributeName ++ // so NSLayoutManager doesn't draw a flat rect before our per-line custom drawing. ++ const CGFloat radiusFallback = textAttributes.borderRadius.value_or(0.0f); ++ const CGFloat widthFallback = textAttributes.borderWidth.value_or(0.0f); ++ ++ // Resolve per-side border colors against the uniform fallback; use NSNull when absent. ++ auto borderUIColor = [&](const facebook::react::SharedColor& perSide) -> id { ++ const auto resolved = perSide ? perSide : textAttributes.borderColor; ++ return resolved ? (id)RCTUIColorFromSharedColor(resolved) : (id)[NSNull null]; ++ }; ++ ++ // attrs: @[bgColor, tlR, trR, blR, brR, ++ // topBorderColor, rightBorderColor, bottomBorderColor, leftBorderColor, ++ // topW, rightW, bottomW, leftW] + attributes[RCTTextBorderRadiusAttributeName] = @[ + bgColor, +- @(textAttributes.borderTopLeftRadius.value_or(fallback)), +- @(textAttributes.borderTopRightRadius.value_or(fallback)), +- @(textAttributes.borderBottomLeftRadius.value_or(fallback)), +- @(textAttributes.borderBottomRightRadius.value_or(fallback)), ++ @(textAttributes.borderTopLeftRadius.value_or(radiusFallback)), ++ @(textAttributes.borderTopRightRadius.value_or(radiusFallback)), ++ @(textAttributes.borderBottomLeftRadius.value_or(radiusFallback)), ++ @(textAttributes.borderBottomRightRadius.value_or(radiusFallback)), ++ borderUIColor(textAttributes.borderTopColor), ++ borderUIColor(textAttributes.borderRightColor), ++ borderUIColor(textAttributes.borderBottomColor), ++ borderUIColor(textAttributes.borderLeftColor), ++ @(textAttributes.borderTopWidth.value_or(widthFallback)), ++ @(textAttributes.borderRightWidth.value_or(widthFallback)), ++ @(textAttributes.borderBottomWidth.value_or(widthFallback)), ++ @(textAttributes.borderLeftWidth.value_or(widthFallback)), + ]; + } else { + attributes[NSBackgroundColorAttributeName] = bgColor; +diff --git a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManagerWithBorderRadius.mm b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManagerWithBorderRadius.mm +index de879555fcf..c2320fe9792 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManagerWithBorderRadius.mm ++++ b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManagerWithBorderRadius.mm +@@ -26,13 +26,32 @@ + return; + } + +- // attrs is @[UIColor, tlRadius, trRadius, blRadius, brRadius] ++ // attrs: @[bgColor, tlR, trR, blR, brR, ++ // topBorderColor, rightBorderColor, bottomBorderColor, leftBorderColor, ++ // topW, rightW, bottomW, leftW] + NSArray *attrs = (NSArray *)value; +- UIColor *color = attrs[0]; +- CGFloat tl = [attrs[1] floatValue]; +- CGFloat tr = [attrs[2] floatValue]; +- CGFloat bl = [attrs[3] floatValue]; +- CGFloat br = [attrs[4] floatValue]; ++ UIColor *bgColor = attrs[0]; ++ CGFloat tl = [attrs[1] floatValue]; ++ CGFloat tr = [attrs[2] floatValue]; ++ CGFloat bl = [attrs[3] floatValue]; ++ CGFloat br = [attrs[4] floatValue]; ++ ++ id topColorRaw = attrs[5]; ++ id rightColorRaw = attrs[6]; ++ id bottomColorRaw = attrs[7]; ++ id leftColorRaw = attrs[8]; ++ UIColor *topBorderColor = [topColorRaw isKindOfClass:[UIColor class]] ? topColorRaw : nil; ++ UIColor *rightBorderColor = [rightColorRaw isKindOfClass:[UIColor class]] ? rightColorRaw : nil; ++ UIColor *bottomBorderColor = [bottomColorRaw isKindOfClass:[UIColor class]] ? bottomColorRaw : nil; ++ UIColor *leftBorderColor = [leftColorRaw isKindOfClass:[UIColor class]] ? leftColorRaw : nil; ++ ++ CGFloat topW = [attrs[9] floatValue]; ++ CGFloat rightW = [attrs[10] floatValue]; ++ CGFloat bottomW = [attrs[11] floatValue]; ++ CGFloat leftW = [attrs[12] floatValue]; ++ ++ BOOL hasBorder = (topBorderColor && topW > 0) || (rightBorderColor && rightW > 0) || ++ (bottomBorderColor && bottomW > 0) || (leftBorderColor && leftW > 0); + + NSRange spanGlyphRange = [self glyphRangeForCharacterRange:attrRange actualCharacterRange:nil]; + +@@ -52,7 +71,6 @@ + } + + CGContextSaveGState(context); +- CGContextSetFillColorWithColor(context, color.CGColor); + + __block NSUInteger lineIdx = 0; + [self enumerateLineFragmentsForGlyphRange:spanGlyphRange +@@ -92,6 +110,7 @@ + BOOL isFirst = (lineIdx == 0); + BOOL isLast = (lineIdx == totalLines - 1); + ++ // Left/right corners follow the same rule as fill: left on first line, right on last. + CGFloat effectiveTL = isFirst ? tl : 0; + CGFloat effectiveBL = isFirst ? bl : 0; + CGFloat effectiveTR = isLast ? tr : 0; +@@ -102,24 +121,97 @@ + CGFloat w = CGRectGetWidth(spanRect); + CGFloat h = CGRectGetHeight(spanRect); + ++ // --- Fill --- ++ CGContextSetFillColorWithColor(context, bgColor.CGColor); + BOOL hasAnyRadius = tl > 0 || tr > 0 || bl > 0 || br > 0; ++ CGMutablePathRef fillPath = NULL; + if (!hasAnyRadius) { + CGContextFillRect(context, spanRect); + } else { +- CGMutablePathRef path = CGPathCreateMutable(); +- CGPathMoveToPoint(path, NULL, x + effectiveTL, y); +- CGPathAddLineToPoint(path, NULL, x + w - effectiveTR, y); +- CGPathAddArcToPoint(path, NULL, x + w, y, x + w, y + effectiveTR, effectiveTR); +- CGPathAddLineToPoint(path, NULL, x + w, y + h - effectiveBR); +- CGPathAddArcToPoint(path, NULL, x + w, y + h, x + w - effectiveBR, y + h, effectiveBR); +- CGPathAddLineToPoint(path, NULL, x + effectiveBL, y + h); +- CGPathAddArcToPoint(path, NULL, x, y + h, x, y + h - effectiveBL, effectiveBL); +- CGPathAddLineToPoint(path, NULL, x, y + effectiveTL); +- CGPathAddArcToPoint(path, NULL, x, y, x + effectiveTL, y, effectiveTL); +- CGPathCloseSubpath(path); +- CGContextAddPath(context, path); ++ fillPath = CGPathCreateMutable(); ++ CGPathMoveToPoint(fillPath, NULL, x + effectiveTL, y); ++ CGPathAddLineToPoint(fillPath, NULL, x + w - effectiveTR, y); ++ CGPathAddArcToPoint(fillPath, NULL, x + w, y, x + w, y + effectiveTR, effectiveTR); ++ CGPathAddLineToPoint(fillPath, NULL, x + w, y + h - effectiveBR); ++ CGPathAddArcToPoint(fillPath, NULL, x + w, y + h, x + w - effectiveBR, y + h, effectiveBR); ++ CGPathAddLineToPoint(fillPath, NULL, x + effectiveBL, y + h); ++ CGPathAddArcToPoint(fillPath, NULL, x, y + h, x, y + h - effectiveBL, effectiveBL); ++ CGPathAddLineToPoint(fillPath, NULL, x, y + effectiveTL); ++ CGPathAddArcToPoint(fillPath, NULL, x, y, x + effectiveTL, y, effectiveTL); ++ CGPathCloseSubpath(fillPath); ++ CGContextAddPath(context, fillPath); + CGContextFillPath(context); +- CGPathRelease(path); ++ // fillPath kept alive for border clipping below. ++ } ++ ++ // --- Border --- ++ if (hasBorder) { ++ // Draw the stroke centered on the span-rect edge, on top of the fill (matching ++ // Android's ReactBackgroundDrawSpan). Do NOT clip to outside the fill: a centered ++ // stroke straddles the edge, so an outside-only clip would remove the inner half of ++ // the border — the part that should sit over the background — making it look clipped. ++ CGContextSaveGState(context); ++ ++ CGContextSetLineCap(context, kCGLineCapButt); ++ ++ BOOL drawLeft = isFirst; ++ BOOL drawRight = isLast; ++ ++ // Top border ++ if (topBorderColor && topW > 0) { ++ CGContextSetStrokeColorWithColor(context, topBorderColor.CGColor); ++ CGContextSetLineWidth(context, topW); ++ CGFloat fromX = x + (drawLeft ? effectiveTL : 0); ++ CGFloat toX = x + w - (drawRight ? effectiveTR : 0); ++ CGContextMoveToPoint(context, fromX, y); ++ CGContextAddLineToPoint(context, toX, y); ++ CGContextStrokePath(context); ++ } ++ ++ // Bottom border ++ if (bottomBorderColor && bottomW > 0) { ++ CGContextSetStrokeColorWithColor(context, bottomBorderColor.CGColor); ++ CGContextSetLineWidth(context, bottomW); ++ CGFloat fromX = x + (drawLeft ? effectiveBL : 0); ++ CGFloat toX = x + w - (drawRight ? effectiveBR : 0); ++ CGContextMoveToPoint(context, fromX, y + h); ++ CGContextAddLineToPoint(context, toX, y + h); ++ CGContextStrokePath(context); ++ } ++ ++ // Left border (with TL and BL arcs via ArcToPoint), drawn on first line only ++ if (drawLeft && leftBorderColor && leftW > 0) { ++ CGContextSetStrokeColorWithColor(context, leftBorderColor.CGColor); ++ CGContextSetLineWidth(context, leftW); ++ CGMutablePathRef path = CGPathCreateMutable(); ++ CGPathMoveToPoint(path, NULL, x + effectiveTL, y); ++ CGPathAddArcToPoint(path, NULL, x, y, x, y + effectiveTL, effectiveTL); ++ CGPathAddLineToPoint(path, NULL, x, y + h - effectiveBL); ++ CGPathAddArcToPoint(path, NULL, x, y + h, x + effectiveBL, y + h, effectiveBL); ++ CGContextAddPath(context, path); ++ CGContextStrokePath(context); ++ CGPathRelease(path); ++ } ++ ++ // Right border (with TR and BR arcs via ArcToPoint), drawn on last line only ++ if (drawRight && rightBorderColor && rightW > 0) { ++ CGContextSetStrokeColorWithColor(context, rightBorderColor.CGColor); ++ CGContextSetLineWidth(context, rightW); ++ CGMutablePathRef path = CGPathCreateMutable(); ++ CGPathMoveToPoint(path, NULL, x + w - effectiveTR, y); ++ CGPathAddArcToPoint(path, NULL, x + w, y, x + w, y + effectiveTR, effectiveTR); ++ CGPathAddLineToPoint(path, NULL, x + w, y + h - effectiveBR); ++ CGPathAddArcToPoint(path, NULL, x + w, y + h, x + w - effectiveBR, y + h, effectiveBR); ++ CGContextAddPath(context, path); ++ CGContextStrokePath(context); ++ CGPathRelease(path); ++ } ++ ++ CGContextRestoreGState(context); ++ } ++ ++ if (fillPath) { ++ CGPathRelease(fillPath); + } + + lineIdx++; diff --git a/patches/react-native/react-native+0.85.3+039+nested-text-padding.patch b/patches/react-native/react-native+0.85.3+039+nested-text-padding.patch new file mode 100644 index 000000000000..da74fc5c6b7f --- /dev/null +++ b/patches/react-native/react-native+0.85.3+039+nested-text-padding.patch @@ -0,0 +1,747 @@ +diff --git a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h +index af278a3921a..5a42b20b944 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h ++++ b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.h +@@ -57,6 +57,10 @@ class TextAttributes : public DebugStringConvertible { + std::optional borderRightWidth{}; + std::optional borderBottomWidth{}; + std::optional borderLeftWidth{}; ++ // Inner padding between the border and the text (resolved from ++ // paddingLeft/paddingRight, falling back to paddingHorizontal). ++ std::optional paddingLeft{}; ++ std::optional paddingRight{}; + + // Font + std::string fontFamily{""}; +@@ -168,7 +172,9 @@ struct hash { + textAttributes.borderTopWidth, + textAttributes.borderRightWidth, + textAttributes.borderBottomWidth, +- textAttributes.borderLeftWidth); ++ textAttributes.borderLeftWidth, ++ textAttributes.paddingLeft, ++ textAttributes.paddingRight); + } + }; + } // namespace std +diff --git a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp +index eb0d85b4b69..ed36f04509b 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/TextAttributes.cpp +@@ -72,6 +72,12 @@ void TextAttributes::apply(TextAttributes textAttributes) { + borderLeftWidth = textAttributes.borderLeftWidth.has_value() + ? textAttributes.borderLeftWidth + : borderLeftWidth; ++ paddingLeft = textAttributes.paddingLeft.has_value() ++ ? textAttributes.paddingLeft ++ : paddingLeft; ++ paddingRight = textAttributes.paddingRight.has_value() ++ ? textAttributes.paddingRight ++ : paddingRight; + + // Font + fontFamily = !textAttributes.fontFamily.empty() ? textAttributes.fontFamily +@@ -201,7 +207,9 @@ bool TextAttributes::operator==(const TextAttributes& rhs) const { + borderTopWidth, + borderRightWidth, + borderBottomWidth, +- borderLeftWidth) == ++ borderLeftWidth, ++ paddingLeft, ++ paddingRight) == + std::tie( + rhs.foregroundColor, + rhs.backgroundColor, +@@ -239,7 +247,9 @@ bool TextAttributes::operator==(const TextAttributes& rhs) const { + rhs.borderTopWidth, + rhs.borderRightWidth, + rhs.borderBottomWidth, +- rhs.borderLeftWidth) && ++ rhs.borderLeftWidth, ++ rhs.paddingLeft, ++ rhs.paddingRight) && + floatEquality(maxFontSizeMultiplier, rhs.maxFontSizeMultiplier) && + floatEquality(opacity, rhs.opacity) && + floatEquality(fontSize, rhs.fontSize) && +@@ -324,6 +334,10 @@ SharedDebugStringConvertibleList TextAttributes::getDebugProps() const { + "borderLeftWidth", + borderLeftWidth, + textAttributes.borderLeftWidth), ++ debugStringConvertibleItem( ++ "paddingLeft", paddingLeft, textAttributes.paddingLeft), ++ debugStringConvertibleItem( ++ "paddingRight", paddingRight, textAttributes.paddingRight), + + // Font + debugStringConvertibleItem( +diff --git a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +index 978eea38d20..1b0beaf9f0b 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h ++++ b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h +@@ -1039,6 +1039,8 @@ constexpr static MapBuffer::Key TA_KEY_BORDER_TOP_WIDTH = 38; + constexpr static MapBuffer::Key TA_KEY_BORDER_RIGHT_WIDTH = 39; + constexpr static MapBuffer::Key TA_KEY_BORDER_BOTTOM_WIDTH = 40; + constexpr static MapBuffer::Key TA_KEY_BORDER_LEFT_WIDTH = 41; ++constexpr static MapBuffer::Key TA_KEY_PADDING_LEFT = 42; ++constexpr static MapBuffer::Key TA_KEY_PADDING_RIGHT = 43; + + // constants for ParagraphAttributes serialization + constexpr static MapBuffer::Key PA_KEY_MAX_NUMBER_OF_LINES = 0; +@@ -1308,6 +1310,16 @@ inline MapBuffer toMapBuffer(const TextAttributes &textAttributes) + textAttributes.borderLeftWidth.value_or(fallback)); + } + } ++ { ++ if (textAttributes.paddingLeft.has_value()) { ++ builder.putDouble( ++ TA_KEY_PADDING_LEFT, textAttributes.paddingLeft.value()); ++ } ++ if (textAttributes.paddingRight.has_value()) { ++ builder.putDouble( ++ TA_KEY_PADDING_RIGHT, textAttributes.paddingRight.value()); ++ } ++ } + return builder.build(); + } + +diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp +index 73d21c90cfb..aa3c836e7e5 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/components/text/BaseTextProps.cpp +@@ -312,6 +312,32 @@ static TextAttributes convertRawProp( + "borderLeftWidth", + sourceTextAttributes.borderLeftWidth, + defaultTextAttributes.borderLeftWidth); ++ // Inner padding between border and text. paddingLeft/paddingRight win over ++ // paddingHorizontal; nothing set leaves the value empty (treated as 0 downstream). ++ { ++ auto rawPaddingHorizontal = convertRawProp( ++ context, ++ rawProps, ++ "paddingHorizontal", ++ std::optional{}, ++ std::optional{}); ++ auto rawPaddingLeft = convertRawProp( ++ context, ++ rawProps, ++ "paddingLeft", ++ sourceTextAttributes.paddingLeft, ++ defaultTextAttributes.paddingLeft); ++ auto rawPaddingRight = convertRawProp( ++ context, ++ rawProps, ++ "paddingRight", ++ sourceTextAttributes.paddingRight, ++ defaultTextAttributes.paddingRight); ++ textAttributes.paddingLeft = ++ rawPaddingLeft.has_value() ? rawPaddingLeft : rawPaddingHorizontal; ++ textAttributes.paddingRight = ++ rawPaddingRight.has_value() ? rawPaddingRight : rawPaddingHorizontal; ++ } + + return textAttributes; + } +@@ -454,6 +480,10 @@ void BaseTextProps::setProp( + defaults, value, textAttributes, borderBottomWidth, "borderBottomWidth"); + REBUILD_FIELD_SWITCH_CASE( + defaults, value, textAttributes, borderLeftWidth, "borderLeftWidth"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, paddingLeft, "paddingLeft"); ++ REBUILD_FIELD_SWITCH_CASE( ++ defaults, value, textAttributes, paddingRight, "paddingRight"); + } + } + +@@ -757,6 +787,18 @@ void BaseTextProps::appendTextAttributesProps( + ? textAttributes.borderLeftWidth.value() + : folly::dynamic(nullptr); + } ++ ++ if (textAttributes.paddingLeft != oldProps->textAttributes.paddingLeft) { ++ result["paddingLeft"] = textAttributes.paddingLeft.has_value() ++ ? textAttributes.paddingLeft.value() ++ : folly::dynamic(nullptr); ++ } ++ ++ if (textAttributes.paddingRight != oldProps->textAttributes.paddingRight) { ++ result["paddingRight"] = textAttributes.paddingRight.has_value() ++ ? textAttributes.paddingRight.value() ++ : folly::dynamic(nullptr); ++ } + } + + #endif +diff --git a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +index 466efb284d9..892dcb72f62 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm ++++ b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm +@@ -220,7 +220,7 @@ NSMutableDictionary *RCTNSTextAttributesFromTextAttri + + // attrs: @[bgColor, tlR, trR, blR, brR, + // topBorderColor, rightBorderColor, bottomBorderColor, leftBorderColor, +- // topW, rightW, bottomW, leftW] ++ // topW, rightW, bottomW, leftW, paddingLeft, paddingRight] + attributes[RCTTextBorderRadiusAttributeName] = @[ + bgColor, + @(textAttributes.borderTopLeftRadius.value_or(radiusFallback)), +@@ -235,6 +235,8 @@ NSMutableDictionary *RCTNSTextAttributesFromTextAttri + @(textAttributes.borderRightWidth.value_or(widthFallback)), + @(textAttributes.borderBottomWidth.value_or(widthFallback)), + @(textAttributes.borderLeftWidth.value_or(widthFallback)), ++ @(textAttributes.paddingLeft.value_or(0.0f)), ++ @(textAttributes.paddingRight.value_or(0.0f)), + ]; + } else { + attributes[NSBackgroundColorAttributeName] = bgColor; +@@ -478,6 +480,71 @@ NSAttributedString *RCTNSAttributedStringFromAttributedString(const AttributedSt + + [nsAttributedString appendAttributedString:nsAttributedStringFragment]; + } ++ ++ // Reserve horizontal advance around a bordered / inline-code span (internal padding of borderWidth + the border, on each side) ++ // so the border has room instead of overlapping neighbouring words or getting clipped: ++ // - Leading at a paragraph start: firstLineHeadIndent (shifts the line + grows the frame). ++ // - Leading mid-paragraph: kern the preceding character (pushes the span right). ++ // - Trailing at a paragraph end: handled by the measure step (RCTTextLayoutManager). ++ // - Trailing mid-paragraph: kern the span's last character (pushes the next word right). ++ // (Soft-wrapped line starts are handled by RCTTextLayoutManagerWithBorderRadius's layout delegate.) ++ NSString *plainString = nsAttributedString.string; ++ NSUInteger fullLength = nsAttributedString.length; ++ [nsAttributedString enumerateAttribute:RCTTextBorderRadiusAttributeName ++ inRange:NSMakeRange(0, fullLength) ++ options:0 ++ usingBlock:^(id value, NSRange range, __unused BOOL *stop) { ++ if (![value isKindOfClass:[NSArray class]] || range.length == 0) { ++ return; ++ } ++ NSArray *attrs = (NSArray *)value; ++ if (attrs.count < 15) { ++ return; ++ } ++ // attrs layout matches RCTNSTextAttributesFromTextAttributes: rightW=attrs[10], leftW=attrs[12], ++ // paddingLeft=attrs[13], paddingRight=attrs[14]. ++ const CGFloat rightW = [attrs[10] floatValue]; ++ const CGFloat leftW = [attrs[12] floatValue]; ++ // Advance to reserve on each side = inner padding + border outer extent (centered stroke). ++ const CGFloat leftReserve = [attrs[13] floatValue] + leftW; ++ const CGFloat rightReserve = [attrs[14] floatValue] + rightW; ++ const BOOL atParagraphStart = ++ range.location == 0 || [plainString characterAtIndex:range.location - 1] == '\n'; ++ const BOOL atParagraphEnd = ++ NSMaxRange(range) >= fullLength || [plainString characterAtIndex:NSMaxRange(range)] == '\n'; ++ ++ if (leftReserve > 0) { ++ if (atParagraphStart) { ++ NSRange paragraphRange = [plainString paragraphRangeForRange:NSMakeRange(range.location, 0)]; ++ NSParagraphStyle *existing = [nsAttributedString attribute:NSParagraphStyleAttributeName ++ atIndex:range.location ++ effectiveRange:NULL]; ++ NSMutableParagraphStyle *paragraphStyle = ++ existing ? [existing mutableCopy] : [NSMutableParagraphStyle new]; ++ paragraphStyle.firstLineHeadIndent = MAX(paragraphStyle.firstLineHeadIndent, leftReserve); ++ [nsAttributedString addAttribute:NSParagraphStyleAttributeName ++ value:paragraphStyle ++ range:paragraphRange]; ++ } else { ++ const NSUInteger prevIndex = range.location - 1; ++ NSNumber *existingKern = [nsAttributedString attribute:NSKernAttributeName ++ atIndex:prevIndex ++ effectiveRange:NULL]; ++ const CGFloat kern = (existingKern ? [existingKern doubleValue] : 0.0) + leftReserve; ++ [nsAttributedString addAttribute:NSKernAttributeName value:@(kern) range:NSMakeRange(prevIndex, 1)]; ++ } ++ } ++ ++ if (rightReserve > 0 && !atParagraphEnd) { ++ const NSUInteger lastIndex = NSMaxRange(range) - 1; ++ NSNumber *existingKern = [nsAttributedString attribute:NSKernAttributeName ++ atIndex:lastIndex ++ effectiveRange:NULL]; ++ const CGFloat kern = (existingKern ? [existingKern doubleValue] : 0.0) + rightReserve; ++ [nsAttributedString addAttribute:NSKernAttributeName value:@(kern) range:NSMakeRange(lastIndex, 1)]; ++ } ++ }]; ++ + [nsAttributedString endEditing]; + + return nsAttributedString; +diff --git a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +index f4b7d39cc7a..60ca85ef2a6 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm ++++ b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm +@@ -375,6 +375,48 @@ static NSLineBreakMode RCTNSLineBreakModeFromEllipsizeMode(EllipsizeMode ellipsi + + if (textDidWrap) { + size.width = textContainer.size.width; ++ } else { ++ // Make the line long enough to fit the advance reserved around bordered / inline-code spans, so ++ // neither the border nor trailing content (e.g. the "(edited)" suffix) is clipped. usedRect ++ // already includes mid-line kerns (RCTAttributedTextUtils), but NOT the firstLineHeadIndent ++ // offset (it lives in usedRect.origin, not .size) nor the paragraph-end right border (usedRect is ++ // glyph-tight). Reserve exactly those two: ++ // - paragraph-start span: 2x borderWidth (the firstLineHeadIndent that shifts the line right), ++ // - paragraph-end span: 3x borderWidth (right border reaches 2x past the glyph + a margin). ++ // Surplus is empty space at the line end (invisible). ++ NSString *string = textStorage.string; ++ NSUInteger length = string.length; ++ __block CGFloat reservation = 0; ++ [textStorage enumerateAttribute:RCTTextBorderRadiusAttributeName ++ inRange:NSMakeRange(0, length) ++ options:0 ++ usingBlock:^(id value, NSRange range, __unused BOOL *stop) { ++ if (![value isKindOfClass:[NSArray class]] || range.length == 0) { ++ return; ++ } ++ NSArray *attrs = (NSArray *)value; ++ if (attrs.count < 15) { ++ return; ++ } ++ const CGFloat rightW = [attrs[10] floatValue]; ++ const CGFloat leftW = [attrs[12] floatValue]; ++ const CGFloat leftPad = [attrs[13] floatValue]; ++ const CGFloat rightPad = [attrs[14] floatValue]; ++ const BOOL atParagraphStart = ++ range.location == 0 || [string characterAtIndex:range.location - 1] == '\n'; ++ const BOOL atParagraphEnd = ++ NSMaxRange(range) >= length || [string characterAtIndex:NSMaxRange(range)] == '\n'; ++ if ((leftW > 0 || leftPad > 0) && atParagraphStart) { ++ // firstLineHeadIndent (excluded from usedRect width) = inner padding + border outer. ++ reservation += leftPad + leftW; ++ } ++ if ((rightW > 0 || rightPad > 0) && atParagraphEnd) { ++ // Right border outer reaches (padding + border) past the last glyph; +border margin so the ++ // glyph-tight usedRect doesn't clip it. ++ reservation += rightPad + rightW * 2.0; ++ } ++ }]; ++ size.width += reservation; + } + + if (paragraphAttributes.maximumNumberOfLines != 0) { +diff --git a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManagerWithBorderRadius.mm b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManagerWithBorderRadius.mm +index c2320fe9792..4faca47e9b9 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManagerWithBorderRadius.mm ++++ b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManagerWithBorderRadius.mm +@@ -9,8 +9,70 @@ + + #import "RCTAttributedTextUtils.h" + ++@interface RCTTextLayoutManagerWithBorderRadius () ++@end ++ + @implementation RCTTextLayoutManagerWithBorderRadius + ++- (instancetype)init ++{ ++ if (self = [super init]) { ++ self.delegate = self; ++ } ++ return self; ++} ++ ++// Indent a visual line that BEGINS with a bordered / inline-code span so its left border has room. ++// firstLineHeadIndent (RCTAttributedTextUtils) only covers a paragraph's first line; this covers the ++// remaining case the user hit: a span that soft-wraps to the start of a later line. We only act on ++// soft-wrapped line starts (index 0 and post-newline paragraph starts are left to firstLineHeadIndent ++// so the two mechanisms never double-indent the same line). ++- (BOOL)layoutManager:(NSLayoutManager *)layoutManager ++ shouldSetLineFragmentRect:(inout CGRect *)lineFragmentRect ++ lineFragmentUsedRect:(inout CGRect *)lineFragmentUsedRect ++ baselineOffset:(inout CGFloat *)baselineOffset ++ inTextContainer:(NSTextContainer *)textContainer ++ forGlyphRange:(NSRange)glyphRange ++{ ++ NSTextStorage *textStorage = layoutManager.textStorage; ++ if (textStorage.length == 0) { ++ return NO; ++ } ++ NSUInteger charIndex = [layoutManager characterIndexForGlyphAtIndex:glyphRange.location]; ++ // index 0 / post-newline = paragraph start → handled by firstLineHeadIndent. ++ if (charIndex == 0 || charIndex >= textStorage.length || ++ [textStorage.string characterAtIndex:charIndex - 1] == '\n') { ++ return NO; ++ } ++ NSRange effectiveRange; ++ id value = [textStorage attribute:RCTTextBorderRadiusAttributeName ++ atIndex:charIndex ++ effectiveRange:&effectiveRange]; ++ // Only when a bordered span starts exactly at this line's first character. ++ if (![value isKindOfClass:[NSArray class]] || effectiveRange.location != charIndex) { ++ return NO; ++ } ++ NSArray *attrs = (NSArray *)value; ++ if (attrs.count < 15) { ++ return NO; ++ } ++ const CGFloat leftW = [attrs[12] floatValue]; ++ const CGFloat leftPad = [attrs[13] floatValue]; ++ if (leftW <= 0 && leftPad <= 0) { ++ return NO; ++ } ++ // Reserve inner padding + border outer at this soft-wrapped line's start. ++ const CGFloat indent = leftPad + leftW; ++ CGRect frag = *lineFragmentRect; ++ CGRect used = *lineFragmentUsedRect; ++ frag.origin.x += indent; ++ frag.size.width = MAX(0, frag.size.width - indent); ++ used.origin.x += indent; ++ *lineFragmentRect = frag; ++ *lineFragmentUsedRect = used; ++ return YES; ++} ++ + - (void)drawBackgroundForGlyphRange:(NSRange)glyphsToShow atPoint:(CGPoint)origin + { + // Let super handle all spans that use NSBackgroundColorAttributeName (no border radius). +@@ -28,7 +90,7 @@ + + // attrs: @[bgColor, tlR, trR, blR, brR, + // topBorderColor, rightBorderColor, bottomBorderColor, leftBorderColor, +- // topW, rightW, bottomW, leftW] ++ // topW, rightW, bottomW, leftW, paddingLeft, paddingRight] + NSArray *attrs = (NSArray *)value; + UIColor *bgColor = attrs[0]; + CGFloat tl = [attrs[1] floatValue]; +@@ -49,12 +111,18 @@ + CGFloat rightW = [attrs[10] floatValue]; + CGFloat bottomW = [attrs[11] floatValue]; + CGFloat leftW = [attrs[12] floatValue]; ++ CGFloat leftPad = attrs.count > 13 ? [attrs[13] floatValue] : 0; ++ CGFloat rightPad = attrs.count > 14 ? [attrs[14] floatValue] : 0; + + BOOL hasBorder = (topBorderColor && topW > 0) || (rightBorderColor && rightW > 0) || + (bottomBorderColor && bottomW > 0) || (leftBorderColor && leftW > 0); + + NSRange spanGlyphRange = [self glyphRangeForCharacterRange:attrRange actualCharacterRange:nil]; + ++ NSString *storageString = self.textStorage.string; ++ const BOOL spanAtParagraphEnd = NSMaxRange(attrRange) >= storageString.length || ++ [storageString characterAtIndex:NSMaxRange(attrRange)] == '\n'; ++ + __block NSUInteger totalLines = 0; + [self enumerateLineFragmentsForGlyphRange:spanGlyphRange + usingBlock:^(CGRect r, CGRect u, NSTextContainer *tc, NSRange gr, BOOL *s) { +@@ -91,6 +159,16 @@ + ? [self locationForGlyphAtIndex:endGlyph].x // start of next glyph = tight right edge + : CGRectGetMaxX(usedRect) - lineRect.origin.x; // span runs to line end → trailing-ws-trimmed edge + ++ // Mid-paragraph, RCTAttributedTextUtils kerns the span's last glyph by (padding + rightW) to ++ // reserve room for the inner padding + right border; that shifts the *next* glyph and inflates ++ // endX. Undo it on the span's last line so the fill still hugs the glyphs (the padding + centered ++ // border then sit in the reserved space). Line/paragraph ends don't kern (trailing kern is ++ // trimmed there; measure handles them). ++ if (endGlyph < NSMaxRange(lineGlyphRange) && endGlyph == NSMaxRange(spanGlyphRange) && ++ (rightW > 0 || rightPad > 0) && !spanAtParagraphEnd) { ++ endX -= (rightPad + rightW); ++ } ++ + CGRect spanRect; + spanRect.origin.x = lineRect.origin.x + startLoc.x + origin.x; + spanRect.size.width = endX - startLoc.x; +@@ -116,6 +194,18 @@ + CGFloat effectiveTR = isLast ? tr : 0; + CGFloat effectiveBR = isLast ? br : 0; + ++ // Extend the fill box by (padding + half the border) into the reserved advance so the centered ++ // stroke leaves a borderWidth gap between the glyphs and the border, with its outer edge at the ++ // reserved frame edge instead of being clipped. ++ if (isFirst && (leftW > 0 || leftPad > 0)) { ++ const CGFloat ext = leftPad + leftW / 2.0; ++ spanRect.origin.x -= ext; ++ spanRect.size.width += ext; ++ } ++ if (isLast && (rightW > 0 || rightPad > 0)) { ++ spanRect.size.width += rightPad + rightW / 2.0; ++ } ++ + CGFloat x = CGRectGetMinX(spanRect); + CGFloat y = CGRectGetMinY(spanRect); + CGFloat w = CGRectGetWidth(spanRect); +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt +index 86dd1fc7386..2be0a2fd350 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextAttributeProps.kt +@@ -127,6 +127,14 @@ public class TextAttributeProps private constructor() { + public var borderLeftWidth: Float = Float.NaN + private set + ++ // Inner padding between the border and the text (resolved on the C++ side from ++ // paddingLeft/paddingRight, falling back to paddingHorizontal). NaN = unset (0). ++ public var paddingLeft: Float = Float.NaN ++ private set ++ ++ public var paddingRight: Float = Float.NaN ++ private set ++ + public var opacity: Float = Float.NaN + private set + +@@ -447,6 +455,8 @@ public class TextAttributeProps private constructor() { + public const val TA_KEY_BORDER_RIGHT_WIDTH: Int = 39 + public const val TA_KEY_BORDER_BOTTOM_WIDTH: Int = 40 + public const val TA_KEY_BORDER_LEFT_WIDTH: Int = 41 ++ public const val TA_KEY_PADDING_LEFT: Int = 42 ++ public const val TA_KEY_PADDING_RIGHT: Int = 43 + + public const val UNSET: Int = -1 + +@@ -521,6 +531,8 @@ public class TextAttributeProps private constructor() { + result.borderBottomWidth = entry.doubleValue.toFloat() + TA_KEY_BORDER_LEFT_WIDTH -> + result.borderLeftWidth = entry.doubleValue.toFloat() ++ TA_KEY_PADDING_LEFT -> result.paddingLeft = entry.doubleValue.toFloat() ++ TA_KEY_PADDING_RIGHT -> result.paddingRight = entry.doubleValue.toFloat() + } + } + +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +index e3f19473554..55c4e22aeab 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +@@ -8,7 +8,9 @@ + package com.facebook.react.views.text + + import android.content.Context ++import android.graphics.Canvas + import android.graphics.Color ++import android.graphics.Paint + import android.graphics.Typeface + import android.os.Build + import android.text.BoringLayout +@@ -21,6 +23,8 @@ import android.text.StaticLayout + import android.text.TextDirectionHeuristics + import android.text.TextPaint + import android.text.TextUtils ++import android.text.style.LeadingMarginSpan ++import android.text.style.ReplacementSpan + import android.util.LayoutDirection + import android.view.Gravity + import android.view.View +@@ -46,6 +50,7 @@ import com.facebook.react.views.text.internal.span.ReactForegroundColorSpan + import com.facebook.react.views.text.internal.span.ReactFragmentIndexSpan + import com.facebook.react.views.text.internal.span.ReactLinkSpan + import com.facebook.react.views.text.internal.span.ReactOpacitySpan ++import com.facebook.react.views.text.internal.span.ReactSpan + import com.facebook.react.views.text.internal.span.ReactStrikethroughSpan + import com.facebook.react.views.text.internal.span.ReactTagSpan + import com.facebook.react.views.text.internal.span.ReactTextPaintHolderSpan +@@ -61,6 +66,35 @@ import kotlin.math.floor + import kotlin.math.max + import kotlin.math.min + ++// A LeadingMarginSpan that is also a ReactSpan, so it can be added via SetSpanOperation (whose `what` ++// is typed ReactSpan). Used to reserve leading room for a bordered / inline-code span at a line start. ++// Standard(margin, 0): indent only the first line of the paragraph (rest = 0). ++private class ReactLeadingMarginSpan(margin: Int) : LeadingMarginSpan.Standard(margin, 0), ReactSpan ++ ++// A zero-content ReplacementSpan that reserves horizontal advance (real space, so neighbours move) ++// around a bordered / inline-code span — Android has no per-character kern. Draws nothing. ++private class ReactInlinePaddingSpan(private val spacerWidth: Int) : ReplacementSpan(), ReactSpan { ++ override fun getSize( ++ paint: Paint, ++ text: CharSequence?, ++ start: Int, ++ end: Int, ++ fm: Paint.FontMetricsInt?, ++ ): Int = spacerWidth ++ ++ override fun draw( ++ canvas: Canvas, ++ text: CharSequence?, ++ start: Int, ++ end: Int, ++ x: Float, ++ top: Int, ++ y: Int, ++ bottom: Int, ++ paint: Paint, ++ ) {} ++} ++ + /** Class responsible of creating [Spanned] object for the JS representation of Text */ + internal object TextLayoutManager { + +@@ -226,11 +260,56 @@ internal object TextLayoutManager { + ) { + for (i in 0 until fragments.count) { + val fragment = fragments.getMapBuffer(i) +- val start = sb.length + + val textAttributes = + TextAttributeProps.fromMapBuffer(fragment.getMapBuffer(FR_KEY_TEXT_ATTRIBUTES)) + ++ // Reserve real advance (so neighbours move) around a bordered / inline-code span by inserting a ++ // zero-content spacer before and after it; Android has no per-character kern. The reserved ++ // advance per side = inner padding + border, into which ReactBackgroundDrawSpan draws (box ++ // extended by padding + half the centered stroke). ++ val leadingBorderDip = textAttributes.borderLeftWidth.takeIf { !it.isNaN() } ?: 0f ++ val trailingBorderDip = textAttributes.borderRightWidth.takeIf { !it.isNaN() } ?: 0f ++ val leadingPaddingDip = textAttributes.paddingLeft.takeIf { !it.isNaN() } ?: 0f ++ val trailingPaddingDip = textAttributes.paddingRight.takeIf { !it.isNaN() } ?: 0f ++ val leadingReserveDip = leadingPaddingDip + leadingBorderDip ++ val trailingReserveDip = trailingPaddingDip + trailingBorderDip ++ // Only reserve advance when this fragment is actually drawn with a custom bordered / background ++ // span (ReactBackgroundDrawSpan) whose fill box is extended by the padding below. Otherwise a ++ // plain padded (paddingHorizontal with no background/border) would get invisible spacer ++ // advance on top of its normal view padding, shifting/widening its text. This mirrors the span ++ // selection condition further down. ++ val hasBorderRadius = !textAttributes.borderTopLeftRadius.isNaN() || ++ !textAttributes.borderTopRightRadius.isNaN() || ++ !textAttributes.borderBottomLeftRadius.isNaN() || ++ !textAttributes.borderBottomRightRadius.isNaN() ++ val hasBorderWidth = !textAttributes.borderTopWidth.isNaN() || ++ !textAttributes.borderRightWidth.isNaN() || ++ !textAttributes.borderBottomWidth.isNaN() || ++ !textAttributes.borderLeftWidth.isNaN() ++ val hasBorderColor = textAttributes.isBorderTopColorSet || ++ textAttributes.isBorderRightColorSet || ++ textAttributes.isBorderBottomColorSet || ++ textAttributes.isBorderLeftColorSet ++ val drawsCustomBackground = (textAttributes.isBackgroundColorSet || hasBorderColor) && ++ (hasBorderRadius || hasBorderWidth || hasBorderColor) ++ if (drawsCustomBackground && leadingReserveDip > 0f) { ++ val spacerStart = sb.length ++ sb.append("\u2060") ++ ops.add( ++ SetSpanOperation( ++ spacerStart, ++ sb.length, ++ ReactInlinePaddingSpan(PixelUtil.toPixelFromDIP(leadingReserveDip).toInt()), ++ ) ++ ) ++ // Glue the spacer to the code with a plain WORD JOINER so a line break can't orphan it on the ++ // previous line (leaving a stray box fragment) when the code wraps; they wrap together. ++ sb.append("\u2060") ++ } ++ ++ val start = sb.length ++ + sb.append( + TextTransform.apply(fragment.getString(FR_KEY_STRING), textAttributes.textTransform) + ) +@@ -299,6 +378,8 @@ internal object TextLayoutManager { + PixelUtil.toPixelFromDIP(textAttributes.borderRightWidth.takeIf { r -> !r.isNaN() } ?: 0f), + PixelUtil.toPixelFromDIP(textAttributes.borderBottomWidth.takeIf { r -> !r.isNaN() } ?: 0f), + PixelUtil.toPixelFromDIP(textAttributes.borderLeftWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(textAttributes.paddingLeft.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(textAttributes.paddingRight.takeIf { r -> !r.isNaN() } ?: 0f), + ) + } else if (textAttributes.isBackgroundColorSet) { + textAttributes.backgroundColor?.let { ReactBackgroundColorSpan(it) } +@@ -373,6 +454,18 @@ internal object TextLayoutManager { + ops.add(SetSpanOperation(start, end, ReactTagSpan(reactTag))) + } + } ++ ++ if (drawsCustomBackground && trailingReserveDip > 0f) { ++ val spacerStart = sb.length ++ sb.append("\u2060") ++ ops.add( ++ SetSpanOperation( ++ spacerStart, ++ sb.length, ++ ReactInlinePaddingSpan(PixelUtil.toPixelFromDIP(trailingReserveDip).toInt()), ++ ) ++ ) ++ } + } + } + +@@ -502,6 +595,8 @@ internal object TextLayoutManager { + PixelUtil.toPixelFromDIP(fragment.props.borderRightWidth.takeIf { r -> !r.isNaN() } ?: 0f), + PixelUtil.toPixelFromDIP(fragment.props.borderBottomWidth.takeIf { r -> !r.isNaN() } ?: 0f), + PixelUtil.toPixelFromDIP(fragment.props.borderLeftWidth.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(fragment.props.paddingLeft.takeIf { r -> !r.isNaN() } ?: 0f), ++ PixelUtil.toPixelFromDIP(fragment.props.paddingRight.takeIf { r -> !r.isNaN() } ?: 0f), + ) + } else if (fragment.props.isBackgroundColorSet) { + fragment.props.backgroundColor?.let { ReactBackgroundColorSpan(it) } +@@ -509,6 +604,20 @@ internal object TextLayoutManager { + null + } + spannable.setSpan(bgSpan, start, end, spanFlags) ++ ++ // Reserve leading advance (inner padding + border) so a bordered span that begins a line ++ // has room for its left border + padding; first line only. ++ val leadingBorderDip = fragment.props.borderLeftWidth.takeIf { !it.isNaN() } ?: 0f ++ val leadingPaddingDip = fragment.props.paddingLeft.takeIf { !it.isNaN() } ?: 0f ++ val leadingReserveDip = leadingPaddingDip + leadingBorderDip ++ if (leadingReserveDip > 0f && (start == 0 || spannable[start - 1] == '\n')) { ++ spannable.setSpan( ++ ReactLeadingMarginSpan(PixelUtil.toPixelFromDIP(leadingReserveDip).toInt()), ++ start, ++ end, ++ spanFlags, ++ ) ++ } + } + + if (!fragment.props.opacity.isNaN()) { +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactBackgroundDrawSpan.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactBackgroundDrawSpan.kt +index 80f8d389475..5c188dc9330 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactBackgroundDrawSpan.kt ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/ReactBackgroundDrawSpan.kt +@@ -38,6 +38,9 @@ internal class ReactBackgroundDrawSpan( + private val borderRightWidth: Float, + private val borderBottomWidth: Float, + private val borderLeftWidth: Float, ++ // Inner padding between the border and the text, per side (px). ++ private val paddingLeft: Float = 0f, ++ private val paddingRight: Float = 0f, + ) : DrawCommandSpan() { + + private val fillPaint = +@@ -99,6 +102,16 @@ internal class ReactBackgroundDrawSpan( + val isFirstLine = line == startLine + val isLastLine = line == endLine + ++ // Extend the fill box by (inner padding + half the centered stroke) so the border leaves a ++ // `padding`-wide gap between the glyphs and the border, with the stroke's outer edge landing ++ // on the reserved spacer advance (inserted in TextLayoutManager). ++ if (isFirstLine && (borderLeftWidth > 0f || paddingLeft > 0f)) { ++ rectF.left -= paddingLeft + borderLeftWidth * 0.5f ++ } ++ if (isLastLine && (borderRightWidth > 0f || paddingRight > 0f)) { ++ rectF.right += paddingRight + borderRightWidth * 0.5f ++ } ++ + drawFill(canvas, rectF, isFirstLine, isLastLine) + drawBorder(canvas, rectF, isFirstLine, isLastLine) + } +diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp +index 5a84c014434..87af2566b04 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/BaseTextInputProps.cpp +@@ -246,6 +246,8 @@ TextAttributes BaseTextInputProps::getEffectiveTextAttributes( + */ + result.backgroundColor = clearColor(); + result.opacity = 1; ++ result.paddingLeft = {}; ++ result.paddingRight = {}; + + return result; + } +diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp +index 301bde7418c..a5677ac096d 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/platform/android/react/renderer/components/androidtextinput/AndroidTextInputShadowNode.cpp +@@ -158,6 +158,10 @@ AttributedString AndroidTextInputShadowNode::getAttributedString( + // string. Android tries to render shadow of the background alongside the + // shadow of the text which results in weird artifacts. + childTextAttributes.backgroundColor = HostPlatformColor::UndefinedColor; ++ // Don't propagate the TextInput's view-level padding as text attributes -- ++ // they would cause spacer characters to be inserted for every fragment. ++ childTextAttributes.paddingLeft = {}; ++ childTextAttributes.paddingRight = {}; + + auto attributedString = AttributedString{}; + auto attachments = BaseTextShadowNode::Attachments{}; +@@ -171,6 +175,8 @@ AttributedString AndroidTextInputShadowNode::getAttributedString( + auto textAttributes = TextAttributes::defaultTextAttributes(); + textAttributes.apply(getConcreteProps().textAttributes); + textAttributes.fontSizeMultiplier = layoutContext.fontSizeMultiplier; ++ textAttributes.paddingLeft = {}; ++ textAttributes.paddingRight = {}; + auto fragment = AttributedString::Fragment{}; + fragment.string = getConcreteProps().text; + fragment.textAttributes = textAttributes; diff --git a/scripts/bumpVersion.ts b/scripts/bumpVersion.ts index 8ad130b7eb2f..7c0f082823b5 100755 --- a/scripts/bumpVersion.ts +++ b/scripts/bumpVersion.ts @@ -6,6 +6,7 @@ import type {SemVer} from 'semver'; import type {PackageJson} from 'type-fest'; import {execSync, exec as originalExec} from 'child_process'; +import CLI from 'expensify-common/CLI'; import {promises as fs} from 'fs'; import path from 'path'; import getMajorVersion from 'semver/functions/major'; @@ -209,12 +210,31 @@ async function run(semanticVersionLevel: SemverLevel) { } if (require.main === module) { - // Get and validate SEMVER_LEVEL input - const semanticVersionLevel = process.argv.at(2) ?? 'BUILD'; - if (!versionUpdater.isValidSemverLevel(semanticVersionLevel)) { - throw new Error(`Invalid semver level ${semanticVersionLevel}. Must be one of: ${Object.values(versionUpdater.SEMANTIC_VERSION_LEVELS).join(', ')}`); - } - run(semanticVersionLevel); + const semverLevelOptions = Object.values(versionUpdater.SEMANTIC_VERSION_LEVELS).join(', '); + const cli = new CLI({ + positionalArgs: [ + { + name: 'semverLevel', + description: `Semantic version level to bump (${semverLevelOptions})`, + default: versionUpdater.SEMANTIC_VERSION_LEVELS.BUILD, + parse: (val) => { + if (!versionUpdater.isValidSemverLevel(val)) { + throw new Error(`Invalid semver level. Must be one of: ${semverLevelOptions}`); + } + return val; + }, + }, + ], + }); + + run(cli.positionalArgs.semverLevel).catch((error: unknown) => { + if (error instanceof Error) { + console.error(error.message); + } else { + console.error('An unexpected error occurred.'); + } + process.exit(1); + }); } export default run; diff --git a/scripts/createRetestRequestForCP.ts b/scripts/createRetestRequestForCP.ts new file mode 100644 index 000000000000..3576a3f5b6dc --- /dev/null +++ b/scripts/createRetestRequestForCP.ts @@ -0,0 +1,265 @@ +#!/usr/bin/env bun +import CONST from '@github/libs/CONST'; +import {getDeployChecklist, NoOpenDeployChecklistError} from '@github/libs/DeployChecklistUtils'; +import GithubUtils from '@github/libs/GithubUtils'; + +import CLI from 'expensify-common/CLI'; + +// GitHub REST API request fields are snake_case (per_page, commit_sha, pull_number), which this rule would otherwise flag. +/* eslint-disable @typescript-eslint/naming-convention */ + +// Tags come back paginated; the request size and the "last page" check must stay in sync. +const TAGS_PER_PAGE = 100; + +/** + * When a deploy-blocker fix is cherry-picked to staging, QA needs to retest the blocker. + * The deployer used to file that retest request in Slack by hand. This script does it for them. + * + * It fires only when all three are true: + * 1. The deploy was triggered by a cherry-pick to staging. + * 2. The cherry-picked PR is on the current StagingDeployCash checklist. + * 3. An issue linked in that PR's body is listed as a deploy blocker on that same checklist. + */ + +// Slack workflow webhook trigger. Empty values are rejected by Slack, so blanks are sent as this instead. +const EMPTY = 'N/A'; + +// Marker left on a PR after we file its retest request, so a re-run of the deploy doesn't file a duplicate. +const getRetestMarker = (tag: string) => ``; + +type RetestHit = { + prNumber: number; + prURL: string; + prAuthor: string; + // A single PR can fix more than one deploy blocker, so all of them go in one retest request. + blockerIssueURLs: string[]; + prTitle: string; +}; + +/** + * List the commit messages deployed since the previous staging release. + * We look at the whole range, not just HEAD, because a cherry-pick pushes several commits + * (version bumps plus the actual picked commit) and the picked commit isn't always HEAD. + */ +async function getDeployedCommitMessages(deploySHA: string, deployTag: string): Promise { + let previousStagingTag: string | null = null; + for (let page = 1; !previousStagingTag; page++) { + const {data: tags} = await GithubUtils.octokit.repos.listTags({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + per_page: TAGS_PER_PAGE, + page, + }); + if (tags.length === 0) { + break; + } + previousStagingTag = tags.find((tag) => tag.name !== deployTag && tag.name.endsWith('-staging'))?.name ?? null; + if (tags.length < TAGS_PER_PAGE) { + break; + } + } + + if (!previousStagingTag) { + const {data: headCommit} = await GithubUtils.octokit.git.getCommit({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + commit_sha: deploySHA, + }); + return [headCommit.message]; + } + + const {data: comparison} = await GithubUtils.octokit.repos.compareCommits({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + base: previousStagingTag, + head: deploySHA, + }); + return comparison.commits.map((commit) => commit.commit.message); +} + +/** + * `git cherry-pick -x` records `(cherry picked from commit )` in each new commit. + * For the picked PR, that source SHA is the PR's original merge commit on main, + * so we can resolve it back to the original pull request. + */ +function getCherryPickSourceSHAs(commitMessages: string[]): string[] { + const shas = new Set(); + for (const message of commitMessages) { + for (const match of message.matchAll(/cherry picked from commit ([0-9a-f]{7,40})/g)) { + shas.add(match[1]); + } + } + return [...shas]; +} + +/** Resolve the original App PRs that produced the given merge commits. */ +async function getPullRequestsForSHAs(shas: string[]): Promise { + const prNumbers = new Set(); + for (const sha of shas) { + const {data: pulls} = await GithubUtils.octokit.repos.listPullRequestsAssociatedWithCommit({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + commit_sha: sha, + }); + for (const pull of pulls) { + prNumbers.add(pull.number); + } + } + return [...prNumbers]; +} + +/** Pull every App issue number linked anywhere in a PR body. */ +function getLinkedIssueNumbers(prBody: string | null): number[] { + if (!prBody) { + return []; + } + const issueNumbers = new Set(); + const issueURLRegex = new RegExp(`${CONST.APP_REPO_URL}/issues/(\\d+)`, 'g'); + for (const match of prBody.matchAll(issueURLRegex)) { + issueNumbers.add(Number.parseInt(match[1], 10)); + } + return [...issueNumbers]; +} + +/** Has a retest request for this staging deploy already been filed on this PR? */ +async function isRetestAlreadyRequested(prNumber: number, deployTag: string): Promise { + const comments = await GithubUtils.getAllComments(prNumber); + const marker = getRetestMarker(deployTag); + return comments.some((comment) => comment?.includes(marker)); +} + +/** Map a hit to the flat string payload the Slack workflow webhook expects. */ +function buildRetestPayload(hit: RetestHit): Record { + return { + isDb: 'dbTrue', + whereToRetest: 'Staging', + notes: `Auto-filed after cherry-pick to staging: "${hit.prTitle}"`, + ghIssueLink: hit.blockerIssueURLs.join(' '), + adhocLink: EMPTY, + requesterName: hit.prAuthor || EMPTY, + cpLink: hit.prURL, + platforms: 'Android, iOS, Web', + }; +} + +/** POST the retest request to the Slack workflow webhook. */ +async function fireRetestRequest(hit: RetestHit, webhookURL: string): Promise { + const payload = buildRetestPayload(hit); + + const response = await fetch(webhookURL, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`Slack webhook returned ${response.status} ${response.statusText}: ${await response.text()}`); + } +} + +async function run(): Promise { + // Gate 1 (this deploy is a cherry-pick to staging, on all platforms) is enforced by the job's `if:` in deploy.yml. + const cli = new CLI({ + namedArgs: { + 'deploy-sha': {description: 'The deploy commit SHA on the staging branch', required: true}, + 'deploy-tag': {description: 'The staging release tag for this deploy', required: true}, + }, + } as const); + const deploySHA = cli.namedArgs['deploy-sha']; + const deployTag = cli.namedArgs['deploy-tag']; + + // The webhook stays in the env, not a CLI arg: it's a secret, and argv is visible via `ps` and can leak into logs. + const webhookURL = process.env.SLACK_RETEST_WEBHOOK; + if (!webhookURL) { + throw new Error('SLACK_RETEST_WEBHOOK is required'); + } + + const commitMessages = await getDeployedCommitMessages(deploySHA, deployTag); + const sourceSHAs = getCherryPickSourceSHAs(commitMessages); + if (sourceSHAs.length === 0) { + console.log('No cherry-pick source commits found in this deploy, nothing to do.'); + return; + } + + const candidatePRNumbers = await getPullRequestsForSHAs(sourceSHAs); + if (candidatePRNumbers.length === 0) { + console.log('No original PRs resolved from cherry-pick source commits, nothing to do.'); + return; + } + + let checklist; + try { + checklist = await getDeployChecklist(); + } catch (error) { + if (error instanceof NoOpenDeployChecklistError) { + console.log('No open deploy checklist, nothing to retest against.'); + return; + } + throw error; + } + + const checklistPRNumbers = new Set(checklist.PRList.map((item) => item.number)); + const blockerIssueByNumber = new Map(checklist.deployBlockers.map((item) => [item.number, item.url])); + + const hits: RetestHit[] = []; + for (const prNumber of candidatePRNumbers) { + // Gate 2: the cherry-picked PR must be on the current checklist. + if (!checklistPRNumbers.has(prNumber)) { + continue; + } + + const {data: pull} = await GithubUtils.octokit.pulls.get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: prNumber, + }); + + // Gate 3: the PR body must link at least one deploy blocker on the checklist. + // A PR can fix several blockers, so collect every match into one request. + const blockerIssueURLs = getLinkedIssueNumbers(pull.body) + .filter((issueNumber) => blockerIssueByNumber.has(issueNumber)) + .map((issueNumber) => blockerIssueByNumber.get(issueNumber) ?? ''); + if (blockerIssueURLs.length === 0) { + continue; + } + + if (await isRetestAlreadyRequested(prNumber, deployTag)) { + console.log(`Retest for PR #${prNumber} on ${deployTag} was already filed, skipping.`); + continue; + } + + hits.push({ + prNumber, + prURL: pull.html_url, + prAuthor: pull.user?.login ?? '', + blockerIssueURLs, + prTitle: pull.title, + }); + } + + if (hits.length === 0) { + console.log('No cherry-picked deploy-blocker fixes matched, nothing to file.'); + return; + } + + for (const hit of hits) { + await fireRetestRequest(hit, webhookURL); + const blockerList = hit.blockerIssueURLs.join(', '); + await GithubUtils.createComment( + CONST.APP_REPO, + hit.prNumber, + `${getRetestMarker(deployTag)}\n🔁 Filed a Staging retest request for deploy blockers ${blockerList} after this PR was cherry-picked to staging.`, + ); + console.log(`Filed retest request for PR #${hit.prNumber} (blockers ${blockerList}).`); + } +} + +if (require.main === module) { + run().catch((error: unknown) => { + console.error(error); + process.exit(1); + }); +} + +export default run; +export {getCherryPickSourceSHAs, getLinkedIssueNumbers, buildRetestPayload, getRetestMarker}; +export type {RetestHit}; diff --git a/scripts/utils/PromisePool.ts b/scripts/utils/PromisePool.ts index 6e608b53967b..4498256fd177 100644 --- a/scripts/utils/PromisePool.ts +++ b/scripts/utils/PromisePool.ts @@ -19,7 +19,9 @@ class PromisePool { * wait for one to finish before starting another. */ public async add(task: () => Promise): Promise { - if (this.executing.size >= this.concurrency) { + // Recheck after each wait: when many add() callers resume from the same + // Promise.race, only the first few should start; the rest must wait again. + while (this.executing.size >= this.concurrency) { await Promise.race(this.executing); } const p = task(); diff --git a/server/libs/log.ts b/server/libs/log.ts new file mode 100644 index 000000000000..fba0fcb4fe62 --- /dev/null +++ b/server/libs/log.ts @@ -0,0 +1,157 @@ +/* + * Rsyslog-backed logger for server-side CLI tools. Each tool constructs its own instance with + * metadata (process name, caller tag, etc.). Writes lines in the same format as Web-Expensify's + * Log.php for Victoria Logs correlation. + * + * Line shape: <6>{processName}: {REQUEST_ID} {scriptName} {email} !{sourceTag}! ?{callerTag}? [level] message ~~ key: 'value' + */ +import writeRsyslog from '@server/libs/rsyslogWriter'; + +type LogLevel = 'info' | 'hmmm' | 'warn' | 'alrt'; + +// Mirrors expensify-common's Logger#Parameters union (see Logger.d.ts) so callers can pass through +// whatever they'd otherwise pass to the real Log. +type LogParams = string | Record | Array> | Error | undefined; + +type LogConfig = { + /** Syslog identifier in the LOG_DIRECT_PREFIX (e.g. php-fpm, victory-chart-renderer). */ + processName: string; + + /** Mirrors Log.php's SCRIPT_NAME field (typically the same as processName for CLIs). */ + scriptName: string; + + /** Mirrors Log.php's source tag (e.g. script, api). */ + sourceTag: string; + + /** Mirrors Log.php's caller tag stack entry (e.g. vcr, Search). */ + callerTag: string; + + /** Mirrors Log.php's email field; empty for headless subprocesses with no user session. */ + email?: string; +}; + +// rsyslog caps messages at 8kb; Log.php leaves headroom below that for the prefix. +const MESSAGE_LIMIT_BYTES = 7168; +const MIN_CHUNK_BYTES = 10; +const TRUNCATED_REQUEST_ID_LENGTH = 10; + +const SENSITIVE_KEY_PATTERN = /token|password|secret|apikey|cookie/i; +const REDACTED = ''; + +class Log { + private readonly metadata: LogConfig; + + constructor(metadata: LogConfig) { + this.metadata = metadata; + } + + info(message: string, sendNow?: boolean, parameters?: LogParams): void { + this.write('info', message, parameters); + } + + alert(message: string, parameters?: LogParams): void { + this.write('alrt', message, parameters); + } + + warn(message: string, parameters?: LogParams): void { + this.write('warn', message, parameters); + } + + hmmm(message: string, parameters?: LogParams): void { + this.write('hmmm', message, parameters); + } + + private write(level: LogLevel, message: string, params?: LogParams): void { + const envRequestId = process.env.REQUEST_ID; + const requestId = envRequestId === undefined ? '' : envRequestId; + const lines = this.formatRsyslogLines({level, message, params, requestId}); + + for (const line of lines) { + writeRsyslog(line); + } + } + + private formatRsyslogLines({level, message, params, requestId}: {level: LogLevel; message: string; params?: LogParams; requestId: string}): string[] { + let prefix = this.buildPrefix(level, requestId); + let chunkSizeBytes = MESSAGE_LIMIT_BYTES - Buffer.byteLength(prefix, 'utf8'); + + if (chunkSizeBytes < MIN_CHUNK_BYTES) { + // Hail-mary shortening, mirroring Log.php's own fallback for when REQUEST_ID makes the + // prefix too long to leave any room for a message. + prefix = this.buildPrefix(level, requestId.slice(0, TRUNCATED_REQUEST_ID_LENGTH)); + chunkSizeBytes = Math.max(MESSAGE_LIMIT_BYTES - Buffer.byteLength(prefix, 'utf8'), MIN_CHUNK_BYTES); + } + + const body = `${message}${this.formatParamString(params)}`; + return Log.chunkMessageBytes(body, chunkSizeBytes).map((chunk) => `${prefix}${chunk}`); + } + + private buildPrefix(level: LogLevel, requestId: string): string { + const {processName, scriptName, sourceTag, callerTag, email} = this.metadata; + const logDirectPrefix = `<6>${processName}: `; + const emailField = email ?? ''; + return `${logDirectPrefix}${requestId} ${scriptName} ${emailField} !${sourceTag}! ?${callerTag}? [${level}] `; + } + + private formatParamString(params: LogParams | undefined): string { + if (params === undefined) { + return ''; + } + if (typeof params === 'string') { + return params.length > 0 ? ` ~~ ${params}` : ''; + } + if (params instanceof Error) { + return ` ~~ ${Log.stringifyParamValue(params)}`; + } + + const entries = Array.isArray(params) ? params.flatMap((entry) => Object.entries(entry)) : Object.entries(params); + return Log.formatParamEntries(entries); + } + + private static formatParamEntries(entries: Array<[string, unknown]>): string { + if (entries.length === 0) { + return ''; + } + + const pretty = entries.map(([key, value]) => `${key}: '${SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : Log.stringifyParamValue(value)}'`).join(' '); + return ` ~~ ${pretty}`; + } + + private static stringifyParamValue(value: unknown): string { + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (value instanceof Error) { + return value.stack ?? value.message; + } + if (typeof value === 'object') { + return JSON.stringify(value); + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' || typeof value === 'symbol') { + return value.toString(); + } + // Functions are the only remaining typeof case; Object.prototype.toString.call is the only + // stringification here that's guaranteed not to trigger @typescript-eslint/no-base-to-string. + return Object.prototype.toString.call(value); + } + + private static chunkMessageBytes(message: string, chunkSizeBytes: number): string[] { + const bytes = Buffer.from(message, 'utf8'); + + if (bytes.length === 0) { + return ['']; + } + + const chunks: string[] = []; + for (let offset = 0; offset < bytes.length; offset += chunkSizeBytes) { + chunks.push(bytes.subarray(offset, offset + chunkSizeBytes).toString('utf8')); + } + return chunks; + } +} + +export default Log; +export type {LogConfig, LogLevel, LogParams}; diff --git a/server/libs/rsyslogWriter.ts b/server/libs/rsyslogWriter.ts new file mode 100644 index 000000000000..8508bc1a78f1 --- /dev/null +++ b/server/libs/rsyslogWriter.ts @@ -0,0 +1,135 @@ +/* + * Writes pre-formatted log lines directly to rsyslog over the systemd-journald AF_UNIX SOCK_DGRAM + * socket, the same way Web-Expensify's Log.php (_writeRsyslog) and Bedrock's libstuff.cpp + * (SSyslogSocketDirect) do. + * + * Bun has no built-in AF_UNIX SOCK_DGRAM client, so we use bun:ffi to call libc's socket/sendto/close syscalls directly. + */ +import {dlopen, suffix} from 'bun:ffi'; + +const DEFAULT_SOCKET_PATH = '/run/systemd/journal/syslog'; + +// POSIX constants passed to socket() / sendto() +const AF_UNIX = 1; +const SOCK_DGRAM = 2; +// sendto() MSG_DONTWAIT: return immediately with EAGAIN if the send would block (e.g. syslog +// receive queue full). Linux: 0x40, Darwin: 0x80. Production targets Linux; macOS rarely has the +// journal socket. Named for the positive behavior rather than the POSIX macro (DONTWAIT) so we +// satisfy rulesdir/no-negated-variables. +const SENDTO_NONBLOCK = process.platform === 'darwin' ? 0x80 : 0x40; + +// Linux struct sockaddr_un layout: sa_family_t (2 bytes) + char sun_path[108]. We build this byte +// array manually because bun:ffi has no struct helper for the address type sendto() expects. +const SUN_PATH_MAX = 108; + +function shouldForceStderr(): boolean { + return process.env.VCR_LOG_DESTINATION === 'stderr'; +} + +function getSocketPath(): string { + return process.env.RSYSLOG_SOCKET_PATH ?? DEFAULT_SOCKET_PATH; +} + +// Load libc once and bind the three syscalls we need. dlopen returns callable wrappers that +// marshal JavaScript values (numbers, Buffers) into the C ABI and read back the return value. +function loadLibc() { + // libc.so.6 is the real runtime library on Linux ("libc.so" is normally only present via -dev + // packages); libc.dylib is a symlink to libSystem on macOS, so `libc.${suffix}` is fine there. + const libcPath = process.platform === 'linux' ? 'libc.so.6' : `libc.${suffix}`; + + return dlopen(libcPath, { + // int socket(int domain, int type, int protocol); + socket: {args: ['i32', 'i32', 'i32'], returns: 'i32'}, + // ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); + sendto: {args: ['i32', 'ptr', 'u64', 'i32', 'ptr', 'u32'], returns: 'i32'}, + // int close(int fd); + close: {args: ['i32'], returns: 'i32'}, + }); +} + +type Libc = ReturnType; + +let libc: Libc | undefined; +// socketFd === -1 means "not open or previously failed and closed". +let socketFd = -1; +let sockaddr: Uint8Array | undefined; + +function buildSockaddrUn(path: string): Uint8Array { + const pathBytes = Buffer.from(path, 'utf8'); + + if (pathBytes.length >= SUN_PATH_MAX) { + throw new Error(`Rsyslog socket path is too long for sockaddr_un: ${path}`); + } + + const addr = new Uint8Array(2 + SUN_PATH_MAX); + // First two bytes are the address family (AF_UNIX); the socket path follows at offset 2. + addr[0] = AF_UNIX; + addr.set(pathBytes, 2); + return addr; +} + +// Lazily open the socket on first log write. +// Returns false if dlopen, socket(), or path encoding fails (caller writes to stderr instead). +function ensureSocket(): boolean { + if (socketFd !== -1) { + return true; + } + + try { + libc ??= loadLibc(); + const fd = libc.symbols.socket(AF_UNIX, SOCK_DGRAM, 0); + + if (fd < 0) { + return false; + } + + socketFd = fd; + sockaddr = buildSockaddrUn(getSocketPath()); + return true; + } catch { + libc = undefined; + socketFd = -1; + sockaddr = undefined; + return false; + } +} + +/** + * Sends a single, already-formatted log line to rsyslog. Returns whether the socket send + * succeeded; on failure (or when `VCR_LOG_DESTINATION=stderr` is set) the line is written to + * stderr instead. + */ +function writeRsyslog(line: string): boolean { + if (shouldForceStderr() || !ensureSocket() || !sockaddr || !libc) { + process.stderr.write(`${line}\n`); + return false; + } + + const message = Buffer.from(line, 'utf8'); + // MSG_DONTWAIT so a full syslog receive queue cannot stall chart rendering; on EAGAIN (or any + // other send failure) fall back to stderr. Bun passes `message` as the buf pointer and + // `sockaddr` as dest_addr. + try { + const bytesSent = libc.symbols.sendto(socketFd, message, message.length, SENDTO_NONBLOCK, sockaddr, sockaddr.length); + + if (bytesSent < 0) { + // Keep the fd on backpressure (EAGAIN); only reset after a hard/FFI failure below so the + // next write can reopen. Temporary full queues should not thrash socket()/close(). + process.stderr.write(`${line}\n`); + return false; + } + + return true; + } catch { + try { + libc.symbols.close(socketFd); + } catch { + // Ignore close failures while already in the error path. + } + socketFd = -1; + process.stderr.write(`${line}\n`); + return false; + } +} + +export default writeRsyslog; diff --git a/server/stubs/expensify-log.ts b/server/stubs/expensify-log.ts index 8e89e091ed52..b4ac35ecf26c 100644 --- a/server/stubs/expensify-log.ts +++ b/server/stubs/expensify-log.ts @@ -1,9 +1,13 @@ +/* + * Headless no-op replacement for @libs/Log (see rnStubPlugin). Bundled App and Onyx code still + * call Log during CLI startup; intentional production logging for this binary lives in + * server/libs/log.ts (via each tool's `new Log({...})` instance) instead. + */ const Log = { - warn: () => {}, - hmmm: () => {}, info: () => {}, alert: () => {}, - error: () => {}, + warn: () => {}, + hmmm: () => {}, }; export default Log; diff --git a/server/victory-chart-renderer/src/cli.tsx b/server/victory-chart-renderer/src/cli.tsx index 26c8e8bff0ab..b901eb87591b 100644 --- a/server/victory-chart-renderer/src/cli.tsx +++ b/server/victory-chart-renderer/src/cli.tsx @@ -1,6 +1,7 @@ import CLI from 'expensify-common/CLI'; import loadChartFontsForCli from './loadChartFontsForCli'; +import log from './log'; import parseChartXml from './parseChartXml'; import renderChartToPng from './renderChartToPng'; import resolveCanvasSize from './resolveCanvasSize'; @@ -23,18 +24,49 @@ const cli = new CLI({ }, }); +const renderStartedAt = Date.now(); + try { const xmlString = cli.namedArgs['chart-xml']; const outPath = cli.namedArgs.out; + + log.info('Victory chart render started', true, { + outPath, + xmlLength: xmlString.length, + }); + const tnode = parseChartXml(xmlString); const canvasSize = resolveCanvasSize(tnode); const fonts = await loadChartFontsForCli(); + log.info('Victory chart render prepared', true, { + width: canvasSize.width, + height: canvasSize.height, + hasFontManager: fonts.fontManager !== null, + }); + await renderChartToPng(tnode, fonts, canvasSize, outPath); + log.info('Victory chart rendered successfully', true, { + outPath, + width: canvasSize.width, + height: canvasSize.height, + durationMs: Date.now() - renderStartedAt, + }); + // Onyx and network modules register listeners/timers during init; exit explicitly so CI smoke tests do not hang. process.exit(0); } catch (error) { - console.error(error instanceof Error ? error.message : error); + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + + // Always surface a plain-text error on stderr for local/CLI visibility, independent of + // whether the rsyslog socket is reachable in this environment. + console.error(message); + log.alert('Victory chart render failed', { + message, + stack, + durationMs: Date.now() - renderStartedAt, + }); process.exit(1); } diff --git a/server/victory-chart-renderer/src/loadChartFontsForCli.ts b/server/victory-chart-renderer/src/loadChartFontsForCli.ts index 4ec198278996..23c1228c7755 100644 --- a/server/victory-chart-renderer/src/loadChartFontsForCli.ts +++ b/server/victory-chart-renderer/src/loadChartFontsForCli.ts @@ -3,13 +3,14 @@ import buildSkiaFontManager from '@components/Charts/utils/buildSkiaFontManager' import {CHART_FONT_MGR_SUPPLEMENTAL_ASSETS, CHART_SKIA_TYPEFACE_ASSETS} from '@components/Charts/utils/chartFontAssets'; import hasAnyLoadedChartTypeface from '@components/Charts/utils/hasAnyLoadedChartTypeface'; import loadChartTypefacesFromAssets from '@components/Charts/utils/loadChartTypefacesFromAssets'; -import logChartFontLoadError from '@components/Charts/utils/logChartFontLoadError'; import type {DataModule, SkTypeface} from '@shopify/react-native-skia'; import {Skia} from '@shopify/react-native-skia'; import {dirname, isAbsolute, join} from 'node:path'; +import log from './log'; + function resolveBundledAssetPath(source: DataModule | string): string { let assetPath: string | null = null; @@ -38,6 +39,13 @@ async function loadTypefaceFromAsset(source: DataModule | string): Promise { const typefaces = await loadChartTypefacesFromAssets(CHART_SKIA_TYPEFACE_ASSETS, async (asset) => loadTypefaceFromAsset(asset), logChartFontLoadError); diff --git a/server/victory-chart-renderer/src/log.ts b/server/victory-chart-renderer/src/log.ts new file mode 100644 index 000000000000..2c115c5dcdf3 --- /dev/null +++ b/server/victory-chart-renderer/src/log.ts @@ -0,0 +1,10 @@ +import Log from '@server/libs/log'; + +const log = new Log({ + processName: 'victory-chart-renderer', + scriptName: 'victory-chart-renderer', + sourceTag: 'script', + callerTag: 'vcr', +}); + +export default log; diff --git a/server/victory-chart-renderer/tests/__golden__/top-categories-10.png b/server/victory-chart-renderer/tests/__golden__/top-categories-10.png index da12528df8fc447b32431b56b256beed2c77229b..9050a96b8bea2980f76e37166843e68113fb0409 100644 GIT binary patch literal 42313 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 literal 41903 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%0D;_t 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 bPzP+%TSm`~cb>Navw^_0?%XWUxEJydBwkMH diff --git a/server/victory-chart-renderer/tests/__golden__/top-categories-6-label-indicators.png b/server/victory-chart-renderer/tests/__golden__/top-categories-6-label-indicators.png index 507e834778246faf982ece155a901b15a1dd0206..1db199859bfdadc5669d0d3df3589a93813df531 100644 GIT binary patch literal 36064 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 RbwvR_^3uvur5}s}{~vgtXR-hQ literal 37891 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 Rt>EwwcNgyyk1=O&{t1-Sv<3hG diff --git a/server/victory-chart-renderer/tests/__golden__/top-categories-6.png b/server/victory-chart-renderer/tests/__golden__/top-categories-6.png index c5343c90b03c28bd7951af71e2a10e6335cf489b..d5fa04ebb32692b8949b71e7442abf441750a645 100644 GIT binary patch literal 35298 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 literal 34515 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 z z+gg424&*Hn$%zv@-}tDYu@djJTP=xdR;4#x#vWnXgozZCcWo;%Rt{O4|O-O<9OPOs`tg=o1%-L!MpqRnt(OMEH z7eJJxZN~3%lu;W#k35R%YJuHH`)JKDLP3z^3HCVcCsSk6}$(G|m& z-;S_#MzYA79kSkaruVmtf&h6HbtT%&=h7o)M03UhRM;KYLvau1k|>Djg}m>XY2$Cd z(l|hjf$=vTuY`hThaqbLFM@&BRPpwfM#lgkOuwA~t+~owki-UFbW)0B=l=fkt@k%4 zUVNqzb5%W|WJAo`ft{0m7+-rCsi#0}?u(My7!j-|TcmJ2AgIMY7Iql{RR}f zB?3j881cnkJ3!=WbA7R_hFdB@4;=Xakuyw$oVjUNeSvg|ciemlI<}vTPNSiEw-s?> zb`%~JUXhVHa*QHGS6L47de7vnY3RI;UXe>`R>7%U*~en8mITwkY%quFaA1o zV9pp^@BJVWm@bi`s?Nv*k5Rq3CF&Y*Hj2hhKY4qRHT@wIEb4{~CU>l|w;D3CtA^8m z$X+H-7&657TuL43gZ4i>yk_d91v|N&I>giOu{bYsmpq2CIK1ASS=7BS#Ku&=bM-fM z{DXfkcKuyj955GWCBel)^e{dq zjpoj!Ujvw8<{If}k$x7WA#^_sGJ!VIDTfOs!*uwcQ89fT&!|yX>;zCVN7wWIG6?zt zKnj8y@8aCZ{da!W&d10qacPuRJR5(m5re6h+~j5@&FCe&P_9{d8aSF$6N{9MyyEiQ zePA4%KBaB?DJPW0gC=|{9qi{NGa8j=_DNsb@{Fft^ft(>IFg74F_$F)+uwb75*t9e znHQ+5)TsHKaAFh`(XQXvgVu3B_^vDa2D{VolV>1pbrP};v!+xejz`Sk^Bc^7G40mr7i zHn(6%Ytr=amBoUtG`2DKSf_e)sEDVeX8dk&O>Gt7lu(L;SzVKyk+*V4EQLESI14d) zW#r;gNvP;C#us9^DzxRz7#+6s3YwK$Dl8Tj$Loc3?;_PU*dh)U)m7TBow+w>C+?p?QQ;l-#!kDzbHoymP7QYb_;?O|^)7cCq(|5m zta!oR*>k__QT({Xbg_OB8vnoZ#u3v)KX2U@-MF!mObP*lczJ~rx_LjK(#P{qbDJ%e z^KQS^`Of1YzB`W=KvXwwXCD&P)XFrv#5zU2dTfU>(cH=dR_%*M`qFUONNcDL$h&?~ zGor63d4`CskhcO$|H3LQNYA+u&&h+fm)0)<&jt1R%w0vgPpQM|ql}~8z^18gtIIPy zkCP}62oHZ}n8)ob0CjPZmY&cETfgD7$Gi3U6vGz$F= z$R6rSU;W|Ocg=k=SXlF7*ql?XlaMK`IJ?=?4!=vra#ZM92G887QNT!X4w~`jD%pqBp^iwe9WY#=AaQ%COsi4 zV#bq|(Y4M6a29ft7bN4&=?#xbG(3*%XPDqJ;|3Dq;d{PKioEadg1aS)m{jO?rKw7O z|86}Q=gbyy1Jd$OGc433%F!HX;1Q3Z0LI?-IsCYRb*YFHGa93{8JuNHSLd3c_n2W* zl+hzIfT&Aca`X`i%G&g?(FZ@9xp9RsB`)m3R^2505lYXAh-rcFP?a%q zF^hV(?A2d@swJq}vRoJTx@1KA{Nw`6(oLVofOEg!Pg=UfARtKH0+-c1&Q4hXp;Y0Fi}lwM})fYRWiSUzxnu=?~BSU$HU$7>43H|6orb05ly&7tEDNdmsc<$okq+}Jr&X~Bw{K;0ZL?n6=cNY@gD|Cwot+)Lj= z(9>3liIMTb<6rmalR0#w&GoS+d3ZFcbP{4?6`M{LmFma(Qw>E~Gz(GrS+yDeSegNO z&nez1-Fi>{#tIGOmTd{AQ?Dibw*Cj@ijnbHJ_U?V5WV~({oqwb(cUp~4Gr#2VeKyRsfqRq1^)ZIm;c{q^8sIV#PW@=NwczmWOzp$7<$-9p13!J&>nlJTAFhzKH%BX3P^WEA}#k>wlQbrxj6o1LpZLnRGL z42_zIAl|tT?qU@6vYyeXPTUZS+L^gXpL#t12*3WG=JKs$FQG|hdkGj65uKYl-d|`R z?Lw=+kT|%ok*MnT^Y8A|FzGp-Y#adyOQ}WCwn(}|Qf>C5pb#N;hr@qdh`8SRQS@LV zaIT4k!E)jH{NsI|HnH-A&7N^ih0MvDBh~?l7wZy9Ik}}V(AH=J>|XrPotZC+`)t^4 z7Q8V*5!2oJ$jxkY^B+bOqli%W?EQR%0(bet)Pt4E{UQ~zgEUoCVQ5Bgtyr@^b-89J zB#;Xb`|VMp-O~?-U2VTo+#kWNRRU}*KtdQf);o~m4NUa8eb416O z?BZmS&67mRa1P+#+s|#uEA^l)R8ZU9)01UDun>Of&}k15DOxT#357%u=_G}3G0)w= z*E^o@r2G8#I0Nyyk~W8nJw~|>$HaE#JW-Sa#dOZemC3uroW_g4?q)@ka+iz)9topm z3cFYw2VF2m}5st=#(SPpeNk z5pOn((WX*?q&>j)aPjZ~F~>wD=l7=HW6136iy+%MQ?#kv!C6;byCiZ6?6h)e;3?&d zU|}D{uRq_=r2z-*Hqq$#w#dmOf4o~_HN!PE84A>>|3>oBk8Xb0qFmAP#vb5pfHrnU zv&b^qv5PtITi*g*;VC$mPO>?&mUFg4Ac*JQ4|QRI)vR0CiR)$6I8w0l>l7=QXwkGQ z;1GfY1%TB(dxg$scSlAdqr%OiiJb4`UXrnI2ooD&oz}7WGO4*}RiSN0A>_0ZvKG zJla9fn90G}Pc((S05N;s-B-=cZ#K0`zwrJp-K?K;_ft=ut}$J}wv>5+-Y-eV@UJ6>C?X7mq3Ujb>SxjZw|HBIcJT{fqvQeY4=!mtyo`Ga zbx-T9I?`w$F?&BGfdzI2JZA48P8JFyZ1vE}{J7usZq3m_O4=1zV6|W2Il4V2euerF zW|MO(<(mg0!Q1vmYJ3$JUAjT$GfJ{9M+aacxa$Axo10$KkplB(d8f~}c_T8}d7)et z9>ucJ$!vk^ZYN5YyL)b<^9sb=nCpw%8`xLn0GuWRlnSlyYs^-RkfCc^bb-m1RgI+wGzMj3TUwo^BCXDy%`PD7(cJpQ$Pb99&1~ykC02VEZ6K zDDVx8+cbR$H*d3<{QX~``9aCpnLbn)E;JG&;wb8^&?7y$V+T(~oUxB(_*w*cuY*IR zH{VG4YrtTwEQDmlswueWR zgi1%bbFcr7&NUv`hOCG5j@c@e7_7IIig?{=Uy?wOrI##J?B*m-m}%u@5It^-N{wm}L{HDFCD-uw< zmRaDW$w!y1oNlLYO@&3q-yr{Dh-8)5w1h(P-1Gx_eXvF{Kr33t00$?tY#Hvr<)*#@ zV?Sws_;G5xSLOJFOD%m-%D88Ldeq@+Y)BH|c3P{t2FH|8v;1S&Y8F z*V%Mzo+V|uS6qkzd2ab@tZ={CL~M@Z>D_)ww)4WW)>HeY7rwO)(Al=^I&Va)heQR7 z83!YTsCkB@brImZw;K#In8>p_#vHETXmh1G#%Mr`tqB0LyMf`d_7S`@+P?nDML!@R zD+Q(EqXea=Wf+9UlEM;qJ*n_$MZ<8r-yF{eIjyl&-%3{63J&W1dH@Bptfs$h z2|&(76$!D1VTU3xEvbWwYEmAeO$HpvLm5dAeIK_eQpX=0t_^@^8$w4v1ZUVnj#8J7 z?pgmb)utj^wa*T0-on}2NL?zu)|llE z0BfSEm+9MP5q7&P7$#%^mHUGpuF$=GIT?s*Kh<{X5}hDhS~>IeU)Zu5B)JKR-YpH$ zha#}|)zd-am3{l+XVWBI7CvvwjJoED@tsj6f=9qz`nLLSNj10IfTc8ucy>Lls66yE zN$|{lzAG}T)q!aC8a@0W@;^&}Xs8$6kwW|k|8q{}+~%#3w)Er1r9=F5O_eCCnKH#j zcpIG{9-2mio%^rAOnKnW0Q#R6Kwk+li&ASY3Wg0w+5ka{kO*=>dJT&5l%3p0_G~7C zDJaB=SzdA4ab!QPEPoavbeo#LHP+r{EC4_~$JC3}hb>1&+WnCuS?R76{@{5j{cU}5 z{8ll)y{us2wS&y0py%4IKtKQ0Y|M%TnR4iOLSs6Q*DK0qT$A8ViNNrkwqeitW%UFU zXuZZS{Dt`!VM#np0eMdzGL9&S1KRDmmxy<3A{c zd4fGk&=HOK*D|bHGbXU!-V`=yh^shQ92->MhS?j4`94wA~-8Lsbz4?|eh@ zJ~R#gi|CG`f=u)AAr4&0ZJD}>a}8N;jj7UG-+Xp5x^ef;b;u5S!SCy}GYNYfJHg&f zuxU1~KY_vyK2=MNyUirp3867(QvG`3aGHp}%sS!R=NOAzt~xm-LGSDI=ogdv9EE9!9N%+W5qy%!GcoyJ8mj6+qMg#dC#hy zGL;=io62vE)VF29!C1iIqrB=Wh-Hw&GI11enCTvel>A-bJUo@d=dltoBQQPCLDs+4h^{S$XTry6IC&Cxqg=6SVl#r%m_M zHtWt)O?24br6;SC>aF4mm}GqSct*w%YU;6G8`^tmX;d6(nNESop2h<(Y$>4Ax61G6 zL*26^UY7mi@shMv3pdO0+lRWt&-%YIa^heYXX_i>ZsSmRhOXD`pYRM3Lm!1s58gchII!MQ_ho; z(?7XocDo91fmL>n*=j6~qjwBenGyZ7S>9+>rGRb#v{bLvmIjca)Cz*Rk(cP3|iWN(mwrHICQx_(eY73lxKeV3Q3 z&A#k%tNGI|b=1x{@KgFgFX73~kqznYS*`WeCy{1*>XwOv3;f-)m!#BE28uYXMqIkW z!e8fW$emvU6^84v*VK!3UU;1%yTl1swll?_{O?t74^>pQf{cTDUOeO4Et-^cm4(NT z8n=en#E>|z&CZ);zB#z@%-|%c(KWO`mJ?}^vO*l`6tIU3?{zb}4D4tVQv#EZ#=(R3 z%p03rj8iM7^CgXp?`!|Ad3S44!yM?{EtJq|vMkn)E;t=(tY7^lFp20YH#!-=3Cy9j zV3XeJMipmg8L#%0)fGh}A0=t^Hqy~y3GGoL;OqlZBEb)&mxRPJaLXY@?*l1-*B9p_ zOtYzXzS>a#1{D7zP74S>g#U<)kKOEy$Sc-rWB`VS_0C>{2+3t~av@dIT%vn4y+0Yv zO&8wlJ%0E%qd-OcLxb;&mRhSC0%EZG?gU!CN!615LQ~1YGH9kk)lAt5lAB-_WO`ly z=F)o?t$!k2&ph^iq8z9r{%O4snFm%hUB2x0zFkF+9&0pM+zeN=vifsrJei36;@>kF zE?dHcsVz_|rdv%fn(a6a&Xa(>wu9VOw1!4zs2+-;exu3wd5OP><6Cu-(W0S04;gLN zkE-*rk9Gv1@a&N}7ed59t3|u_lH`B)p8Ia@*66t`i;WGQI(r^2jt!UvGKlaJ1b35+ z7kNRqz$n{Uo%WbTPnmt#^pe}9(A@Qc0>Q~*$IcIKc(CoUB7+9uoid?P8{V74u8x}_ z3{Funtw+T?^CTSov}MI$4oo`{aLEb1+LQ0q^N4=1A0hO6h#C{;NWh@{S0O~qqEK0e zU2CYUqEgSp7vwHf`teT*%?yF{JbMOLuFqFk(0#li9Cki)D!S*lv_ERlCgiL%Rx#Uh z+sfp<+v|lSR1_dbLoJZatyRwx9h~2FS+OBn|q=CiLLNW zKaWG4U$^4p>}T)^FUx^(G0{y!&cj7GgX9tK@NzciwI zy2uRBr$qnUvl|xU9%3#a&g5xesSlIt64DH)l@H&XoX|b1i%i_?6#K6kL@CsZ>84(G zOYK(a$SPN-?L~f6>@|n3uDq>KuM2O9X%Mj>G=3C`M_!qD)%w*9I-Q30o_w|DSlfkF zO)CTQrijx;tdi|Qf51%363}8=&}{-6Oe} zuY$3i+YPeaAaMq;KB=EJQ?a6)c)u6NEfz-df<}kqD7_E+yUoamt(TWj1cZ$9DaLY+ zGVt-ObXCV|+64K!p~bL@q%EgMbBh#-PA z(xo6EAktkbDN3hwBV7WA7L_iMZWW}vk@is1(s1ZQNFKU5+&RAQeb-%g{r-Ww)^)9C z>2uc1eD}=kn!V?D&gUo8%U5Y8{+$2hI6Gez60XztgjBk^QbHGY(&rjD z_>!J3$4v9xI$KVm$XBbOUZ3?~ikUm}^iLHJjQ-7eeoA*r#6a3-jp*L{^OwQ7an`_R zKFGim^~~zYn+}n>xt^-J`QI}GX#AJ2u6 zI(e1N@j|S&KBjqACO%RE{6NA6C^!D*nCZm_uh&^pS?RO1n`8OEe_Y=oq9BEFF!;3v z9vx*bFL=$V7BaIe*FEtE0B~)m+4nZd)k?~1ALjiaQ{|R?<}9d};9hRj<~X7fwT^IU zjPO&!{k(i-oxS82d(h6{#Z!9ZNgHKj#SqX`lU{#SV1cE?=GI`qpPy2WPxRzhME+@bSr8pEKfM=AN^DV}mbz zPLPP9jnR6?;UAi1+EK^=x*;cxJm=~6hYiT!G>plT`T9T}(wiJxGe*eh`jF*%=G?5; z9Fc3Yq~yge4fRC9D^~=IFQ5OD^>@hUeJo1n9#_KtX`(^*yieQLPbn@^?>`vFY9JWO z8=uc(Tn(n1xCPh3hP94YBnJmz2RqT#Hint=aAbG6Z?=E)Ya0?Np@B>{G+gocuvmL; z=>7HUrcc+HH5ES(`J=D_Bn`$}RmqJXcQz#qE{c2B?WViy30)=1%4xIE*R9t(a7j24 z2AdL*ueBpQ%zqkS?PiHOpbV7l;7^?c033(Xe^sDEuG=KlYp6?3zRDZs{hf(vVUGzPiw{|xPOuQCRTf1 ztYiNB_$6aPhSa8+!N!dne{Krj?SP|W=6dz;gQGpTjdq!2Hn4Hv#%TI<1z$kZ`7K~D zAi|r^($A8UG0f9@z(l;O!=>)po9yI9`HEbynq`~cy?4lc)mt=O>&8lIHMrPR)N>&eJZjnJ?#IlJkRguAgLsyl-Thcwm00!ktqoE}}#DzIj-9 zDC-lm$@ymcnc4)qJ8NWBB4E!i=jx!_(TypR0Y=}-3Z6*>54E9$2dC*w3om)@(7ogS z8X6y5PA9?byG0ja-Dvwo-|n0Fz6?7x{=S&KVJ}N!vEIu#@8f-__^iY`N&tVepK40E zsM+E^8tFsp9f2a|Kh^%XJ$B~@X5y8`^0lK9l~UCO9tMuWJuP5~oPo(U*o2kdh&$fz zHhneqIVQ`@;otB>?X=32OL88m*E%rsJdO6lKEW=9SJ+YfQTvk|thQq38p-L{UB|&s z4b8xR%=qIMa*sOzB@S+UCF5#_tgfu_I*!Op4GG-FR%`W`|5Ko`K47`~ zrE}JQ=Q#4hopwt@9T=;3Z^u(n5&f-3%Q1KV)$FyG$-Fwo+z9}Hmj}j#x zp}nFT#OfNpeNm&Dla33YzXfZBRY0}Jen5+wR&|@gUxduwWmo+O1jh(^Lq|{TpN6R5 ztE!?Bv%yFr6kgb$a#g4eytXmGc`AO(-IsFRT9`&dMls34b&nKntdl&Cl&8TMgn}v8zUiujH0X+vo|| z(bugN&Usc-%$rG1frOE_QUqVu^)0o4Zi+- z|ERSJkg7Son>%IoJsa%qMsycPS-9xGG5-;lNC&sk&3jr?H2GEgc$2?WC+kO%4%vmT zB;5xM!F+73oCnGj`2v0y?y5N$N1H303K#xDoz}MX!wsVJ%I8>xJryTui*6^_;opG0!fnSzMsHXIZY~5a z!9VRdF6#Cl^zh>a8+ynteolK?WyR~)E*@;`<^I*A>G0>nKxrFo2VDlrFQ=D&W3LrO zH;K}z?yb34C6(IG*CxcY+$8qw-9=}}tUSmw^lF~&otx9a>4}mNdJo9H|2tC@kC|)- z6oM~@x8Wk!ps!(#)gu)7Tk3+bu?e!^iuvu3*`}lRMxYX?S}lnwNJ%Nb3`xbav_2b< z!eli@Jrss~yf`seUGRrQW{HRGm6tPStq(sG#;Vb2^u{MI)g`arosxFO^-)rezC$M) zy>y1bMxkmbkGHM!2Qr zWrb@BC+3x8@8j7C>28>GiBi`!v2Q#SQfAQ0mUiwX=lnt50Y{;Vihmv@z zbaOEH$VS66mZ<<+Yvpe3s6;+bV%2_*X4C00h5cMZRomn)6>nt6*%^lM?KAIL$}cnm zxzc^wvb9Uc=g^aq$%a)Y9gRG>D2>54ib*@R@0U~n%P#4M&`q>gKp9svz!EU+c>a7g zejj=8cf|j8&+lUlZnLq;bT2)fRDrjc&kXFBVC9mnStVkg`HRJv_MXx`83yIqm?5IsMTH#Zdh6Lpr>mS8a9s9}kX|eU)Pl<& zom!Tkd5gBHr1frhM$`khNw?Y+i_}Va-9+wA*#17A=(+)L8R@=zvM9TFoBQGKN6;!& zA!MTCOCS2P>1itq8b_a}+^N}eJ|xrmQ~6S(g#ofS%qT zWoZ5Lw1F8`))ZWytlOoMa(}b#fwHsEJN|S)b#?&=-X_^Co>8XZj^SFLrwY^?JRKC< zvbWVN>JpjrA)+CLEo*Uvg@ym(S7Q7H3yq3y-*I(;E%u3A9_?zD=_L?w>Q{K)1gf@T zxx7D=$fu!=VpX5F2{nFXY(+HqT02m0iB>pS+5R=ZJZgaiL?33_j}WE!g!jx16>+ou z=~O&C2tWnGEMs`I7G`bVG`(i=JG_8GMC0wyc%9AXfVkDYx{o+AHnEzrq`>EIkHm|< z>dm#J6}NSBx*U$M6LPRP*iJEUo|v8$+GYWUSn~qsmFiUKBW_0E`kqaQQLE&LYXq`? z8QP#9dwZOzEmM*z2wOiq+%k@vuJPm|wH|zMz4&~Fr4I6dm+=FqSY4IRf+T^Jm02NV{;@l|JXpZGVnE?GXAnuQFej*;+Z z{4}A%U7f5d`yJa-VI&5L*SCu_Dr;Db@edr$>-tY1AS&b&h zM*LeyhEGu!vN_AV-_!B)=rv;@=t0wWpOeLggL5F`^K1;QM_E8bH$QU@$73DpR;aPA zDPWn{0-n(QHwK|?0jP_l7MUi(Tb=f332(x1`!L5t(eVd9;~&Vqb09Jb*A z2ghh8P)YHdQe1A}mht6*MkW+FV1}r*8HsN!Yl^lTYAl!WLgVQdtd!0Fq>lF+)qcu7 z-u#pIO6aLXBNsXla@4ZZ=*eTZVP?gt{|k^XRbefJl(>3C7pG4Sz21yhx)Qf!v=hoJ zHyOtzF*CQBF6VJb6R_T!_PgEvJu{Sa;Fbvp6!}W+6IyZUzwoxYlc86={43L%=c|v3 z`0jGRRo3P;S*A8v|EOqDGLa5{srf(9t{>RnY*#adKF4Ns#vz+KX%>8MwWZLBR#_dg z6f!?52hjm=>W5fYBYUPM=NV^7yHH2`se~=>k4Q3Aq^`d7dF`MA$i0B}H(!~`f}Rab zDh&1K_z&nk>gcG1?$|>C@jx(Pw{(tnrW{Y=gGGteBM= zE-A~I?^sXtn!`bCz0&1UaDWv%@oJ zf6VOh$YHhJOnMU1>-{Law%anbc(yvw#|R|^qHX5G*Pgr^88Lq+a_Jy$NnD8UH;o_t zY;3^%QoxL{mhmMUodScFtP%3q;iE&DC?SetQL)dbVD0BoyEK`oMF&U!T^ta3Hl&^? z)UcRmjl13UD$B3GifR4_0P^`BomIViJCBjIFf>&V*MRyRa=ux&zvA8OyZ3igCOAaQ zKfrS7Ofo<9E*HsH5g<*kL$FEN&uPbM^wpskAM0*GkGhrsF;p<_p5t?F!>5214z^1? zd5(TXfVwjNbda=Kr`wAS@ZIH&B~Rz|h0>*o_5eig^N+U!94VnVjVDO#ukwx2vTGFp z=!ZhKa^2cnHas1zl4hNKN)c>2Txn`9Cwb0=4yx`AraL?(SS)iQ`^x<5`5B1Wn%zf_uwk*@VWJ>e7o zLkmeP)u%a|HNI&L*%)rZf;c8cJhwhxx|YwxT)Yem*y}rLrEP6>bvYB#>%M0cd+-Vi zq(=ROgl$*5@B_Pd(wMR7eVUC7tmZ>@c$FOKe6b*nCp%asPI}Z8YHA=*WcRJYnoIvz z6ImZ6)XIr^{OL~$*Q@H*o?~yDKjmtHuuXv?ZNwl)=^c}^TlJ@3uC6I8bMRVhlg?mw*)FjZT8LcqIr00nJNUWQA$QBXDhcgB9#wnEAKePAKk+EcKtw$=A3X(ecr1`odyaY}uRqJhmZHVnz+SiAB zF`3}nCEwp-$-whSx>QyWHWI^VPQ^OEsN07aj9jLh9qQIwjeo1>3i#L};EK&2M9Hq`D!HrrThcOwZY)G5$ghB6PB4y-9P?e8Q((%~gOfs@c#$ETa) z*yzZw*yeTS=5<2!j(uPu(9AHjx*a8I&88kNrGZ|p)ftzsKb!yT>*4756`QP$8*kos zj~F%K1c3mh=DM}mN=EI8I4Nnmgw=iW5>I!V+2~y3@jO=-9)rC}2I`TRCZW!9IOm*I z$626ML#X^aDy~B6@Ce(fN=jlU-!8GlxEr3`f?=|l(yUUL@f|4DM|;8nZ(AM!eMJNn z_w3~D-Yt7ayZhYT7tS8*`ue}OyHG2erP?V|s9MVZLi5h7K)PqkP4H1a-fP5P!bV$P zj6cN8TM`#8BbdVcn1;=YOU=dSKkEiKa)DkYNnY?Sc2=QuNaeq6G9PYfvAPofIEhI% zIOOmMtaL!YejIVSfbV0Z?+t#DhT#5c8KNFI^)8KbS9@7Ef4-Y$lR9P#pC3YR6?Rax zaLA5&AK#9lDArq=x=x5%awaScK9eOFmHN$iu51yyVE#OZ!mNsWd6b*P(^PXVQq3LT zYFsv1E6?=I^A68+C6^>ia1zr0!QSbQtqE5o2Uj^4XOvFs*{5|W)cc0MBvUfp`$nIS z`d`mqF6l#_iXRq}%#BubE;+n6IybX0u`6P-w6LZ-KJ7P08L9h$ly*T3s!C)ePhgwNt|)692V6bL2*{eI_J@8C}KobiUP z4Rifpv~|ZB7Sv!2Np^|jo{CtKg0%QiqWl);J}%8gEGY%0YxYOpFMp-owBm7gt5NTF zcz*aM$OonHEW6SBth=L)-+7giYcs$If zmu?62GeEEWN?(2Dg$z;K7IA*Rlw_ogm~nqO)NircbsR}~eVZ@wiRh2hRAjYw3Ds~j zE2zMC>{6!mhC3#}??{pmzc~z4s1O^Ok3IRp@VH&eYm^;ia(bgB8mDXRlFUza1cO)i z{^U?EQ{bqAq)@2!dMo#&(z_>_vD#riiP6K~yza?0@3Ffja;{FMj6yf6Vq(0n5j;}7 ze)|~y=L?Nvg^8MP(`lWaI?=IT1mib-3N0@usmTJ$i-u6Vf3B>lt)DsK%<&h}S7_-p zyA;%o>3chx!}e;j1nWWJr&0W90jEyhQlI6A9>|66G(ySVbL;fq%QZ#kCo#t7_E(fZ zWn71e^d%kq))*T7=@J)bo5%ep)(dM8%;0y=AR|lV-iPkWgEa%80{x+iOAb2t61}Dd zI1!!$Ii)p|&pIpI{7$(cMjNG*8RYZpFR*?Y`n796h9uW&E^or%tIM4#a-V^MMRRh0 z2y$;iik^eN3kg8xhpJrbZAPrMZTW^(7ILizcd}7eQRf^w+&t9D3R4XJIgh03Vx0=2mDHjORzs~Yf$ zxe8OWm)Ly*FAn3w#=w}sTHnd6+HN|}9bYD;oLx5nSM}S^Jr{asIN5ypFXT~v-F{e^ z(JP0SUOt*e$PLaU1MO{`*H~d;k!F8`D6k3c2#@6Mz%kr3Kw?MzuZO1WxyAMnn)1ss zD(c6|F=GX?9PKL`mLkj63rRZ^%#omCf) z2gL3@-DDli82Hps1*!jzgmk+ZR*5k0)$6ClaZu)Q8Qp!>Cl^#U7!wh_R)|{1Mr9hW zi2TqvSi&+5pmTY$SwYS)Lz8H(yev8nc%Yl&r40cvd%%efnNp)(_WUxrzWOn%#r)Og z(j4A_&UT|gNZspv>MSCt0rS({df`M%EbZM>;CZ#p|3<6WPl2a%%A51eK}Lt?TwaIL zFmZZsPxdoRj=rbfS+iGBr*+#;yibEaB~P^>E(9W^s5WI>|Bihp1E%|4+ox*^qyK#u zz?0Np%F*+KuLc2G7O?gz>xm__jjfd~SC806r?Z|juBL$Vf$twcqPqY7@r95vCa%Wi zFi`-C=%)j`-=LE6{mDSwZG8&5jUkaLNMKIxFoyej^K{={iL*&z zK+3BCxP|;h3ty+MR32f&xa)y@E)A3GWJO+|*V}2^8w}Ba65?%~6viH7?}9a&TSqER z#Ha^7Cb`-XHf>4{f_raH$E}<8e&6M-tiq7K0%y>&p>7|kXfxn9 zL!i*V1Kng|RMT(7Z}u;p^S_GSMDpEpKz6Fgb30xbU)4C%2Lzxtp7G4^G%yPp`%v}NTX>t?|C8=xP zds3Ce*)I9L+bzJG+R672YbY8@@xBhVKc(gqMV@#Qw$k>kq}j3QuQ~*sUYDeiV&QJ} z0D3pD`7N^kiru?f%)h7SLb*K z1CO|WG1IA<(sV_J5~zbz`i2)2tL$GuK4RjC;PR*QnI+eNBxTMeCGDbG5#(=`^sC<+ zTPp+hRhGLx*IzuRkUak!RHUOW#ujEbM58dbDL~+0eS9~j=~}8KZBMLqAXPacIO^~0 zcL5tJY>@Moyc`*Mt5+u(vv72Z82rig%Zjm2H#6~*1RDa_= z@aHq?B;aH6uhWy_9&mCi&?=!GCoS3o4ugWCg2k`&H}1<;=7$Jf%K{IMt`zG!qyoPMr2eGsE%<|D z>$(Ah&VZX5q|l+HBgv5Y43%u7s8r}I{aTrU+&ha$em~zKf&(cImorZPG+1tttOtA- zfqe22KSIvO0WC{HM%>;~&Hn^$rCL_ybu!UdzyJLe&LI@{`iaUhs<&;AvpZqG;NvZ( zy-ulZ1Myhb!5&4p&QY;Xtm31q&NWJ^!II*^X2<*5qmm%%hpa`;%OWDMxp!Ng+&tDl zP%vc_qBG9%8Ss<~R9?LytE<1mwCem}*^~Z$Pn29Gr>Up|!N=HL zRdl~acKA0p`?IWyEE)SM z2LjY`w26+5!G|Kn!KvnnKhRY}bu+Te=3`|&{&y!7x89F6`!s-(X;6^nb@6|wai_QP zATz+y97^|8)ezk|M^>Ft-uWl?_-ySkx;MBNb3KWNueiObRmi;_R~Dm3Wt>iU3i8=!^lGaw3MN|`IZ z(!qeETLhg}N!V~kvc2DN@gHndko}6Gl>^a+Wkz@N>qL2q=Z419^I3 ztDI@M!mieZKi6)6VR6WvAG9D5qu{gtS@lptspgqt4iT6Y?vm38eER+Tlm27n)3#6r z8*Vr2z@2TbWNj!Rh$Lw~Lbov$@Th-=H6TsKmiK|@W-R>g7MOSV3vldsSR8Eqe#3)5 zw_%e4G*{T*u3{AdE92>33P}70NaWsiF1|BJ&`z}>#$Cf&sL)_`Hem)$5~Usu|62r7 zGO}Px)G>>GzlrW1v-OWX=uFseK=24OajElzNywI`p16qT+Wi2FaRB?(f7W%LAA6fN zhD|*da0%N7ts)5zUpjenYOy5KZTQOD*Tgskd_Lb1dGgblIbE^fE^ zPjayZCML!~H_JQUPBp zl6}PM$teq>^n7KfW6t8G3bG)gmxK9%r{AeGE3(3~8GLJHjH=~+e>#}_vAp3jdQ}y` zs^Uy(QJatN{&iN>Z_a15jORGC>o%XUA}hYTURO$55DF#~|9RX4q{rtDrEX`z+-7A~ z;#1nE(xoogud6uJUtA~?vpo!G>}z+1?&>OwmUd>RN3k*Hb@Zd?Kz?t(9`(0~4y>UX zn$I4JvEUT>dv+s!uDwk*&Upd?p?~(1wRNrSJxROKx2gaKD8HAw3%gpY4NBIh5T@6W zQBF_cqk}be?ALpNMO0V+*JON+gxfH>TZ^kuVR})~XW;y+93|@Ux+ey4K@Xrf9U{hi ztLXQ1bhkU_IRTSh7MOm{3LcWAKb;LdeIrb{c<>WDFW7e0o(0R$!{>e?^(?I`z6Qi> zJf1Tg&%^9w#R7&y6lC)-v{o?Bo&&~ z9-91jXxidU7q`6X4qdK|W&?SL~M9&3JhY~ePT?KhuV-`>hs{KA9QHks5!~;_GG+g)zzhUs z<xuykutr`m-ag?S^myOV0gFis7Tj8OnHt-3DRa*N z{j2uwHQ~n$y7(!SZpkc#%LiOdVr<<3Y!e+jYB1#uCo@MwqWCGsgebsTe0Hx@&ET09 z#i3OnN%V6nfdnpN!rCS;v)aQP;83}_oNxZI8kXzJ^At_v$1P`hAIbl_6%ml-3hJY~ ziuUHnJL0Vp^yndj#I6PKj1^VQ^oji{7zc6D^9FJj`myP@@f{dwJ6U|nqhW=fxvce> zIsu&xHQrNSoFPoMT7^|0)j%Vt^P-*#VRE*tH|jqS;(NO<%g(}(*<1%{`6_eTJqz-z z_t76p0Fw$iAN0B}#{2MbMbvfL{?&mqdh(jPw};GDp5iGx^U)h$-EI%#R1X8f=aNj9 z@*bHk|B-w_07=@9uknH0IEN9WAr}<`Sl{mZxex{tlUASo8#wmCo@c`?F*c|?op;Nn z*h&sIXN!WY$yps@+Q)8q8J@XzW*!VERItni-Wny|ZKP}&7MJe@fw;Lq@ndyihD-^e z_wEm%oR6CBVp^-^C|Ck9dZbW%F}GJoM_YYXD!Z_ldhR&991(B5d;11=zDc7koc28# zeL7RLtPIdfs^=F{_cD9FcAj2cJYKu_{XBw8oqlyd%#Q<#Pf72KBl6^3`Mx`_{TWc<;lNdLZd&hohf%s`6j)F}oVe6O;%pE`=;df;pA zQiRW4_#A&efDX|EW%Hv$Y)@dj;|^#oam{IPuNBXhhS3tuhC`W)gJLzXnWY$fM;gX| z>B$ii@}N^Ergk#&F)MMYg^vyH{;F^yHjc>IAgBjOntqs45T0j8+6-yLs-x|}{Wmo; zOJb*Ci-OCi%jZ{$k+=0f!lM;Jsao7W?VxC;-aL2crnsT3Z8QbL{7WOUS+?xfnF$~% z#sniH=RWy~-$j_%s=|qJ$Y8DbL#6G{4J!yuf--`o)^fiBtW-BhC6QpvzglRIE}#cp znzwK+_k1I(9iBVL=TuWoIEptuZQn!Qb(K~-D{LX16_m+&2ITWaTlDKTpU%77A1*ob z8MWAHFCSzYe&q2@`!FBRwZ!Jo!$rb~Zq{Kte0$*yA!ak3c|dr$CMbPFE8bZ8Yp)d7 zTSMC-;OfsLRx>EygU;L%9US{Zik`=Ots{M`ewv2~U~P+gh-p4dK2POg{Yr^}eQhtH zzW}PX1eoO07t`ftGfWSQ7kg>yvFxh}89~;k?#a_}9~Kg+V(MmeOk^-yIf>cg(F0eR z7b2Rg-;tY9+Q1LO$C|fZ{g43Suj_O76veZbA~kb3s2?9$@Dkn7c$XS7CWrl%`HDf zRmK*)Rp-q(J)s3PY_7>cJ~yy>0)GQi4xTWxy3qsvm$|BP&TaV|}g(iO98V z9a7;Gq$ekDlGCh{^wQKDs9FX-?XZ_Pq=v9eQL3)3277l!xhRM@~t=4w2tp5j>SAN#1aE9;$iEPdnl?I%{=5=4-i@jL)*mIFCJKS31y+4y&&O zvTNbTkr0WH5KuM)^vPSS=w21|BMXdA+#$hE<^^>-5k5o#{pIRTz{!cOO(9`Zv8NgO z>*F$T`RLKWWg}iY{H8(Ou%3A}YiS|KXCVV{?2C~WsS`op{g{v(2VxNsG=DriUAbAz z-1AkKsYPrpHgDesS{5{m=dwC^H0$F8D?bU&@e4Sr0sMjc63*y)faVX4Qm?EW#&c=o!y$HAR~!QRNd~oSQBNK7Naxh@Jwpa15rqI zO-)ESOmwm2gt<0_65abbx;LYfU?$HgLQhvP{4QjH%5z!j&}h5olWHC#Yx|zVuLKlJ zX@6P!Adb5zM0crqPn!lu&LSU_VGRKcSI}l~PqLfuy~H6h%F>n<9Se1_QlMR5vQ#C7LoB*8>~S=fBi_E$^O9rK}|s(B>f$h(Ym zW=53?nd#WjZobQbSe;-RY&e;$y(V;Lm#;8{9$B14&Z9hPu@yY#4zyNTmPM!!pO==@n>Q;>r;jypRei3R%e z#FMiz`;h^6eJ#ximSVT_3xR7IbW?2#TuSK--<|mTYPwu$?klG;{FcxbRLC1D_AB)f!=aWmljd z_ZSXYkH;iTe^q+B+~XT4Q>z#YguM4LE8P%b9YT+XWh~|X@95ItR+!cq{06^Ku1Gac z;~{{x@bI;E_g_)LX6QEGgO}ag`J3_&T@N$q z;uX|c9JYzT-*1`Yq$beG#a`N=l;hjMT+Cw|_lH6|lJ@XWf9<3*!a_fV0umGlXGIx? zCZ%}@?7_k?Q_z2GeaTR~a!dF3bp=z+%gYM9o7$$O%YGzJQy*B>us2I{d(0?8_}qiB zf>4FKfCY$q2HFnS-RiAc2@|Eprqd~5Kt8cJydbERTT|oupG31ibDtE$UZ(^#c#&K6 zKG}Y%#l4vrING7u8wuP3OmI#^QxlKAMe?X^ssV3E#WN|&TwUc0UB)NFIzH_TWv?K+ zxj`a&n$7C;)l)M^{m>NNdrX$(YirY#RE^O;J8b<#`DhD)&USGyhruax_3>5I;Z5Jw)Z@%j)gi|#tTNkG~^p4YNGucVW_4YT>kHo)8 zhLhiK)zXDNIXP~4HhoR()M&f(%9o7eyC-1!SYA2cQ{V6DnF+b9>vkEq5g#kS4AQE* z=aHeT>n>`_D$pHW_K_~*{uhHti7oDtI`i#?53#jmOb#?%flW8ACLBPk#D&`#PTXN~ zsX?4)ijoHObNlWSek3N_w2;Zc11gVEmbb1S#&=;9N%u-RL$RQol*X)kvNO6UL8rQF zyj{7N(3EG9pxYzlBM0rQY2eLFsqee4XFY0#Hbz&HZOL0n!NN45|OgU)$@|jT~Pgn$AA@gxjk)< z37B?FBSXNE)_abzjZg(DU<73p`-PLkFDK<)IO)kYHFG`{KOE=zbrSOQ7V!AD4bRLS zF_^^Pd4(!8fTNU*a&ihU>FG)m_oVV&cMx6Jn$4sHRg?T-a+s*(wEfO`EGsOy>~on9 zz)22G=LaXfSyJORgKPbKjo25u1C1u>HTC!P-yvdJa06yPPL&Qn5haa1P(e551t-_p zIHQglGybWISoK^eV?_u=uAI<>yPT1$gW~2c@HIb!iUi^4p@qAqn#oV&lbkuvORq8% zv;yCJ6gAZ(oPUa^4dwF1i81$?!7RZiE-(Nh9g2wbOLM+CW-kpg4wMoa+tBpZUeBb= zpv?@i{j?O*bT?poK`R+F18o(CLH_L!clZnQ*lB7pXYmR#?|%G+j~wDQ~9)*K#*N z^B}uvmCtV6V^msiwX;ts{dD3N6>oDiVQY_B0z41~)6e^rq{Je?CnUy^FeWHn&356u zz;(N}*ntq?JtaK(tl&&0PBC@z2oduWk7Lu-US~jEMMY*s0SIT#YaU*YCA~8Nur_8n zi8(rj1-8OVH+5r^7GqlQ;G=>$4Pl|QFTu*kzgatxO}1k-Db4x-c)?fX zn{t6c)y!$z-&$IZgQlkWk$ z*7G;IQ9gk{LgHm)!oi6{rZ;c)b-&W+fw#`2O8absW_K}0{jYLc&o)|YVB6`xz zVi_4_py0r5)5v1K5O1A4KtyDPwPC&nJDsVTuDgnu!RLuB+}%A@Yi34Qv^rM^van!i zQ!osS{Yk>-Zn;79u;{Xo4ClW?6*1;tUSxa={kNgSw<13_a(k3@DZry$oLMcldwtSd zND+$!K%8Q0ZgLQCHLZv2`@;c6X7XC?(aOJ~3!)8ccs%Sm74k#qh>?FUNr>5E(EJIW z{z>_cznAl(cYp0*2_!hqrI6Y1;i9v3nzBk$xHcr0%SgduXwZJ4IU<2Z$Qd$g)*&^O`g1haSA|9UB~mQmlns<7pV9oP7<9Da5&K#{ESb|CzS#@r$IV{>nW+JYV%9Bb)U)#Mel`zCKd2_DBhvqQMe}lvxtj7$9@_YO7001VHcBN(_`=*gsJtCidk$vEFSt(9_PdUtYdx zi~u)hSXg+&hq_{x=5>q~4~@Ib;DS4;0@mOv4nKqMtv|%Evxf(*Z^~`4B*fmCowa6~ zT~BbLN6{0qebFZubz5Yr)=#klKPh@w=?7HKcndcc=vR zR=1JBQD>f9@{%z3u!FOX=d9i5@8}Pj{|hwi#;@&iS~gQ1`;Y>Uu^%C_*@8wcp7ss# zt&P`B)%$V4vI4?e3{|-HuIbaHcXBqtYoR6eHGPyuQT#G1bD=%YgyJ}eGM~{L%~!;MqXywW&QeXXjp$E%PeWIr45V_l)k6a zd&Eqp3}+drzdqn-Kyi$Ip?u0_3cXZZ6!O^euxMi6*~) z3OsoO-d}9FCV^|;0*@*W0#Af&1ON>O=5!3ZRpMCpMywe|a~vfky$*~u!(#HZ5V?4b ze@Xn)kJi;r(a3Cyp{6#S)+qE_wkriop2MfrAJ&~P01$m|WuR_<*K?uV`IueaJmCms zj@&}ueDv5E{LIQ=WjqrYcd;E_>38}x_o?2q=*hk@*?Uv990?AM>M1SIhbS}e1oX}* z(j0?|-)LPz|F6V4o5uer7j)d&!qPfK6bpzoE-<$WIBya=?U>|0iQ@nJ1*D4=f3EgD zfI#jjy_9*bX#y}o{YKTF{|mbEYJpy&K&OcO&GfYIWuGt_*d)px4lp`@3T>*|{DPWM zz48ps4PJx*O;Cjc=*77s7at*&%`dTw{jNjpAZWv!Db1Mld@%K}>& z9-ER7WRu*{-DOI7r@7I6y?FHJQr(l(38%K8=AnmzqQRv*qMsAulpGx$7w4Cau#*J? zp61m!$jAJjFv9L?fFr~%J*<}iZ#U>BW5>XwHe884{tgF@(@c=kQN2tO+yS8V@1Av| zZz>!OEB@>A)Cf{GIm89NLZM#5O*C!bf>VX_IqwV$D}r|*JNQt@ar(8Hf$!ow)~{2O zbx1`^`OT-LEzU$hUDYABvZ5_cTbjXx0AB#M4YM>VBJA6w%^dW!h>Ke(ZtCw_^qxUZ z=!Ku5hnUfOIsUt+zpZkI(U=dP>2X#Hcx|IV6+~rtUc*eLEOngEy(moJM!s4NbD>=2iVwWG0GXYbQ}EX;Od?jszu)Jw#>}2 zl@4}`7d32Y`D!K9oN4?&Y+qpH)ggO=IrSk5krCz#f!Bd{p4&6B?=<;yP4Azl0_k~! zj($=CaA9N`e=O(z$$G-ICGpikx(9|NfcxK+Yz3 z_sFl5O87DN(Ad@?&*r2{vC2!(_|G>jyNlE9&M_~yltv4lQ#`t^{{+&*B5+_9@cxxW z+u#3HZO$8SH^9h*SWE369|{05Q0)01Or3tkq?MI5pT`L1QwC?$O`Tf^JLJW42xb&J z>P8Azjt5@MNUfdxJI2SoZ*D7~H605Q@IZAb@LD+7GSg|H1TOd*0j2vBfarSz*~7*l zgS@~0N*w~pxvL3*sAIB2AZ-7B3&e#y3x9?Mc>q$!kk5>&w;&3{`TxJ!{|gJ^igC19 WgCtOV!Vdv|Udk%Vl)fZ)2Y*PL^VIaZj0oCNwCqBkHA2>qL+s1gVSUkLnRzeWPS0UZd+ z1OMTilq9}_%122KfDf;nzJ61A4IJLDzlVZA??K;0zo@vUAFa5#2vS7NnLcf8nAQeJL#cd)2f6+t11Y(XLW6XQ3rXUMddog-xU zH0JY1?Ds!D-cWn%jcvtmuckP)v&U}7JDH9Wtd=^XVgW}iiy^8D9L38aq5K*5<(tYs zesnLN4aMM8MSNe#vS@87^7 zu-UJF?MYbg!EqaDrs=BKcSmjNV7?Aaj$Ff5goiILbw%`>soV2#*V$Y2`VJQC!ZvWpf^v=BZEEu0NHsz@hanw z8s9$)@#S$bK>o6zwR@ke9r3#RIi+$j&k()LgjD>-h*ZN@-5#tGF1wfK!_9MA;HhrK zpyxX!G4Cv8%qD&}K=o!tZP}pz8BYrVu`)d-H}ppVjmlP0emqa;o|2 zV4{k>;kqffao^n5^TN!$y6ZLEcvt&u?+D6f2MYBu;4FzW%9_N2f2J(KDw^bKdLp zyt`&eT&JAebf<0#yQW7~D_fs3(|b?N7T(bQ6!&5)#eMxwCP@*%U8$#hGvw?0 zyNyMjzd9xqPnT#$*m${4<;n?K($H)tZfm8pE?`4A4hnVo$%oec4#~#CWh-c4t z17ZvaknCda!~0h~;!UsqIo;jivzT-Vw5RgL_J^#D;&h*UI_JGz0_%O`v|$(Ib7i_n zmRD0QAL}n06B^x|p3(7{$V*?U9}-j_W!-p?1rpw`EQ(8r=% zWO75v({>Qr=~7maozLfjnkD;B?C~M1p6Gtx?Ajsw>Z=C5NjVmCe{jj8pE40k)YhR7 z9#4m3aJR6Xxx2Bp=k*rw-YNTLKlvFlJe3ggplV6A`8K!r?QM-18FbKcvDphhLRlT* ztjky)dXIn!?L)wv+WRZ5QRlH`;m!TBAfhY&zM76&YN)muv*oL|l=5}HLzW=cKCuh*L1uAknWHQa`o%DQx zepp%g`W1rMN!H$tp_XabSeO1idbZ0}{>i{rmJS`5&?LOx- z{#d#%o_JEtF#q-@j~qVxOWCilb3$Y&3LU4*isld7bgFNJV!|T-01eE|x7h{eb(*&M zSe(}7O`YzZJVf-E*WOQ_=5gRL0W#n2h8cA@&FIcNw#RRK+=6+6{C;U`Be);tF#ND| zbMoQu!6rHP%Fxy+jC@Y`{{4#y)CNI8ji-Iuq4v#E-WcI;@~MhwS%1ID(eqB&6Y`8X zHIvzT=%&ZfnajZ+TXEnIiR+kT9_N3GAn z0b-V!mjKXQq)&|-2j+vS#qgCL{nuY@n6*0+hto;^O&5M~Ot4(OxXfXY(;dlaSQSsB z9+__f3s2!Q3RoOVEDD!=5lQssD$ah&Ny!geu`RHH;&Bvd$IwFeCaV>V$p{^ZpIWYX z@|@Nym*h*R{YJ~i4F4WJ8w1YuvECK^#rL>P6~Lb@urp^~&i}__76S&7?{cVIVakYC zP;PoO#@@6Wck<#^$Ec$bvV9GeuhXa1E zFz(xyTBp%K6%_{E9S5$r-}AidC$6ZrwIXuU^+I4I=G=&F&^POeA7d?4KULyIrcp{y z1ww=SZh)J)0)J4fd`G7yI#HZb8=o-qQxsL<`wgFupO$Wh}@Y?yG9|)(Cn+=%0sTRip&L0$m(G(?_R5DPX)v`qvEcpD9 zTi_a#XjYDP-poA9*V%d|mGt3y(~)nX-i&)r@B*Y58(X^EDD+Gu7gLa3hE!*Xa0W|0 zy|w%&D&BE{grB(-4Su?^L-XBbEeBtIVG^nkr02GIFGwE|u~@%VEw@+W(eDb%d^*!L zWw@_uPhV^digO8N*c*)&$bSAvk7Pc{FXX1(MZ z4ywt1Z|r?yAVn$3!wtf+rW2{EmrZAJ-Bx~j%H|$aelc0znfmG#+etgpg!6||wnrX_ zg8VMLwo3?Jy5B}}KZ-wbBmlwNK`g0UXJY3o(seU-LHC#LMcT-_u{3X%U9VX?*{#1$ zORV<^+>O`2AKoBNx8u8Qg=O#)2)l7rDNZ0-#p?m~Ul(5jy_){^*lbEQa>%_M4sCHf zRDCMM#vf}M(Y8O0;)d>W>z~bJhV@T3IP22c317O0jdl&Dms0yxz^>S`BFQ2C3G@W) zUPv~n99zs!NcP5K^itDF|LJ7V%-5&9jj@Oc^>>H$xf6RoT3-5+Ndi*`$w0nOF7u)R zVHU(@%J}}T$INOjn4vgT0J4A1?)p%!0C>+_?@!smVXll?tk9JhLO~}Y z2zRO3tX%{TundFsR0ER-2qgENHF1%)XsJA-M^xhFR<+iXwF6J>B;0rnbKS?>Xz&LAmU$WU)82dznYG!UZIX`dM>7Cb zv9i|MtprO05%~O$M^XFK`-;~KlY}RKwcxY~KTQ^l85SBkFc>8JSvRpcxG`BHF|V99 z!LQkU=gmor3(t8~l~}tS0g@9Q^Pdq-#?Yu}B!j+35BJv0seg0u?I|posQk{^s#B(s zcNRo7_>9}(5lpjigCD6bHf`M-baDAVh0B%HFC$ffPey4D7s z$H?#YQ>TmwRKitiHgoT?Jb6QehSr{XMoG(QIoWEM^Yvlj*fq2H;WRmaFuzQjfYXEG z%5o;(()(-JSpWHAv(-2wXjt=+p8xv9WL#A}YxeFP;8NJ-HP9>&{l9S8E_}_hU0L{< zC_c^sH*nra$ZN_`0cP{ZY<~(T^>QVzIUPoXdhB%Hy!@qH9m0czZm;S6^9QxFz(c=y zg`rnH4*f@aj(iqM9YV^x!$<~eT>+)bmC<+-Q5w-%yaC{jXC`LwEt#}h1^oI-MZ3Yy zy$dMU%;e(qX02C1hq-gNOxvD^q*k~ET@fo5t@+!$OUc~;N$D@XQ{&za#H= zGK$YpL>qN#ZC%y6SR*rFIHXz)zqP(5_&-^Gq^dF5!*D%a>d!=Y__kD)F?@8ib#1JL zm)2loGPl|`Emf{p3RRq}2b_SYoT|pFY23rv$v+M_AT2K2zu_)caOR8IA09Bs)=N;f zD*edtof&J_c5Rl-XR|*5_WiM^ccRu8fy-`5%{(ozkw?jL`cw7V{JuHU(Ri$4Jk8H7 zhp9T<5dRw`%h~NrZCfg8x^TRxwDrL&044M6mEz^vtVLU-S}#}UWuH)b`wB0)9_Q+o z)HFThckvv#=09FIg^h*)%hv+xyRj*k@rJZJjsj zt6#Ch^^M$KmAc)lfG?Rohv=E$2U*`_t{~LKx`J;{_q}}NR5sE;H@8)S9*I=sZJD%P zPYfWVq{Eu6h2w`|s>Svgp%%}htd~3 zyVL3l%uZ)s#warf_CM&utwWzPhDok*Ds`d>7Fw!`bm7;pIwXg_Ys0Gu4>2f(syNt^ zp%Gilc#Lr7OVz5sxA+JFuQNa{K2`ipd9mJHb-yjoO0DL!6Hd>4k5LBlmqT{-=^yF| zi8H&8*JZ9h zx>IBo8X5V$KXXNAr3{%fE-snFOYOH0@myK|{DE-nmV>b-p0?Xbw#Xq@Q2pT0VAt`v zLW$?Z)ELrP(jhV#^geE;WB|J3Fg7Bh(dK}#CYz~owyGIL1f6Pa%AGC^no_)d$5o`C z6;@+f?b+=~G+hJ%?4&5=muPgqthgGNi{-4qNz;6r9r>DE=I0i*vZl%>@|+}z=ndw% zHI1{yp{qlUMsM1NHI~p?(j`LZlu9sW;}3zXm>J?Zr>U8z=%ukRl2dOW(1iX<`iVR} zL(DY}JDLsBvzlr}gv{l$T`nGK)Hop6dEc|f#$xF_78D)lqZ2vmm8i8sckn?kE?eB? z5O>jZ=f2K*tu5^bRcl(SM|WT8N^8oTFzJ`$f1Ga+C%DY&*7w9v4?L%%q$EVeL?=7U zn5oC^Qb=Obn(Hi`?!=NRy=~|^%CrW38Zh{6vy6RHrNq_ngqgJl#v<_GSsL}h^~b9& ze~ryG3YbQSIJUw1zT$z9UNKTW#TL(rB+i!(1oC&MCD;ZvhlW#+?QP9qk)L%|E`^4F z^7NS!hHme&0nVVj<#C9uLjENPQ=mqq<f1eD+6OP}P%WMg7(KoqUq_?VP5hUXu<{SBFYx~VZ|dfM$398rp%z-S zxMI)Lt*}1lnVH~PJ-cX6HeRgq4-O9sop3?bnP`fw_+828_4ls?I$~IB@@t&dRw-^E z*Y55YZ4KsLP|ykZcxUU{;gXeu+#XjX%gg^XU%r?8b$aD>&LQ2X!+&O#Qlb#`6Fu{a z54r5WS^8T(I<{(!hZ!j;Q};Dkk9a=`bhVMUCi!ZKqWo~8WWcB4bfr6+hTOD|pHi|& zZ!7txBa1bvx2VM9d7{8$`*`l|d`}bY#ODen=J%Jf#oYX-Q5$A~k>)ZB0p7=o0w0o> z3En83jLIf+@I7>DEI#Z-d}h>5l2Vy-eY}ClH$m6NfHj>ZPbA;KA2I>WT)MN~w~aH2rtg6uBqS+V6v4N4vije{Ou>eE1o5Kk4-SyP3nY z6*I#4Q~5?@3g=UD>SPUDSVZ&(swTe7RvqjT72X5Z%a&aJ81yGpazPjav+u=+G*@c? zX-e=rl6YZ~W$y_#K>Oyh5LoPbtNj&c|19#bz46)>xN3ahT7BF_1^Mc>RS(1(zu4)d ziiLy%ewk+P@9wF4P8=LmJ^crsL811@^|9 zzBJ8BOT+-rE7Rk|HTn6zSZFPf)5q_P?W_{p?KadEy&z(MUXZ?Qdkgf3HcX0Gf!y=3 zPFRRsAW|=b8MK~xh%}jR;k0^t@1!F~BM&|k1_AH%T%|d>5o(AJcL;F6$8*-Ip8mc~ z=)*Y>WLm*fJL(gqdOl1%1&m%8q}zY$?>wKMjt`|l5q{L9m|q!O^xGLaQv)qEx^O|BUE&JT`bRtd^X{_(cwPJwISh^ zSH!{+xSX}JUTX(whPCV+hce__X7|mKuY$1vl7;V1V`;J z-;;>TJ;nlnZDQvxDW%om1Z9cGF;k1%q7T5pY__A;)FaP9&#TX4@v|?;!;z2fn6r>( zs%mH91)}f8_pt*2lIZ5JcNte2)rK9Gw4k$e%;qYW+MCZB-?TF%L}ShVcL(0!;J&V3 zr+^R_t1B}A`PTWbhS8rtpj(qWY$gl0qdbrVgH}yAz~k=j#8NgM2Nif+&Mukj6r4C% z#n|(~%{TgJ|8}+JBA(M*3{V$(NQPVS0`R$bRWy0$fjvvMw!Gf$wom!~WCBv1lANdQ z3j-l1kw^MPFa?0P&>1K*bYp(vXpE<)#%)<71|vhW(bJ@hixVgQ7J@1deGI(;HLj=E z3$Pwx=!68UXHoU^T@34=Y(7oon5Qc>jMt^Mwdf6lVX*fAtdy4d{_(jPYR90NHngVa z6K1D(D<3=OA_UAcKs{{&hH|k=DUT~tL0)k!(2-@RuHOyVcyvm5N1+WrsQk#$uqx&` z6m_Y7OP{=BOjVbex3`877+NLoU~c*qx;|I4T!6V?u~;R6IY1TQyFkxRMjuyO1;mA* zUkjz_)vX#HM?JXp`>UDT_ezn_MRcP9FR9{acJvPHz@XU@ERdCzJn+dnII1lKW1MAY zICXQi(uV$pd)?X7{|voH|N5UR(&0wuXMIPgsKG-m@nFLsPya?vu`ce!80_T+Kd2DQz3)c6Pz+evr0nqq^ovm#Z z`LQS6^2rgKmG<9+Ad6IuL>HdI#prwU$AiPim|(+C}v(N$D^&o-=o+$;0fdJz&mVS6JPPlG@|}*z9TB~98QR4)M$99iXKsQ;YZiwCCFo*bYs&gXQK`ikYRP-YEHpv)ENqv%vNkA zw$HIN+JOPEY3Q?f3cwO^+021Eo3X9c45ONf+Zv~^? z!X|8O%m;nJwQDgCnUb1=;G+ukmZ$9{JhF_sH{!+tUGEcjgmZ^#hI&A2n~jXwensV`+tcCl3(+cR$jnK5q%pR)?tZRdAK&5N1JrDE|Ag@2w`I{ zBFa)27cblX*C=Q!1L527;(EGBWcdk3@gq$uBe1{=N2|%VFiQ1(g#E2R00yedb}mK| zD^_7#eogAHDFKjX3#QfEZXBKqvpviO4eQqj^4pU1cra>L`OL`)-EHvyp`a+z=SP$; z=LYxV1j!;Q7Kx&D2e0V0mZUO3CO;L2O1`GeR>KDHSMTwaqwAC@Plnt{n+rjO@1>B% zQo~g37m@qx{cmezf^f-?P-_YuCZOn0<9(d!&vkvYCNJZR#?{TU;YE=U%WY7}+byn@ z7*#7njO>Ii;EN#N*-1PAUUS;cdETEx?w<8OR#d6>I&+LX>xhcJQ=pFkvhZ&;W>|z+aGlXBEd$WIu$Tx+n|{~6M~;LI z1l#uj#J=jgT6T63E~cSoGn_?B%rF0K5>Gv>SNjt$iWp(MRWWA^Fg?ZNtNB|&c45DK zliiRBO8wKT^n1aa)lPD7BVi&wWu@RV6uaJdq1cF*2zVLt5|O~>-$EwdTC2)631P*C zi$i2oDcJFc_ZntO?7akgdVB1Bm!OU7t=FKJm6UKehqd82QKyT{m*K=jq99p*|Mrcwk4LqxwYIKAo7Lvx z(&TFW?9{zY*h22tF&4g=GlOL^3I6SudPj17MX`X*Ldj<=?%Wl*@4sW6hxl@dT{t5*rwx8p=M)#Pqh;L0%3kGB zU<8#LdDi-&DoO{3I4$*wi;r)bD)d(~2gIPtWKSK^$5e8w?&kU9Q?|<_VYR_C3c?|x z#%SW3QVQ&ApkVuvK{KS_7+;eMPn+vH;r%bNV?Psxxk?`XqPXbn%;7Bf5|fH(!kHuk zXE1~Ml7c!7P|*ZPJX*IOe&iur^Ta1I2FI}qmWz_i=RRlsi{W2%8UB#s$rdMD5AoDU zU`DMA9r2PIjoq<4i1I}gwaa)Xu!Zn%BC-?jNVCFl%o825kP5VCc;Bk>h*Ly2H8gz# z=t=G{BD}Aolb}YTn2HKoV)q~gq>o~1bh6uRIVpiEpVS%-wD=K`u;!a|9&Ybh#oySb zH*8tmkvziS@0`V$^Vs244WW_;N}q9K*_@nNq9P-y`w6=m@-qnGyi$TEb4T=568JEhewSyV5YbH!P53U zk+sxGl&!0_hK*qHa3Vff&o@w`+mMI679_x70X@i&n8Qh0)klwGud2PriAINkNxjRCI|J zSCo%Qh!ET})@kA2;JI{*?A%pQ|45Obun%?$l?-xmX5pluxTWF>Lyppb;X7Aey_;iX zM5T{@Gnl%|%m{assn~e!-0NZgDcDY|5EVOCN>urgLdmGh;ODXqcbCCibrp`9bT)Wgr{8r}2a~aMQTo;*2ptRyBd}xNybMD4af|`cdp5RUmkC90 z3%+@q;4$-qb9cY}ZfV(X3za(~z4c(G#6uEz2XKF6Gq#k6t~M4gJD@ul_?BY&w&Etn z!1EK|k2x9SLlJMN@;8!Nv_I$(cr*UM9?S{TQ(TD`EO723~O{<6JL!SlKVQr|B<~(}m zaA)PRfoG?qvBn)aYs~%fg+X!(!4DgPF4DKlVOY!hqh=pDWYi_#RrPxvxlmnbDX{yT z(ThVtBpCTZI^~joa<=9zkrqrBJZLm{ePy=c;W|yfM43lOA0ickc|y4&F(+BNa=+om zyU$s`Mr=#`#S$LPN)qi`$6x0fzH-|OneD`gT$cl0*6k(?v0Ohbiq3Gou$h*I#@HW{V-*b*=4;7Ap#~<8XW8l zsl?QKgm|22IA(43)a>92-6X-;M!%xBe9^Ns#!Lk_n7*iJ|bx%2Z zVSKAxb(i=qP>u2@-oIkGFv5wH&okVWBtQxp9vX;(s1nPrA}5CzZ1@%jN1_~ey=mB^ zHKXno^ep#Mc#HqsIiVcHP;1>vh}PjCK{GziVqpt3UuKkz!Dwtf%|3En&w3SQ-Vyva zk)MBh=a&WxObGrwCa@U|3k3o2ie>~IYZ7vD)KyjIfDt0*lmO;jn@yCciU`>HCG+tT-ArlUJ$IpVUzv_mC28O~H4-md5K>HaV z|LyyC4lFE12I#TLeAhSYOLD%0c71AH*|q(AkPOSQw?*Qb?o$+c1e5qrCa!oov$JU* zi<%wA&J)czfP#(=sbR-#{zsV1r7e?#Hg{j`dQJ~L zoMa6e8D_JTk3bC(Uebd9WhLUoQ1Z?QFmuE$5A4>f7N3&^Eg+#?`o}KAg(V`uaQwmn z%tPuEGjkH)`GBFGKgQrfo;>KVLrX(AC|KV8n;O8olz`SLIo0(`NsG9y$k4HgQIe)R zceA`+%Y&dxaDG|ezzqznPtt#mZ&3P{MYD$@4EASvVut|7m>V}ZIM`!agGEY;+$nl$ z8cdoT`oK}aS!x?$s+`eb)cqdcon@VGr$1})hg|UBOg-p|Gm1PPetu=mXv~wb)V8>q zBY6uvRorUt{(%jvNCpV1bd{gq`z>VvHiiaJL!ewlw*9tK{(8jfq1-Xo^3+Ei zC^d+UwUK$9;)Z95sFSMxJ@>r-NAsRq$I8}uC!x;RCpirUn4S1dxBe9)qZpOaAP_H9 z6!a?6K6gsU$iOZXLG(TkFZY}r*%IrUWUariy9^x$+uo~8jTVTNMlJqjA+$yP`Nv9z zo`#)RVzgzxMi@j-Q=2EM<0pLJ#PUZ(Dcq|mHETEY04q@nLSrpSu?u%Hr{vq4~!zg558CuF}B0|SH}MSU&wq%VqItf zn=i^YS!pZuyrLQ+dPZV1LTasy3p6ti`|s_6f@ZJYreZlm5(9X`P(Y|U1z&WF~oixbU^0H*2tjc~Uf|$&EaBO&!MV^Mr;@m6c zz6TO!hwp7~)D~82)jxKJbu4-%E4D#8?10FINCfwkPV`?n5vZ z!IRT&iMy-YHGmPWecMox>y%?TA@kZSl=gP;A zD!n`543~OuBebt$%!;|omJb~XT{DX(6Z9=tWt7e898dM>s3CgD;0tqj)l77;Q=ZA^ zAA*Nk)}=LCs@DD1`ZP1p2!##7fe zAZNZ7*)A)9qI?mryhu8VmQKFk{|WNGzeLsX)F{pH{IS5dE1@C{_F$XO2!*@+QUS7@lV^?hKXkid zy5aeP$pB&~gC)H^z3C@+jW7M&qNh0Lcf*mcHw8+&!vnUrmcw*wmF6DyCmm#zm3fc% zzour36^&uOTO-R?7f^3LLk65(*sx$l>*vLCQndDJOsw+9tQ_gN3c-zx2U6|K5kSJ| zEo33!l*stop+j$2x2l#TMl@WSvXElu)7!InAlYzsCSg*a7}FgZ77*3ZfhTWPzwq8| zbu1Z)?uu7sS|>lx7%G#;f@Jzv^vUx~p7x%=>UHiphMm)q%loqgfD<}QK0^myC($hy zS`B1dd&8zTT?jh!^`tL)En0wuI{Saf)TF@qKjdUDRDHWA%tVNUKlO<)#Qi$jYk%T5 zJF0pG!&*RW{N&dsik+Urkq>WQza>sn*mh9?0kh@`lo@vAZ0CjRmn};yBeoB>+nu8d zm2G{(A5$m~RRKtws0~hpGI-C>`2D8&9TrFoco#N2G$e->c88`vK$({B#1$|AV9b|v zeVLY_hACym7JFd`QgcB&>$r}S6NS-hjEtzfr zol-CWiyK!`dU`&lv!rV!2KC2B2@i>N_~bws)9W3nSzG#K-xOeT`Ll9w$v+k@!{;!b zs;7t8A`Cko$cu(c0Ap_Q9(-3QLxMpA;8L0uA%6ah&_a!J0iM(#4}M5q-zXMsr0j`& z>G3VMMOa`sqaE2Of=K5OZ4%6%HQ==h`Up;ecYEv4OYK;a(^Q|LRv?^9rHmy-^v8=> zTf<}Y4@&$1_`+brvrsc3Ac}lSBB}_r?7{!0j$=3vbR$;T@OEx<8B7}Nk*#x&s&dEO zHLnfHtDpof0(gitS)5cFY#eAmLkyq-C>$8Tv1P{10>T`vV@nMvAUV0w$cFs&E#R#q zx$2$9ep{I6k$1|nmbr6RC6Q^;;re6EM?*h@BRj7h_lLtO?wuLT?ii;tV;z151{M

KF)ldw-7GGVVX6+(;m6`sEK~4RUg`C`mDNQl9s|T%r&AO*h*9JNC*02znmbU3~Jsxu;88XrgQ?{MlNJ zaL@61=LW7?@MTNlJuPkS@%(6@Ci{SEi-kr56N0C1*wk-aK1cxUI}WcbnLLJGMN(j3 z4|dQLK+-zq=<2k9t(yX;LN@tn)1`vGFT|Yz{(^nepY0U#`Z1dQ3C4Dc+W!-#~`A1iL$= zO#L6kLEXn!>&`qeP5Txii>E!JViT|5FD zT_3k%{?cFwWCuLUm-ujWp+4jdDGodF4N-pUBI7k z0uC}!D6@!(6hMGP1-o;|xBQ<7>TB#}9oO%CslW7xAPe~$slRH}c{&`U=S@mJ-N{tP5|JQ+lSj7({U zEY_V@I&NZi43O{F6{sH1X%(+uNw=HU{@=2jyPWeb1HaV4!DV+<@?iAFOx=g;YJ-Ki z$0%G;5pe*zBzw2(u(3~tg@@D5F6N6l0Cbe7IP3npJG@CN%WYol)tx{SrfaWq(RT(f zHrV!rMt!l#V$6nq4CPiBn}s%9-FHAiG*o3)an-07kXx{^#l7K7Md!)E4NbiJ$(-Oz|CSiTIxRs zlrKL50)dx0wT8{Gep7m3`SHAu0XXbXNO@=b`^L>fkrGvv24J|odytOVOV%5KN%)jJ z9YaG>O3QOyM`4nC8m5F_JLZ1E9`6VyfKIMF@p7H!D%nhN$O0l;h&`o&vg z0Vr>hbh8r_h9YLaFy?c1*yTU19}+$fg0Y|^7R&p0LZr`> z?S5H5+RC?QOYUQjM>on8^;umuys@`%ZXcq!r`kkl?gi|*r!b!p?*82+HSJFRbtA!> zQR@IdmKkKBRCI{kVBF1(rC7-VkF6==m)60uKx{oDF>O}>YC!Ivl6mU|pJ|6#P*!T^q* zkMFv63F_%&-$crwnXo3^L} zbTB{sI#gh1f;bD-TrgMZK{AQ7jChfXoczl?BZbe5zqiULb*G+Et-4ayy+HlTmAh2QD|1C8pg=3v|)1z-aKwvjwp zdg^S6{J%m6jzZNm#um2?=);23nL*b>cf|>?Nd2gjf`{*`M(q) zcY^RN?_e52#g;_^#7n0)SZGKo)=OQH%ZzzSTjv7$I}}Xe3>mbNvEkGN{O>3mQN6CU zO=d#`A4vTt=8SpA_;$Ezd7cjz3`Z?p|d}R1Ix+eM}aW7I(*HJEvOG}10ZDhI9Nb9EWMtReE0Z&N-8vu4@ zTy!2*0D6*@MYlgsnv9yj_K7j-DC6PM13-C{fx)QFZnTUka4~zR1Efq1SBGQ%s`Nl* zS+w!u*BPSiiL}aF=1d z>SRE|m3P{^V!rlghW1K(VrOS3E+a!uMrN6onOybP(Gl9_X7SnAUGHn8mAu)LtU9Ne z?Ho~EZ7O)AA3Zl!)+RUowIdC$TlOB5SM}v%uxZI;&yzYkPFj=luZYgJ#@H8{?8uYq zPX|hFU;&mWD9*M%7+C^W%tD01Nc!(PwV%BsGc#I#reusP){f|SoMuc*%4|;+=zmI{ z1n)Atox}DOIpm6@%W}8ze}65e2;7)#Qdd=GXAIm)N?>QjD|!-SmkeXh{TQ$P3C8NVYv7O+ve z-_u~jR{?O#C)S%=C6hjkup$w}z%Yiqg*A)zeHq!(1!3ovA6k)PjxHr@Sl@d#fk{S&TIoKS1uu=a20g5SDomS@7Ia6vdDk(>Y=% zw&B4nPuB(Pl~Zc%po~CD8b#=H{HhQqHE92A7OsH>4?dgCwhQ3Ng{3^@N z%^ntxGAr0MJkZ}Zt~lEiP@Mk3g`B&$P0JUF#4za=Y12P@_RJ7-vw#ommVLXkzdpR^ z5bmUt;`ShvdE&D7H?8Tn-KenB)sC1dVc9JMalX2^681uQ{XZ0xF%Qk}5|j3*2+*)e zNwxF2#a&{oJ7Z?#g|sLsBv98Ey?BGcE-AHSkq`7g0?neIeOW>QX4bnER{4&WX#`8{ zbIbLy32c&TdHIG&hY273-?ZUl=>f=F(1FA_UzXQ2D}eA|I9WIbuC06>_Pb%Daw&z& z4TD54bSDjE>tH+|9TVdjRZ&q9kPMtPRzRwN@d1EmU(|KcJM*_%nSy}Y1F(Ms_$e%-DSP<-PJTxETeVn%<^x4S z%1)v5`wbMJ2>k2I;T^ZkT}cf6g>J{1dNC#(7B9}HY_-o(|7K`-S!1~+d}Ub7s|8%p z%hI^}ef`tGo5sdXg(5Vo&%5-1KFooYb=hai0ECfSfJ2Jg8+s~HW%ev&Nv*yI1Z07e zLkT9-pWXmbU95_ea`cpe+wNmBDNzu*h!$y~No^C6PU~MKQrRNhcV_qLs@|quY~Ilf zs7updHd~}7o4yU{*8Jxau40b0L+*JuT=d^;`R>cs%(809*WY@o?4+@2ah)umS$Jt7i7UUd@7*0RCFB65ASx~nhx9y@ zu1@VU&+1;GRRN^w+rvh03ooQn>hz`q;5gct9W4QkME1y1eYNEiAJJS{$kf61s&>f6 zwY%gJ?D=I^yNbzT|M@Ae@+8jt%hg!D1PUe5st6}zZ^~kT#T#6N>?j0wPs4D znl+u2N>qMJ^aJ><7R9d^fXE-ICW>5$i$Bs#HOXqb9xbXK9XgKxXewCSD@E5lJvhr{ z(yC+Ld`q8?tfph0PoLsZ!|LPorY-+CPjpBe(el+0^e&mhf`ZP;!Xn&_C_?uPxQfzF$}WL8pY28bI`8 zk%O?2J?+1>0CrvV8T^FL-8aDY16I=u?)1ePnmVNz1~UA|I~p*9UJt1VAYD+#7ne`< z8#^sfyCTxDU;GT(G%_L-I76eBe+U{rVt5hfr}*6C0<8g`Z}03VC#8kPTdOm|f1|W75Z-dPX1|d@dv>%QL-{9&%Zssy%;~S0*sFZns<|otw9wS# z&3CW`A=m#VGu57gUSA1{d|(9LM>${Sm;#P3b+ zu`o8=@^1}Nxn|u#W_D}7J3(ZK9d=BZ2Q)Ko+2UOjY_QXuZ)Z^$PNWH zb#!TV)+=?`H7oeG{@nYXg!+5Mrn|7p9F6OCuVn#mTt(=+u^K4+u)rSXdlYINk!;I- zSG2cbu5=1w(LYQyzkfgaKt+YiCNsd9EnYb;Sc2f?PW6Pj3ei!?=V}d(5I$V}%$US= zjfcY7-GP024f|aA&W&8!0JEJBwQ@wo4Co-XRxh(78isC@9dlS0Syt$i2lh1{H*&LV zT1&}=Qr(oM<|(zU^gpRq2Ww(y{iOLXy+x9|Rf3E_Ye@Y&eRT*c_{r6C;}kFfeoIpg z(B1ju=Bfnv{dls#zKsgt?wPlj!VotWCb_uKqfcsJ!#mUJ6L8h$rKEldQt}&{@7|C3k#AM4$#?1P1{uJv=u8=I@-Gd< z+K4^M{}oL;kv8cc!*PFfRdS$H8nt#qD7{7f`j!Vry^nviAGhe$G{85$0hoZ&Qna}E z5_p3csiCX@PYzuu%i`XTz4d+uzux&$T!91_amix~s$LA|a%rg><;-y7cqvFU{;fq*PH&eaUNZHMi$%z)x$oXwW!gFk^@#zis0;dGR1@p&}7y}U-DqX zb=T8V&Xr@jr_DlRgW4-zGG);rrk|IX{l=*tH7x$xBr){ZWh&X>@3ul$r#LatPs!;P z{O{rRw!$S-G|%@8TZnl`X8MN7PVHr4_w#@jyatHp{DZ$cj^y4B2~fo139d2ViH@x@ z{wQ<2WHWh&zOhQmfO`r|Pq*IO+{A3J>`v(LU;AcDtaYCeMN1J~iOrNZp3;lK@(5vh zq$i1@{m=7B`t~5*J9NulL~$EWiN0r+#742<16(D>y?o&6U-S>C{l?*v*JcDQY&ET?O(}1Tn@j^C={9;8nNZ3h6W1HXu(4G=>RjFC+H<;-en) zC;DP$93GnE@38j+Ys*}QvJsKpu0DrV>`yG<5k%h2{O8Gdsa|wS8-%Q{wvc$q)V%(L zlVkvApDr#O&)khzM!JRY`kiVmSUFmmu?b59-=abHVEgVevF5;2zjsjVWu7>EDevxn znDM_80TfRgnydu|X)~lz7O(y|a>rMnx+k?!ssdg%GiJn#Gc>s#wz>v276F*Eml&OUqZv(LV+y-%&-!%03075dqN zcf_a;5J+%HNH;qkvlMTAm`BcFxGBXVX~?rj>K!JL(%h$}eAFuRDgX0Lpk4BOt4gJd6H@^SMdZBVKQOk!xe}+R>#}X9 zSDkO`tbUb%LCTnRpE+=4Y-WW%RYWgR{qS-~7`?2Pf8GO2NgpUQeWj5CSm?kj$xvVl zOjH9EM>nUzA&hzOr_YC09#p#%G~v1Qh04@p4GF2@o`yXIhN~(dy(f%rkl%@36bP5h z_B%aQs*cgmc?~3|T>kqxKnqHFYc5kOhsq(?E*ZVaCYG%8`W9kJO9<;mm#U5}SyCj*R^)$^V>*USS)-xMof?Ndd` z?DwxXvn*b={*6XHBsw-$UPp%`^@N6LXeCOr$Q^tkglcSV#8t{5;@7c6wl9`y;Zxv` zYN)!_80+Crzv&KN{G5Wfn)l~h(JDtVwrpEA9O z2gG~3`<|FT4NEn|9ZYMk=Ge7Kx^-q{QRN>&t4vk-g8Z`xGfi^?6QmZ70(B_RD2l(q z_`-J^eKbo@impsM(#2Ekm%RQ}Zb+P%aggfpp@di;3~osF$fh>m5&w%oBZ83|SV_nt zwQ$VPTrH{FL8N7nGWW~eX)|fX6q({z@;l=V@*&ojXsFaacf>YL`yX~Eik#;8KVDTV z3~_j#QiYj4oHXzd8- zvOnFI**GX4`(!?7f1X-vtJ1E8IenNHNm5{Hz>;7*B0-x8Cj9w-eUd<6>t^yWNr6;M ziv#Va^P&kXZ%|9E788R_oW93vYdTuRxIbGN#MH-_HgfayGnY+Aqp?&u8=XImshrtx zcEG&b!ucDWdj?SiPwe3DH${0l>|fh#TnqeI`qQa$GdzPU+KZfAM|fKdM4TIOHVpQixrxXa5aE5B zf%oS#1QhN>rzLf*o2zu*k#wO(vc)L_zZneX^8w&{pmOsf!|zmE4v0lyK>ZK!xc2Hx z_v1x+>8QdU(Q!%01DLu|M<0R~cF zJ!5(WVBEp_*~);cCM(;`_JvV7FJvvYji}mBbasDW@Hr!guXy6vO9uLw47YWj_Z)g` zmONGl`!2D4LqmuxMgp8T%N8Pvn#TG{)IT`k%Xn5A@*sebczyo!!}#%)gMFiL(k92D z`R>uJijlQl@w*5f#t_*rrY82F?C|WjTns4Ky0?P0*16vacqgAyZk-)K)5S~kH4<4j zGRwXPCdaid#QBG}=_B?03zO2z-DhJh`9b}5u3?P0FzH;LA#H-k53dfV^r!1RcvZYp zC8bPX9cZVcywkCIlUif@(i%$rwz85#F82H7L+AYu#>b(===LCiZj!P;9Utf&9wMgz z6@RIDHfaio@IcB;z?r!UaU_|N-&<_)-P+5j`cg!!8X4E$&Yx;F+SA!aT!-ftYdGv^ zZ7IEEB>y&SJzL?tCMD%{%6;&;CL44Y@w_;?WWj}pkS zM2?s}%!^E8Q#-O7Th%SxU==sA8^~h!eSAT5YO1)LoK(w&v6RN#UFTJPd6zm^4;>{Y+O^S9~)( zj^JqlHP}!;0pCds{*Mm4l)NVi1 z6T6RXLN}mJ0$$P`UB_y-KnQpRcIdku@yvDm_dcV)yf89udKbnEl;hjs;Rbh;EHBJU zL9Z4ZO0J}?%QXbe&~#?Y7Tn~c>mGvIbft*+7M95PauLzL4NWSy^KQ@7C%fWE{qV`r zX1*nYnWic6e6EIC#ap3eD)jmPY-)UF4=kcpyWGrfHD3)SksNa6#5i~d>(9xBJX2c1 z8jldHLAAS<6(*lE~tmNmk_Ve&w0Kze~^zy)8D3TyS`@I(gTh~ra7n69=4-5 zftF;BQ`Waxij`ToPb)>40yVO15o0qkog(%NmIJO>s7#7T> z$#>lC*jexxY4R4opKU7aGOLBn45`#E^l@f-ww=<6ZgzO1eek@qQ`sC|=|PBexRf?U z^d4-EuZWui#Ba_e_qZqVSMvFynOw49&{XSXT8nPOigP`d@KWtZUTW7h6YpfWpo@+@ zZhrdsuP#p-GIu(c=1Uyg2+GbJ^460s7MC*5g;~En2tubPcq_wT6}z<3X3x~deRV0# zSs9J2x(i!^c+@>mbMd0mgxLTZhodKZA1%zPx_@Ypd*_^s7lqif#eg`jh^ znP?d2r@8dCUp>>;taCsYgdgTfH%2=0XW=Ghct66FCP&w7v0+(4@c0K5tG_kdF-qb6 z+E%#Gz*&bd2R+A%r?=An>VEk7!aMp$p&h!#5J>G@cI*vm@@0zN>vU|>w60}nWNh7N zEQ#z^ibETJs7FvB1(R`kq|3VPVG&YFV>40|?sZ6x!BXit(Z$W1rTD zJ?cABhH7hQ*?UrZ_(v#-j_pVp;Xse4{F;E1R;?5E|D>Hp3Y-`Gwu*Ce06lnW(i*ky zJcfDuUp6r(DGQ&)zBqS@Hmm?9sPhaa{PN)%rn=;_U%@(B^?)mJvDYMEN{<3G?!&?O z?j@S{{oNrY=*@+qa5$Z8tLK?c25q-~s&1_V4K~aC(m&51V5E>Ln#kMoY=|XMXBa4D zz}^q&&aOPA>rHWa)7$GxF{qBiEIC*4QrWnh*%vdU#ZeQQ^`%T4wX%`lb$-#i-@2=^ zn%)jJyYoe%@j4X+CE}6*jy2b8#8V@Ry>y<$X-Vhy-;(wo)P{==wjDNO32&($D!=^$ z%q1O5xb+lp zdr(QjLO+$LarD{zRIki|ABDR}U2rvdv`eI1zVTNJ%&rU>RcC(xPH%=LxUgnUe$`yD z8k{KY)l+ONrQB-DvgmQd0bUn?bSHi-`3@V&TSvf~9ItL=vIE5+DbZ<{wVp#OGltug zc$~dgFVP>MgbV-u_QZo=@BFRhvDl&eF*y(5wOHzhXM4=yA=d%rw%m$w3vsG`k&{1` z57EdQ%GCbRD|x3#$DidGAp5Vvb$dv|6*5nz@OA#Xp+9T3;h$Nn#d?32UZ1{-YRgNL zMH-=_>yMt-)3E`e7T@q0BDXHgGJlGnCJ4FoE&#J7Of;|Xy!(9UdMFE?J@B*9#&|cC z=3lcehBPG6?s1?XYBbibj&m3&Iy?HOU3xMu4Lcr#RbJVxx=z~6$blN=!WJKeEkvID z%2?l%NY{(I(q0ayLzw6BnoHVLzR!C@_scyDtlw&syieWlb9fFey=I6+lDT}%LGQAC zKhC5UJ8GDhaO91in{TE2`Q|nDBTgWS3W!7GUyr#GE|E08jPG@~D-HUjgSq-sPd<9K zQrD(JsXdDR-n>#k1Sm2nu2*{?y8oSGkTy|QiXz;Y@4r~i4jY}a%9fW4XZZQ>=DIDd z5X3C1h=F}PQnT3{Evn__5ekCB`H7c+OE)`?r!Pw0faE!onw#h40S-U>0ZPJsb>8v} zhp1QY>I5_S`H~sA!y!5n*`!b_w)f7wzrD%R#yS*q{GMf6ZQ*%&RS&MyK#QvJ?_SR!%aY1zI zu3HoyRUiaP_RkK-(Q5|+49Ti&=HsHMuWZ}#GZFCFu=Oji$W2yhGfg};i?`1fFZJpD z)ao8o+a}tC-@_{3Xx68tX1a&Va^@Xh+(p?9_j!F?YI+IR;fza^_UQ-i)$!|M#lOlx zL%XRk=)WpGuY3FMrBJVC`LRtLVT4$Z;$7jtQ>xEZM(f6k9J@Dt^XIE$$T{*KbW!U} zyP(MLPOR+{cDexnQKMuqdO`OX*K*3x0IoN_B-&bO_(#B`-(wseYpz^NUAhEkvruOiiuqLenOjQ7 z+o?TM!$wnM`+A+c6*L^+&$54V?Re-eu|HclQGYPXjG9#K`l59>bL;D&G8P_w-yDdx zu*mp9A?3ZL<7PHGPMsT>PXr=CV8Zg*xl@Qva`FxcK9`UA^ncD~GQ7`&h*pwo#t&%>vlRm8X$~vnD3qN(Hh}K+gGI2+=q3H zq7D=O@CWSItxyM`Zjy`e?MB<9t{~Whqt-1wexqSC`}?a=&a}Bk{c<`+u+4vfQI?=C zPkt<22P?u<=M3V~)9$Dx`>VFf%jK-zFi-CteamfUdGAl&z}>onrHb4D>U5d7yx^T; zd~D^y=~->Y2M(_>YIXboV#Iuerm4)Xy){AZ#fodyBix0#O zUM+N+sjbdXw630Kcd)5H&r3Fm90DP`K$hNjx;?dTfVCNN|~nxb|B2|#+;cSLrc5oD&TRF z1FaCZr{DT~^*I^bNpriAZ{oMXA)nDol3GF76*8)Qn6K%pvCbPz??iW58tysJcDq}| zaJvj#F`UUVx(^k__FgK{3KN@huYo;mOUn%l6E2|dSz~dvIn4YRrX$4kV*RM+8bosZ zubJGHJe;;AhR^DAy@f|N);9y4%EfJLoa<}NwccCJ^{ip8;5|?vE%3Jfz8e?UC6PgC z&zryCAiTenz0>AP%ocu|bkOKx58~e)2eCaOmm$qsCr(B0OKRjMIlW|A@3!Sk@>^#( z#CkS!>he`I&GFy?qN~w|a1B+fK7f?P zbZe79BXrT&K6q?SEEyU2E8$ywFMkt8D(Wu!n}51S_plYGlb*3~J+?kFb#8?f`i!_h zL7?8)%(NLXLx}O=ndXX5f5epCz!-w@xNdm|d-HUJ7xBx&;VlYiTCybyYn9KWEZxZc zlu1<8Z@U*jD($6QcDV<%Nx&$quoxmJ!Qa=v+uAqAMQ=LR*>$WE?02{Cc*ZkR9m#g%e%>j>+AAlyu2lPBvGjd{{ zLS|=&;;EYfLD;RSA`$35tZ2(!7gT)J()VH^)u}vh?fVtKd@&ixd5^{_vSuY@1GGs@ zV31ltf$giZ?{1v}i=x543fs}m=u1!d98SjFS*O^PDA+EN!!}P325gDz*-t__hx34w z9RKqv7T`a~xa3C9%h$Sbv<@~w`c=7BuN*r!jmxG#O6bo19zUgY-b)mOa-LnnAhY6j zet1KEe5-O>mUtZ_aV9WdHG&!J$X}sC8^=DmAdGDXx zYQ@Mrc`x~#?91S~?eKy;A2;yE7)*AA3PiYQ&aj-_c1vJP@S zISLOrtv?*`LA4tTNVHpU>xS|C%aUY%C4^wIorgc?)v*InSPe4Lz1o@(M~2VHeF=1` zqyFYRloE7D0Qy4-gZhDA)Z>q%(_{V3lK{*JdduE5z;jHiRs9kqPeI!fKsb}d3`rZ$ zdZJ>62!w7iPyh27tNFuKqLJBfqk+k5{qM(1W(@+IN3y|Tzvk|re7$ypwNKkJKonY4 zwCXQPjY=h1f;^NCbjFS&P??Iskvq%T;@od63+0U<0wZ*nQLS!!d<=QcG>ak|2GQ%K zL5FKb)A_J+dWxedba7na&oJ2bkcSj=5BO8J>y#%*)0@Cw2!g`40)TWjY(t?oFZ zgxDX0E#J>i{4rj&H-e)Z1;hl*d}$K>L2J^n7I;N6sK}+w!=?O0#~q(2;-2~L`CmVi zkLD9qjI3ih@~0%@a`NZ~3waT$@(PTv6;LhIO4}QTQl}e?G3G{{E1buqE=<0Zc`S(( zy`q_SZ3Vr_zL=O9&NzomWycd1zqk+G1!2?G7v+_)wKvR&*2MbO*Op4fJmSNYs&-y$ z7uLC<2lkETGi9IJGsUZkyt}WRklU%%Tzt=;M4fg{xFPi^GT!a_D_m^z2bEb)ZiQpw$XEurto9!H z3R7;<1qcS3ZF4juWb0|e#a2UW-HATD%<6z5qvb$>y8bx9FCaMRdNm(r;1k9AZEj<= zm%r71j}u|2zZngo^0_zW`7bN+i+91j>r+wgkZ%9*QK>!8PpyR+S6V_jh!UMd2DCS) zPz6ij?@W~wwws7(pRBCaLb?A)EW>EEwTHrv^N~z9@O2k?X3K!dwi^T^b3Qg%zw&&e zRqawQzAE3M;m9Scw9I@Lg#6fFig^5XpLAS%=y~P`^ zMn^jS7laqE2=`8O($nF~a%4hj-f5z`%4$4GcxUHUz=zex2b4Sx?@ly!1*l680GU4i zZO4^yV51t^D8Yn5$KVy!<3rm8tu#I9jp_cf@g+9y$2;h-E?@meUOmMA6OfNm+on&m zWF;F6{G)wkd`x>t_BJj=#oK5kbfe3)`Rvdgbcf%}RIUWp{_6JjN%D*qSK-pPuwlMp z1J0wE63JYgCmyOXkYgP7zyrwrv)kDMFJI#nqt$dv zwLx)izHMXd9BU)i1^h?kwf9?i>pjt&b1pF@BEvH9?P^7FVS_c6iJ6XQYYGVAbc4c9hCa~*(=6g-ZNB6|Tn_?#^v-40F+=qZ3Tc`7ZIGeVHTu za&p@U)P{Ll zG&(LfjO}Sn-a*a|y4l;ET;A=a>TPtfje}7{V;JsS?c2L_MY~3whb&#Nd7qpT~z@pr-reFiMLU+qv$8y)ol~ zQ{g+@Xtc20ro&K3_tX zBzNGZ4RbX41hz~vvBs5--G%0z)Vh=Cbitv^8{x`@S#H0gAlV->22rw3M1s>hb1Lgf zYR~lkRZX?;JW$RmYovm5}FD?R$LZpT&>+xQ?@W}3W3GP0*~XxHGG zTUUvU3BXv_?eL)D)wPx^*)m)lr>6WA(2FX2t_&G2C0`~7zWku8+F}efdiRIyUaf)@ zBau71UxlK-0t%Ucx}PIjXI#bQIBcJJtoQvske#FNBy z(aMa@og1NwgmWcB4aOZ@v&N}aC@;b!_&RGJ?!S>Xw(yOU89}lex_2HB8&tY~lB7-E zfjGO;oP)o81eo%tA`B{o<1{7sj>OyitxTh?ulTCbMgV$e44n&94f6QJ>zWq9K)ccJ z=!))hf1!L$LBbd+LQdOl@1CVvf;u-2WEz5l$TT=`;>!E8;uURF2!0#5hMkMz(Tv2j zzibZK3FQ$(vey9BAOQSjbsV=n@D4e58Pf|5JZVQmHxIsND)jEimn!@|xV5-%e)VPB zn1M((yzPG!Yr@-7to3u^xE3k!ch_p^07ckWUW*Pmkep z@J?D%_hlmbr1OGN35vw1xjq40F{bFfOhsBmeJ7Hk@!0Ts)94`&AH~>3Rj>1e16Q6* z)ktm5-;wXxq)q`-fY7lj#Od*Sjlcs@V(8t9 zx(C!)nXta1^^qWS=BFu99YS=X?wGC zp;w9#xEDs}KpYQC&Pc@wCgj;yW(m58L-ddm3z!O2N_$h?f#$ZWONhzP+4_JJZq~c@ z`FQJ_R`+q;e+-_#9B)4d{CtN?R;J2)E!okRelI7pS_D^JpGFQs9ruo4>nNk(TeE8C zE$xcY!~2fGtDfV!n#S@A5n@I(APy6QDkoPo+iyB(T%>CBZ*+FpHWeScXtE!>12T;& zpb|*-XGh@Azu1;cCq)w(k|btSl(KtLvTy(5@%~FPpX5_}NhVa}a|n$)F^4nX1VG#L zegdsKS?_lxevA~G4`GsQB%pcW0Q-rH9%&~{vBpj8dY2%QLc7ua_;+voxt5F(#*@JB z^uVF~w0$34@uBRe`D67njP`r+W>CA^-n^wymX3HfmvJ$@7>Rt;8>B14_@(lg2NqQD*f&ilJnr#@#z_ zMT^JNoU3hEvR|V=3?N5;Wh!H|1<%MJi~+92Nwq=lGG&ZUthj9d zUR)+@El#4JQvsL^L|9aw1fyZl8ZVs$06zZ1@fcubwY2(wL9t9H<*mjH|X z-EXdEJNik(CHbq1r$f@jj2QtQQ|vgFihn;pR=r|N>U$B!rkotm{7$#ggwwvc(E-W~ zC3W`#O}MMbJbBqpk zaHoi|MQat5MZY$oQX0oh-?WFLJMrz;!yxypnI@WV=hW81FO_jzu1}L%_`yKCFSHOVNKAVnNT+>BQMjjN5XWk6GaWSf<=(d_@wqkRu~|~z_t=|hq1(DR)IKT(&1JiL z?s`uZQQ4O_4L!LxdX1LgzFV0$`{IG3SDMjK;&X3+4Wty)G(>@7MdDO{zSr#?d>j%C za>)i~gQ%sr^~qW0;`AM4lhpH#fg3dKB(%Qk9w2ZQBhi;&Qo`Rw6SrM2AXs;zD31)#b4%O&Z{!}!|cx`i|W&({%qlgK_4gizu3E>bsQ);jR* zR=z@Ok3igp;6{D-n&sGQbOv9bG349#HjIu0)dJo3xwcA^xEEpNPTVN%BPHVaA{}+H2bcV5>r44Kd z?6xMZIuLA;-&K_NzTzJ%R1prgmm=wcExmmHBgkw&VmgHTSpPN>4L4ZgmGP2*AESn` zo8a1lH_7sC7WsWqDa!1oJpDMBIF{W=VLs@?n|XD!{82VEip`OQLnI@T<@53B9t=>@ z$Y*BidpNXUk`D%rc#Ow_$d5qG$_Wb-8WRA*OV1G8fOvI1n6d)(xU9rJ{!0bvMJ%nG?~Jepyu0u2CP{ zJ^P5a`t2#Uz(#vh(puP9N7M*Iqk%(YbW3~^uY&LXEsfGX6Yk@*iyvs1VG<-$6{Y1x zwJ2>DRoxmc92rz*z#&tg&x~Td!t1;sxkES*+N%d7`(rKJH(u?R=WkD)chQxkkLOXf zvT1h!8)mn@*GbW}do%{Fas!#FOUDxly+*h`j4Gc!<=3UJyA*C8g7Qmt;_Vj``BzS=@Rv~8!Q z>K7ygU`oNBg?nR!sX)#ZyM(4{e)6H)A+BjlL`5848%O^&C`tC)3N!3#;v|4f;G;~N zVztbUm9Gvv=HSk$`M>MGi|itb{#C4m&h%~IjX*9QxIE^hJfH0R?h8P5&F{U&{-ik@ z;k&y4W(dRi`mFS!nc{Ve-_0CoCjIY}?aS?WW?dN!1yt(y&d>#(UT7F!9jzdgSYGBT z-L9@a&9~jMK?1f-JQomT7TXd^uDah0I7CAJF!E1FmWwAQ-HWL)Nq^~h=Lfa*BMsa8 ziGeEr5_b6w+fvdVbAIc!;(2oGoyV32O-L7d6YucCsH{`gEMr-VS-~@|`tc)kl(5^ki=9k2wPS0;#5kqIfkmm0yR22lZ=Sa0OzwnKteUT`U`Xdm7|26( z({$fL{&?Y%&RHjZZAdexgT2Ce)oTkUTzD_iy)<2w5~231Y>uak!&tj@qdRL}aBz?(7N_x6Pwx-IWQA3m z(pv*m&kf!?9KvXNgij~l@)}RT0&TY?>~=I&Lms##cQpwT826rximpYri(bn6S>yjE zF0;=U4Pm~^UCrbWy0q~8rFKJWnEime16Ty8W$8mKI6~@F2r;HBjSWZc|KwX7E-i6g zkZq+V_)V-o_0))XqXlz zy(_CXz9cv<9c%pYTc=^$L9M!P`)SW8CW@iv&F*7A74Gdl!0mQ;S~D{S>xd}$EH-QQ zr)GC_D2a>LL0$whD@m@U#by5QTNsp&GD@(Op*+WOl>mGHI7Ep#$;WEP_bTs(hwxM? zQyI$3qNZ}*u-v-&abFWxjv))0Mh%t`hxBOcn3-O=iTScQ$&|_=^JB)8@C5 zy7-`dNAfK$n)^jIAijboP(%Qc>EXXqU^!0V`}@$?fTv3fy}NJ^8saLYyXL8qy0zkE z_PphVzA?jugHVgi--jnhk=K-)MXT^sRub#V4zs~UUCk4uWP<|U|_^;GM@j7EV{ijKEd@LY}G1l<09A5WV z`$Q9vO4mA5f$LQA_2JaF^`=9gN<%t{Si-%u+d$0^L{TPm3@yA$&2@6%8uEx^F}7A?)*7uF#U=9YT3>< zor1fsf4HOKfT%P%utwWHMfVSD$yMe`%9CqDNn@B)6D%VDHd|U|S&ixDL2Cly`SzO; zbTaee^r-Y^N?4=WD$Uv3c2*6vM?kHn4_`&rzNGC{5H29>;KB*i^JGj+bHb~!=5V^O zZQ{0|2LPJ^`?~`Q?mED5C38Q{MHZ%Moo%J6S@AMk?s~4F~V=Cs6a_TktM|z!KaibsW0EW%-XF zs<$&3^c3$OHa+Nx;QFl{^}?SmPu;=^d7Bady{0n~)Q_y}r;1-ORGKZd2cXV5AJV@N zK^i)6)`7DHt zfU`1sYdXq<=eac1-F>qL?!; zBqBSi&wN}(sdd(mbF&P%_cDFxi-J{O&zds%-3nR0@_C~rr>?*wi-~!*KOYd==!mwQ zuf?g=WW->qT4{BKUY>hE5jVcGvL365Rb3s9b9ro)u)nl=I)ungaF zh-5uC`L++6uQxnF+k-X;qUb{o$B&BB_E5jGxeWCDbLT6`^c;v`Q^I6U0L}be^2`i<56#{D<^P+W4LJ=z7zlQQz4<%vx|Zi)UEq=p@vvAg5j=^h{2lco zTc9CprmBtntZeo1!WpNt@&WinA`%>kqp^l{M4!NZl~x$4Ofgx^k{URkh(0Ph|v9IQeVq5 z())KI$ZXzjka#Jsc7$HWAaguI_d& zp}^24&EIUCd!x2>2Q|U_K(z|RufYWkbq5cN<(q2mSzTMdbR1u(>i4u}(+!_3UbALpDcIB)&+dMw5{ z!Oy;6_KJam4yaeca)@~6Xbp06P>p`Pk#fV4f$0*MD~meg%eg(6N`tpyeP;-o{^B|19iXP6EgK^77Ni6|wN1C+z!*pkzgXex6S1=SsvJJI z>ldvGE~+SJ(P{Ph=F$IzY^Jd@QOZc(b4tFeWKqb9KRB2ZDSb@iCDSGvypHKR4b9WC zVxAt+?U%n_<$veKXQbI|Aa`x4rvw{y*`}d}r5~&TbGpx|GbJdPPaI71DTl+w^3}T0 z;LvGjIu^Kk^9kQMqRg@i%Y}*PLC<1D9m7(Tq2$ep|d|_~~IP$%e z`aFxDd^&YFU(P3gGb9jNdG_EA)>$&~YJMj8iRVmRzHpewB&DHtn#=3rI1$YMWn7J{gBR>vLv~5H3MjqODLbte?h}7 z^SpVmOfbk^g*a>`pu0-l;TkV(+T|+;PgJq_qT(A&z(@{Na~}6xnADjyg4yRNwxU@4eZGNMdfqcX=gUMv*D^b7?_S{u+!Gb#Ov-CF9uQ zl4pj^7ep|(q^)7;nsh)wGQl9dSSXpMIX>{R`+yLeh?_D45~5gk&v zrdbe>N#S$wq`!nZ1Fh}CSKeSzI@9~@E1wh@Pg#Vi72`S&@l03G8cbL`(P~6{c$cJ2 zx=ET%X=-ef77-m!a`-zklOf?9jPRlR5}_7CSb}~XIj)fhZgoCxkKo}NFSfB=c!uA~ z?H5IqTU2+KIF)^^F6Kt`;=~X^D2t$ch>Dv9B*WZE1E&@bY zd8r0B?GF{MFq|XQSB9k|vVq+ZVy4eK&I8jwpGzIFKZ*XvkYKP?jCs~VC7A17S4kNC z8q@$wh(R;MEEju$>+vhMh169>zb!LXd7Qx&ZV=UK%%Fd{c8sk#72)_V=}JXL@9?Of+&b-DY!Sf(eEtcf^nb9ALMqWeG95 ze=yb8Vo!a5Pe|9~oM#LPeijd;7$Q^?Q18XKIlGf&nQ#0%GbFl^ZC*?5G1&Hh_cWJL zzQsn;-hgGNKbaxfta*8bzSpn=T%(wcdtD_H=@t4;-wpO?3zLi_Jrmdr zqPLCb$aYdWM%9h?KS$IfGNooM!o5=4YC6(a%8$E@G%N$vn)#hAAp5&R<;AsaMI%^E zm@C5y5?%J-9XSq|*X+)J+J%(Ls6RJt?qllf1^TAzzzmL)XK@f+Rxr7(O(y!BiT=t3^h=8Usrxq9qphmLc5byUtkfIa0zxb|6l0-IGj!5n#>} zM$3>s2DL(d!7{1M%WH*}=O1j~#gKX7;o zvM!gN^E5($nJHrva+~9hiT(M=``UL;^I)ap#QD!+9m8c z#>%(n5A@n3l*f)z@>X+)D3(d> zR)6QOm2L9`1 z-`8GE%*UitpU-zl&Xf2Q4y19_22gsK{5$5%=Q16}aWa3R_Lh~IS9&btS*f>Add@JsM(wMNlkBLEV*2>^ zDO1bC9J2|KIaZ#U@jCiOoq3;4BA@&svp=5uY-4MJy{Uu=QRhCr^5!+aSt8G;B5mgV z2FT^ByShO5{-2zxQf{|J(JIShEGkj251nRmpT1>g5-B_H^|dNaC0t>foS&&~*^d-a zQsjkWC%bbViXVjPW_^!a_*}N@wXk%e=caDtK~*PO6Y&5ALRhdtaN03o@$8d6c_Qof z)zxVgz4xC-qT?#&%u~fSf0U?1A)nC|@`v^6lpF?s97b)I2(0Y>yQNNVcFm$!Vrb&g z{zI4gb^Mo?k9~ShmA?hmjD&atJg(bl#Ea!~U+O3atu58Gqm#h=BKI6;Q+vy1g$o3NtGmAdcq(5t*4|TH8&B&%_=S z%f_UPTmHPR-g|Aa{`~5Cy6UKVr-=9&t!S$J5GB{R8MYC)MkLO-wm2pz$ZBzM7I9DU zlAdAbz{&FIlUn7}dn!6xwtMj)52Lx)D0cXskE);H<JhZ@aTnFX zV2V1YIwtAjx1@99r4Wtu*|?v-EO`^(99cY>k&rdlx4g=8wO&ficK4zljT{#a)oqo6 zLNoJ&=3K_NMZBu_-WBb;xtX*`w>_1V4CwFciy7NdefRE1Q?oU(@Jp07r=y3}&w7TF z8D&Gm3-W^m=kxQg@s(?YHFW2y#*TK_EZY1R`aOY5zE9!5Ft4WJmKfmi&KYUGiEo-L zF^6<3q!;EM)AX&p`IpVeIo_g$eK6k&(|}HtOAfpp$}&|d%SaNIuQW2Im!(Qs`jwd7 zTcA=r>25n&{JDx%VXKnWq9+M+cU0hm7C*m$LFLY+C)HIXvQEUQhFKy`jb5a{0 z9QsU8jgiZ4{IDpse*l^o<dHuSj#S@o)P^FLvk%rnvgRZEwFj%voZ{e~lGIM(Jlv-U|ch+T`s@g&} z#`#cwWLVf^{k7fe-%|4D2a~$@-rl*9t&>SpiA{e7_;XuB&vy*zs;mwybj#^2`w(e%|EIRfN+7X+4X}yqKVAy<9 z$yVr>gUA7qyxL=@!dwziHU0Jii&QkMf7ZzJHj*rZ>3_0 z;Pk~#W2O~bGd_HqB#U$?C@8kajnPJlA3v_DGt!+lg@%Tn|3yoms4^6HyT6=iJdkay zcGAce5)xAFp3+dnb<>IWxv0+JrGRYS^0P!}|LGg~74Nna1HfN88e3c4zRKeF3y<&W z_ry)sgrFLafLia`L-EgFBXXvUsAeRwBKs8h`QR=JfT}Zj;KEsJuL9{YvA4y~D42J1`Pi@ zIy#ezlqY_be(~n#=cw=V{inPy$awm!_tl+sbF)}jSGxslSQNG8O8e|bp$|${xfOK$ zDtW2qd8_=zgh~8wt}hY1cwHv=L5bGa4~o1mukL>2r-|Uz5Vq0 zH0{ebdd~R#tf>BMFp00i_>zopr0PHYx761SM literal 0 HcmV?d00001 diff --git a/server/victory-chart-renderer/tests/__golden__/top-categories-single-slice.png b/server/victory-chart-renderer/tests/__golden__/top-categories-single-slice.png new file mode 100644 index 0000000000000000000000000000000000000000..0814958c44095d2bbc0cf28a2baa323ec66a46d7 GIT binary patch literal 23276 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 LPQRC*^^X4!#g+J# literal 0 HcmV?d00001 diff --git a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend-truncated-labels.png index b75268445e1ec6a538c43d5bd82c6f081fbc49ef..a934e07abadb3550b2a3b4c2d06b3bb1a2c73a58 100644 GIT binary patch literal 18231 zcmdVCcRXC*zb;OC2+|M)DMSmBXhD=jkRf^>Er>c=)EG65gdiEc4$+MvdKo2&h#-0y zT@ZB)2~!vuerxjieD6Kyckb(V?)ThtfA^mJGiL9-_N=w`TJQJsex7INm9Dlr69Xp$ z9UUDL^l-Zp%kzBUyQWJqk<|3&`xaOTXhC(qX|UOfJs|Cn%=D}z`YTA1}PJEhWPSge*b z9*7wwi8a+mO$^u6j6fe69AbU({_CYn6%dE2EbjB=B=Fs8_ap$SAS?5a>?w^Wo(K;4^INlrB9T-2>r6hP0y$^sL|` z|BmuuaH{C!f8|r>cy#yi#H0mzvC3g34^eX$pOmR9_~w;O?xZ4d^15_GyqLFC%!*fV zoeesu-QI;HzSq4Bp5rxz)`P-e5BSMt^Y>-e$FpCcjGelg6(XZv4{Xm~ zCjOLJHw-I7`nb#P+_QNy(nvB9HN!x;{M=38wutqNTSm1OO6v&UIt#Jj8d$`l*#5l< zvZIKU;_W!K=>BK5lVajJy$->l6iOQ0-#1Zl-=#n;FArBA!IRLGeOp{~J3@M63>{zh zICyD&*QfR6egA!KGz$G__i+F&d|$>~o@E_7w;!NaYo%m`sFD#k!G3GDKDevugl%lw z+#)%92jOLpL@;VEN9S!gZ%t1LmlYeT=|?up?p&EtTvs!@nrPgvYZ?Sqg75f4YlEY1 zAoP7U;)_!Krc9-zrK({k_7t1DDKH&Tyux~t#6HYA)F295I?{kQk3Y$Z-uK5gr3Q5; zI|Mhi_*EyCnCDEM*=cYl_(sN@>svg3kS?t91WRGk{M4{nDlrOq4}m!HWx16JMw|_= zhF1DkE>bC>3phM}nv}Lr36_$UF7~)rxab)CZpRGiv@X7HPQKqz)Yi;i3e8pz+1Wtk znwRmGG0E4$eifjqM3%y=D6=injMkMQdyU%Ziu{oAjI^ALqb!Au#ro&UP*SLFrASK(v3_k-8YOfF6(=`zimDBXg=3?Y2yiU&-6)1$ST+A;s7&V znAJ!-e95Gxf9gy@73{Lv$$n8m)b*@Hg+VAT8&C| zQ?e)c$`SUw@MiA~TlV+75xM(&yVj{0c?vBXOIABgd@FHDR)5xobyKBjO3@13ZY7Xn z+iTKYgM{Nv6H2BF@%yH^d%+Z!rH@pNSJ8v7VzJA8yGW)2XY5PodYR4=>6VIzT$x+9>30e`>DSEIqEru3!eJwBgZz+* z3bNEh2+7S~@a6?&Y1D92H<)DBv&e?Rjn=~gh8D)f%1TN`!$VGEUx<_-vNqEi`$foj zf_K&!$$#=D1gve1D4n5g?~n5_TmnTpo-dD(qWVT{F7rVOx;n++WnD$6rg?3h^SQ7H z=D`jLe$Z68KvT2&A>M_*{j-Z96ps0V&2;;uqQc6LtABEeD$pAr&1^? zG?efQ%*ER1!L;aLfA<)MNS$iga7IgaQ8YKXD`Ve7yV>ma-d-g4$s$Njj~E^)CaNHB zz0*$XdH;%vZ+mo{SSoEEYIrI?hc>La(!Jz5IqX_b#J%yV`+m0)->PzF(mIs;N@eVi zBCu>YM;kZZNwgtyi^ri;`*ET5%T2LJxv@3hEF^LlrsTm3(SH*fhUsqa(nJjPje6=? zCmEOow!2kAeS34$ssegV#;XDgdJN+=5j@H8v_^%dajE7qaaAX`G{uh>p#)^hu=xI_ zdQ+`K-n4n8zMQ`UAt_cVuZ~`ioh2*7PKWc{;OaRspZe-5(sY4FZ+sGFre%*( zaaz4~tX@n^X%~sm>;_BS$vpOWA+?8kFf{k)^K|Wkl95V*&=y7Sgpq6TSQj74n6lhX zDr`albXnaAcg{lICpnQiyB{AHdVhknF*v!vy8?d6hu!KRzpnY-@Dyk9=W2VObZ4p6rBaHdP76~l3Jxb zU4$6)$lh){Z{fQJ4K3}0z%-iwh+R)s_~D~ClbM}ThyA%3$*lq<>`@h!OC%b;2qi64=JR1~4C62^kVv|ggy7$AHXW_H^ z3VBQW8>$LF2YE$guNcejw42TyrTcz^kQ^oqA#0`N?*)IV_8j(d8>>~HIj1x`xuv|} zLV76#fo~PG3?v2m<0>uX3Fx9)moMG5RgY!1vFjN(jNZ;tC6#bzg6lnM^=;>UyL{&f zKk@#;xs0KD%I3b*?QHeo(b2*(X70wdM>E%K<9BhdmK2gIo91D?(pw=^E-F4Jk77$U zfmXOj-#{RUc=?th4w;#O#nB|{tP~9nSg-A9%%s9m>uEQ%21~gnT+>Frcs7GAm1Lx8 z-rgj#w7;_JSZr;YtT_2z8^k(7oO(0*y2=fob{1rFNeCU?GWx1-g1uNy)Ut-P(*58m z-u--V`n6a)T7H%C8)(WPmE8gL^bfa5)D~FLNFmLVbwZ+Rrv&>!wAx$k^8dgkNV~i>*{xGT8wnWce%% z&RSSlWUOV0cIWla$xI_U#Wr4C`n$QwloSBTbjxFPUs=HC|2)jh56}&r{uM-ZTd`Gu zV`UPqU27Pm1~=Md_^tPjD9C6Tx!kcuO^BK9l~cmQT{^3={;LY)^PT-)GA0P0gITJ! zDR+E{L})VcRSYYe;D=tF!7o!;gwMATxvc^*xK>}7p^-t(^C$fc9;@h0!cQk(MXB^5WkqVzji~Br5)IXiw>z0h4Q2ggRIanzqJ`GKh)O+6D_IYWRmwjcnI8=@ia0M-8?3r+5f9=$FOcS-j8;_g$I&90Gs3W4qrD$J| zmer_UBXbiGfTW4nRF&OaUG#hUa~kaC9&+FE`pg|)41*DB^P-3owR^=__U^%7ajNWi z41DVf6>GhhyHDvt5>}F|?;)(LC3Qf!v`XJiR2+M6wGs5&2D5teG2y*c#y$%f$G+5@ zcloeDytQHBOf%7>sr>QVkezlDA}K=CXSQr6HDJPL>bi3$zjf_H-TNi^I>{x<`*hJA z=St-Qo6a*wF!RV>4Yjtml;Bg@YYs`hNrBogEmSw?te5A2h0rIZa^nXMpL5g$Svuif zw@n@keeV~&H{V-^Qv#{YZK75kK^p{!@&xP9og8K!g~`=AD9WvsgkHC^G%`RNS-TEw zzs-T3-~GZp;nV%*gqheQ*Aiot{@m%atf#V{4i@l1tTHn>&vK!36wL)7a+Yv4>xJ{W z>FB<%K8Or!AnV55d3r$VrsC}H2d|mQ_9HgXlwT5q{j0;t-9PXl#)Le=258rQTvd z3mXfov0ncb#NYl!MDT+0NBSq=0&nr7p`tf0=<1z1l~LM1(WUup{><6)CksPtg2fsV zD)ZZ!$kinievsrg=>9;AIMK>?m(y zOIg{#={Q4k>w+)&UziOiz1*yMac;Y$;`|!pGo{WuN=LYAJci%Lf7~Q(HY^8fX=zc$ zP~xlX`((tldG(!nkR7veYb zHJN=`Sq|7aHdgkonOtO>P8B&La^DuMKtT|W=Tm>CqB&ABk`j4CHbcAK zLQG0ZcEJV~+T=ET1u{`dUL8s~P2D7#{P3y&;Uh;}{asI4ujMhc_pJuI1I*>)!nQp( z=`^dyn<>VybMiL~0=K**0|>sN(?cNg!KGUdu9DW8B4d`zWXY3D?z{7tt!le{#U1$W z@^;6iDOu;pOBBLz1re5pPdm$YGp$}H~*br&>E6}m7 zc3*fYOd3Bv8*<=7IJbGiIqsakEwM9o7Hp82da^n9G;JZI&C>h!W}lDdxlv^{@iX2K zKZ!)QuQ!_ndV3QvEiFRU9vh5_AM$?GJ+{x!!zBcG^FwkX9tvKU%O8`*Ft1}lT%`p; z|B2`vHQZ2&1Uz{5Rsj6*GR5ZdS&0k!i1^eug(izsm024b21a*<63dv3qa;{Y<`iXi zO?}EO%EZ2?q(UE7>pC*-?LVm$)IJp%{d(ZFxjQxjhFy;99TBp5P?aCUJ+r7d(-=^! zIQ`ygxLPW5g}2DOU8V)%;u-X=f@NvN9~5?%c2y+V zmMc$E2|CX%SW#W&nIHFPS%iB_jzRlrIy#Ff8kDiDGL0S?0-GVn7rDCUT+3+*c(<^k zQ@WUr4#er5oozwE3m<6Q4c-0dFd8LiFw5oyKK^f|v-+=KnzXB{ht-mvxa(V5W``e% z=(o>Gi%($XvOLfLQF*2^Jk0v%q`A2$rm1K7hTQAF#&t0ZkaiyokI`V%HP!<(p6dRK z|6-?Zy%wWWXkDHcFPb!S^x>c@eIBRAPM3B0$m1hIV<@D4<7G*KHMlDL2$!XP((DN^ zMr9VmA>AUPR6WIeWhvEX+rC?T#@z>E8BQB{U#pQko}$-(nu_aSc`cTw`G>VEJ?-69 z5Xy?^G=GeLIJ^ULz-e|+n> z{AafByDHq8Bd)Cin$+{HPuZ~?&E>PSng3vPr5n}K&+qexH2p4oqfg?l4CTr>aR0~W zEWwkZYv}2EOYSs~&goi?x8l~-6cHxu*3L8*Qdf>T2))xmGLzE)Z1Fb{!?HLkjdU=G zTmQd6?!*j;0OuV2md{0#qKw?!s{8YNG|HF@5f1?X`xMs;ek*CLJQR6=daw$_NLsI& z?uHGUD@E0*vOIr%18|)ExPz;CKiFAa^hUf6A3d}TB0%Roj;m3*Q?f@{zb_S-4Q?13 zX+^Wyi<+2vzGu)z>H{WoN(ny_MS(U2#L271Njg9>==E;R+sSKKAHPe*P8e+A#Z6*S86}<^~1s z>zm$tX%({MmUo43wO+jiUEihHmlW8n550rvL-l8u)MfQE8-~3WBnrkU3HON&r;F?&wzx4BPzT6 zc3Dpv3dGS2IfDV)z{gIemE~&+@i}j@l$m05bhBDoNIaeWAfsD)rPth55>aXi6I)?i zG`6(7=;2`<0Ecr}GBn&aGb_KN(i$yxa4IYN<3+H~>FG^$UVa=#fx(R=ZZ|bG#-C>w zhcFz!#r6A^!Yx_|&RbYO+5x_uuX`cCSdm_!pF9~4y{RLh8JUpyCf9(UUqkEO(3F9@`$&7Imc3{VvaF`r zN}WM_86Xz)L`|OA?#}#j;c?BUhBmc64ZMUyHu%vaCb5~+lDihnGv1+n@;>YLcRs48 zHIyJ?z8`4Qu8==Jp5d*mouo5Pog+~srJijVGqtWsQDG}8a+jK=Gj zw|rnR2*U=|nAVh(GkJIb#xvI406z$V%+B>oO0-L2VKpk{9$Z842kjP}>U`=pfl`9oW+!!>GaIe=2QEc7> zM+bCFa6J$MfT&7M7P}MpqInRr%!RE6`ql1T4pVOeNHLhYLP1bh27!7Gwi13nxf9-Q{qc!mkt@%>zsRE)>n3>tCaF>M|d%uA%DWJeFLZu~O ztC!M$+4~1>YR7(Cq|Lo-aGU(w3@{g2Ne^DNuulYBFb5bkO)edb!Kc=$<+DdTKg^g` zey!I#r}pQ8xRcDF`P=D?=5IPWg4oqxpmL{M`i~_JfI+jh`~ovlQ&WE|u|Mz;T0R0Y z(=J|<4i^A!`gvnNlw(k=tptzyg-Tq)HM&-^Ed|8GcC zZLP}(`XpW}?jbreeASZrFP`Fg!uexLM`)V*Cxrxtb6Nrr;dIzh_tCKYuxVbjQ~(9G z!cI#eRJ3cQg%^h<8=yT79T0{m$T>a>fqSJioBu4OO=6hlg{*fblq|2>M8hC^kf!Le z4j(ckK$T>7vNbqiuf@Q$T7-o~TA@0fv2&dnYDpkJh$$qF3DDaeWcj9l7pcg-E(4J5MyW+9Z%;@`9Ba)C)~7s8SB}Vs^RfvX%Fv*B^*ja~Hs# zHM`1&o@r183mxPa0SJ`Xwp5HpNj*GaB4Ldx$Yg0Ti+yeh^AQ3$`825K#Q8zrqbd?b z04rb6ldh-M`M``1-y>T{r=--K8d6Z~;!-6DtN--$nUN_p)Hb<)DqFj?)Xz3Tnn1S~{J_@5s`e2NUD?75WA|OQI<=%*bVE5?q4Jp!OO7{Yn zOx0+JkhRh$K}o^wcN=(FA6=H|kO;nxu0aDDsPAyZ`OFNN`!*0>H+RZrReip_j#o0p zs0Fete6_7!Uc`NIF{J1&u(Pf<8JRHREY!KkHSfkbjtHV&p=34q;$C-ru#+h#q==SAo1-E9F0S zl;YKEqM>dEEEh*! z)$_Z6xo|Pve1Pv($bG)-WY60(+&rlQcUW|ll9XN^4)9Jm&t7KWr~!3lVPV2JT@`QK zi{vXRFV}O1JUh$II_%0-(3Mx5%1LB8ZWLgYz_&8;>kMIFvV~TH22&Q;8$w2p8DU3? z%Zv0tn0K;vk7*|NwRXy9mc`1H3>FgpkX>F{Jl+{M^p9A7;;+XmmZV8!#_al9Hd%sf!obL^=lLLu#M?92 z5yB{|{SPky!WO`j6+_4?{WGV1$?<7v#BwhKvnV`zWk$wA=+SXGW7E;AS-CJmpAZ*}MtiL)PI6q}e^D2I`ig?w z=b~#!Y5${OTi2rIt{Z@ygYjnmy5i*wDp(-J0=lU9Oa2vzzP`_EX5`K6)p1UvVMCnL z>~Vgz_In7>y97Ww9wG5w&|zVrkt|E2df)<7aXwVMCs?yBFE7c}Lrc{+0X!}d$(w~{ zfna;A;{Bv#GrGq`5zXEkwUUC;rDnS$O@R;nM~Q3S#!|iTql>%gF- zol0hW;JM};KFlp&h!!kKlum!Y96iN0RWX{+s=n=;4FWPQdG0?ggUf0lz$VDM&e2L3 z|FpA7c<=nsrQuWSr?VmJn@<=g%sIQTSTOr+ZE9-Hb5h+K=W6dUFds8~w36bNLC<|2 zS-933K$)7^Fa8>GYLnm;Fb=q>vU#9H0@_ZV4`IaFK-dTx*jm%s3@+p`x{G*I&zhpL@p%*}8zRjV=Y4h49d% zcSSz=(=0;|D6hhLE@r*cH0vk;k24OWKYi!mx+qZe_%PG6S_(JC_R~O8W=vS5SRCi# z!26K&+lT4sGE|gRlir)<&S-mfexXSUf0~}2;aJqyf7x#YTA*&nhB6DkW}OqI{)QL0 zM>{&x?#XmOSj_xkn55w6y!dM?Z!c=U@ZIou1#V~}dq8;He#$aobBpm>!wImr89kXm zG4;P2vxGPLzjjPUG_@wIODM-iL>s*6Zhs>O3SRQ}6TMhg3xH;7mouAPaCUMXsvshTmqxFTar>zf zqx$eN+J<+L%~Mcy{~hNRXU=5~TN)1g&ahnlui=PyQZS9=R^Mn;mrgSRYZE(@*j%=u zAZe~>HLaF}wg04WfUryN53^OS_=D|4a!rm7X?BPU54|f{0Nc2)NWwAx?F~~l2ndD3 zKp(2ipD6;VP_z?T`u_U^)^x?ZuRlW?LdVhU#A+Z>xJ_Zy5t0s0;a8&Twaa>ts9z~L zl2-%XcM6LT#-9ejLwe>r$E)-(%n!Rq!NW+!kPm?m{;MJdM1x! zfe+<#$L7zUeI0YB+f-G3yB=ZmZEv;4uW^_9AnD<^T+^SrD*izbrCESMNJ~v6RGL`( zv}o&S_(go%EY{FMUW3?Nya?aPs7(Mw9!=O;6x&s{Ufzh6aSlFBXTX9zlnE8>t?Kxl@-{h!!g&WTTaQ@GY>!KBGk(o`Q2f07%$)>=QF z6nL-qTRJ-Y%aJBIk5a_^obT#=NECiH+h(0CbTe4vZSZb=>k8JLJJc?T8ztJ`+u19g z)kOj8ua3rfn48a$Nt^m!7L9yDeAjdV?fzqBHanUAt+1$g65nCUQK#jsvjD@rwt+Uv zq0E%K6*9|yj6ObmIGxF_qx=yKUFfZ-5GbE^yyPGKulfM7uLRY-l`uT{*#Jlh> zYGgCRq+GIzBQ06X@@7~om}AgIE94^dS=@n+c8 z$j%^+Pe@2kEkY+<7qHjGF2%l8KcAn@vEzdj9e+hzI|p}JXy^!GH=p3WRtPRI0%{UX z?3x=G(5yjfgnq&VR_ z-hVlVh+OU65Jdn;^5hhi_> zr@)5S$5f0G`GLoa=;(;&Xx^3)6cI6Ya;gI?DVL<=&4;yEp$ivAX_vXcQ1+E$;7U+X zaDQq*-}dAAI`ZM=UOo%RD0EY36Y*bM_8qASw1pJtp6Wm=JLvLNl<`?$4RTvex4<^@ zHogKdw2Y^@1O6kXM^()BPZ4e~J{ge8EJJQp^els-Q(62rDCzzq_xtx+(a1%3cz?UK zetl(W7lo00Ot->cDetkm6W|H?;Y55zGd-Ph?-vfkbMNZPp-Alck+eMP<;B*rRh>Mp zh4`-J3ed0PCv|OZVo-W5a@BXdwHxhd6Z7HFvBGQ72}VaH%YxEq1t{IKQ@S?7EjNj; zv?b%YXr^OzUJH+ zQli(i{14gxsbjC1mNES+?sOY|6(I8%c2*%!CbA}F_6HkyNHtVR2bkX?h|k6mA0r>B zC4r&b&-xGMi#8ii;-c1ggMTca^S8beTAbb)RvAhr^AhULpi%?gnC0rjTZG@~?KDgc zgh~LkmVK1K3UgZly{+aC0XUMsy-gEw=fFXl(hVrRNRE?51Djqe7RS!Z392F_ z{|9B^Gs}OrEUabKTp?5ya`}lC1EveoKp!IIt_8JhO+($m3}y0VqE_N!6(ak z-mAf)0(!N$2!$zRN$SdjM@&&0N~gt?c&%R?ju5x)X-NHvCrCctKAp+~(W7Puyw@}BP=D=G6ASElWOyj&nJaM!?H~$o*d-K!t$6WMPqaaDrFN2q@-&M#118 zC#!}e5S*J;%kULWUUxP9H;R~bv5VQeHUj1t^5R0px<&{>v4y~5Ys_d1omOoGF zP756Yo|Mbh9^OjH(|<9ef!|JtYTiN*9QBtLy8d}t<6Bun!=35r=1l_OM#EX3XB8E; zFT%q?>}t3TbTz>4bV10%n4ZGB`iP-Wml9+~Q*TZPhZbx~CEVRwr}QvocwwoXQx<&R ztUCfKtbf9a^*{Tbz#ItM%N%OFp^$~12IE@DCIj}uh9}cj^nm~F4z#iqh3;)_3c7k=S@aOuli>aW}tHdBoa?YU^0@3PIY!n${Rc}}nX zLBSF(95S@fThQ{Qg!J3ybb+BL90OyY9A7=3;ac+7jH!Sr6!pTl{b5_-#|{WLsFvn@ z{*$CxZY!(SzVEd4ISz`*2*CK7pCG=Hq_MYspw*)`Gy8sBB>kU6L~B@D$8j-b^Zn?b z%8782_S3-%zlnn{o&UPzB(2ub|MzMgn_bJIa-WfXLW|}TW8PMp%1^ij3Tq8Sx5FT# zj-s6phvldHb`ZiU%H-X;!4VmCFj-wg|Hp(pKZ$?+^qhRa%E^jYCI0868OheEnQS}t zYMgRSX6khVv3f#*Kf55nx6S|w_jC;dLU%kZ@ZRzM{!YboRB(L+;oG+{7bn*~)YhV~ zte90nPkhxV4-exYsPA;wgbaDZlV|UeOU~L52l44fPm)<3$4A~TIe0T@Rlp>8X607Hf#2kr8 z7fUK(i*i|A6RuG%Mc*t8II&fbB>0F0ZlR< zsE+A&>a>ERIMt=;CRvmYn3jo~VFmYUqb|9wAP{Fw9jKFHR^xcXb*BibNs1 zRjm+&tb@@4E;ao4%nVj%f`sn}MxEgn9qaD81!*88c7mgtN|tDO0WCcTXoFRXj0r*# zelJ`;i&+OU+#s6ftFkBv(YG*+|MKv}AdGRTYp^vRIFA@u7!9`TyN-&vdTf{u#2xY7 z-mX&5F@QTq7^Ov7z<1g7LFimQuW4z|0juO58e7U3=VqoZb}Ql3W52yEs|`T1y8K0$ zbti6%FVw@*;jV(bMVTf4ZnAh9Ep~V+tEL7jou&oSidxdZ#jC%#oR*%rGQD2*SoMV0 z@4cTYZuH7Q(P9q={qO}~jv4}HKgmagYH)QPXZk>(Y)}cP2-=Ahn6CU~2jzzmN#u=G z*GcXjlkr)G9AJ?IEejg@7MPQ2gVA6lAM!xRTDbbd04$~BhYWq{35t_#U|q@4h_IyT zB$|}_=kJ9jp(!Z*@lO)pv;jA3&3$FFHOJLQS-Q~+@=Z$ z_z*HF4VZVjJ~tAXzj<$b3d*USGs$?DQf)2`+gi5-T9|>wkn)rdm1g?C00G;vPSK)P z%}7d0v)ZvM0|Al7p<)ws5d4TGRjCS^k@n>SR$$%D^oXD(Xv@{*hPx1EZLaV|t) zZI;Ash3=8UU0h)NQK|^SS8lSPm~ioMFFo?D2&y_@+FMYa%inzv*g&%yC~(nll)LQ= zjAw!G!+G9a7wB~mV42{mnpds?^~swPTFoSi!$yDqj{Q2uDsag>DH#h)8&M|czK=QT zK+i~coBqz@e)7$Mn%+qX2$0eRlg-0Lb5gvp$l#Io2j#i@$1rj5T`$9JQsBNKQ^ zn?djD3jt(5$c8y}g@wjSM}yQ#OGXxVGGdj6=8QxpCmew51PC(J2P2m`uiwm?qX0{tgHDSk%gm$NnvI!)gX+u}GH&_z4YK8i*W#}JR>Pxq>5QvF{bZhW^u{Dv zl%|tSPo_I;wD!t=iwt9^O>K|^D(2y%M`&*5xn!K=Z0dCvFFD_zUuj~{YU)J}njzqG zKNfgNdY2LXUm6#9v%BVaqY_>pF-;X!2Z#uX$05tg0JU~c zwXHzvLL+h=2}eCWaDcFvYQ(7^sbcf-BRkQ|%8b8YuxO@*Dg)e;!a$+}Hq`3>w&6MjA+DZ8cqvRlDL0mSzi zRDr#``cRWcW<}vF+D5Jq;{%mKdTbohZ@)+zw(U<3Tpt8JjkvCbp#p375g_jtl^6Y* zWc$5fr-trM%gMeC7Ph3Y+sn+52Z^^_rGq!e;J}}4B2`_T>e-#lw(IKZG6eh|-HmuI z3Q2)n^phYI-0ZzuzfnVy{Vw5^S4G_lCZ;JB@aFq;N&xD!)g1K6)7O%U_G!zn-Ry+hQyE9;O_9Ps;$DRm;!E`K)jNt0@ZTw1h6K zb^k8Pr1&SnnI>*ydVz(B_WA>Be?b|A%w(ePTB4mH(nCKx3lzrh z(-BDTWDa^(chZrFM6@+ZWv87F(jLWN?=MNKcmaP4oZu+@*skHmdl-JC8|c)M4G*iX zdjR2l0a&IpRgM~2nxg>fK!wgU!+%e6z-NmbiCY4hBfu+U{IDhDj~f_pp4#cRih%u{ zkVIxL0yZxJO%0#r(d(*Y-oDkPu->l;!{fKC3*T>7?Ge@wFzxQ+rvg0ViQMhKdfmmm zl#i+3VOG6%`0RUzHfYR+$IAA~<<+L;&n`zAA4#V_b@DxfOQb$)_G82Ro|~gkzMLB_ zAMV=TF>*ig>bZ)n*J)}0du)g`5=&?C8zziH994C?EG1*MBLq3K6`bkew2!BX3I|o`x{8Nxo-MKZqb{X zkwmxK&8KbQziz5WoP7Uy_G!WIsYBmRPN6Y9l9GZ{l(>;cEw1lZ4ks0b^Cc#{s(_*+ zV^{KYT1JaS}d#I>t*9hw#9BP<^EYP!Nh1K4xKXoHV#b>-q>6#3(cW}g-y&H3{f99 z*7Q!yJv{+oO&9Ec|Ku5QF;?XJ?PI42BzS(gwv;H2b;f}H-`k>@UOh?VG;S4>y?2Q0T{V3y}!Txd0}1c zulz0er#L%BZR(iG zpi|yuB={esyOjj3v@RR15yV=(yCQq`0_uq0YTizNt&7iNTMVgU=|c#U_FBP*urNP` z^ro>?ZhBTJs#@AYA1*O1uMdH;Jo>di1*1Uc#NPN67jgkcM|Y%y_WuGf_|O5b z5+tnz3w61)KSDWh23^3ycihvQ=Z|&H?pz)R~QL zue@R;$xnHzMOj(X5^HW!2L7)LgGXqpl0$3WX_j06X?EQ>*CO0R9{m$YY2RU z`la3PR?Es3?V%Bjojx8g2COUa(^9LJb1 zNEz+9x51p-MK$CdoXZ!VwX)^$xYg&QQ#_-!mg$uzE@d4EN4ZzPoL2;RhQ&m*hK1TW zVzKM}QX0qmuWP)F(eu=OWBaZFQ**Gy5y^9dyf*YBpfW+yE>${+ROB-)VhoIt9wF~< zLnQK@N2`ROH6*Ew3aqtfiT7@a&P47jZwHsn#2!E;l8fm^}rqudfjV+?TxlDa#vHJ+T1J+ag?z{}R-iyoeoP0ppft_s39 z>a=&&nj7JFUW&V5_bjw5Z&*Lu$SjFKAVvAhR@r~>5FKK>?;u}!~z}MN5}nlTt}$-T-Lua z?p)V343fT?7`dTg86`~^%V34{-&LKcV#2q}mA<`o$0=BeUKk!>&x3r{5^U+B7o6i* zO^kNL1<%GeS&^@%lnK?oHld_eH(bq6GTvX}E_zYlfcdgy*Fy(=7RGQ+jOz!k-Xr32H?0vI;V7Gpf9Xt?i2HUwfPx!h^(WbW=46|DL^TuAVm zu1z+WKDNbQvfC4Tn>A>Efom6((@dJOU*)(P`qa9oUb_UrY&O4zCZjO0AjQQKSyHsA zKmD|!#+lk?af)k+;*agg0Pko%o~HhShqj5p1&EjVwRy?}MoJ8Uj5a}RP*u}F$VEiR zV5a&;1RTQ9NJR$<%?QV!>na3 zQR&3xZa3WiMzjLdLa<HlP*{rBLyA<9g$vc-#T?k+0Pnm`lQ&WYSpoV3n77~HJy>h{A{6s5 zrGm6JvOUHEvoj8#wn{De`m%?8%6|^LDTQn{(9Q0c)MbaoBMV&VltK>~ zW*vO=wTLi&a9q#-{!#6u;?R3l;V+0ITBoS?385_?xSMA^H8^(I@Nzc&vl>F>xG|;u zna3#x#%d?NpZ-f5)O*YViv9mUa{4dOXj4gnX&_nCt>`W2EFDBu`)=u-2T%VWPg-&U literal 18183 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 diff --git a/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png b/server/victory-chart-renderer/tests/__golden__/top-employees-by-spend.png index f8a25c0bf3b7777acc8367dad6997d234aa2e85d..7fbdc5cd98bf29bb92ac022df1d0bd6b4d7d06f6 100644 GIT binary patch literal 13401 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 g|M&lBnc8JqB;WUbdEd3xv)Z$GW}v6Z#K^@6 zfk2p`TGx#r5c&cLgs%I@VepOfxtGr1Lg#6usRk+OWM`MY{s-H;v%^$sQ|-~W@Pl0&9*C~irq%=R#yl6M zsj`<{>!*hU9^`2p5~BksbfL+Z!3;c9!rdi(YEJ(>*>pgRwm4 za$Lg2UY6+}g%<6}AC^mW4ZcYXG--^R(7`yaY_+5!`-%P9aaVm7dzfN( zGx!~vhxSab`@Z8B60WVtlLV3&pDR_!Y|(6erz|Ea;u$hrp{}V>p``e-L<^46KlPbN z>PRKX;=`V-oynBP;$fJ%tR7}mywAcATEn$Arz`m~;pduDVvi_vLi}`2yEwO{6OUN3 z4gyuu8-eKP8x)--5%bVDn7Fcs#Ok$GA3ii)uiNbKkqr8#5L9-%?(mx4mz=6G%-f09 zwYFA1dt!bx=}UDa@4MZLOpD6hb$cy+ren40j~+>G??Nr>k&$-WleTd8Z&#p3gX@=> zUQ4JR{j*d%{i=G_`*UlZ*mH_sOQkhkyS7cxJyN8PU(T=TlCsW-SDTnI@tjNTfK79+ z^RyAMh@I4=5;z7ARg?%`<&3TyKW}ORld?Z?``o%P)k#Z~X};!CYsjb6MaP!7a7&zb z1fQmhX)j7;u_!irhbeS-%!yzPcze-GTkscWpjo#oq010Ki%v|vLCEo4z!sbqV zTyE}Ty_AZ;XLh29Yz-%vLz`zvqN2=K#r2lfkizHB87m^AdI)|9M^df$+P7;VeM~uG z827P^%P-M2ueYN4XP+O7aM+?$q?GzTG`eH3Bk}%X!o&}ieC_gksW?efx z{Oj8CHkOo*2vT%QNG5kGE96PeI^gU!n@P#T>erG*`^v*T*OvMAirg>UAo^`zRN8sj zsIE>FyQ*zG)$t|4&vW%lnfMGPQd8Rg1WH&`WTha9LRt8cY>JMg&GNfB4a7KBaT zlUO_ZTUfR_#TDbA8VB{n<-48rT7JjO?B^MJ33z9D%ot8XkwsqQe&>1cLLDZuw83O+ ztdnWQyaEQDwSxB@zXk0nKjb+=wRmTj$Upn`9EX&BFw^NWr#&SMR6m}7hx}=5HZOv& zx1Cuz9~}`*$e$k~DL$FdjQvvCg)hFk!7&=5nP+Re)|n-z?^K9he9gNT$ⅈTS*eR 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 diff --git a/server/victory-chart-renderer/tests/fixtures/top-categories-crowded-slices.xml b/server/victory-chart-renderer/tests/fixtures/top-categories-crowded-slices.xml new file mode 100644 index 000000000000..22afb224afc8 --- /dev/null +++ b/server/victory-chart-renderer/tests/fixtures/top-categories-crowded-slices.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/server/victory-chart-renderer/tests/fixtures/top-categories-single-slice.xml b/server/victory-chart-renderer/tests/fixtures/top-categories-single-slice.xml new file mode 100644 index 000000000000..dd32f12c5861 --- /dev/null +++ b/server/victory-chart-renderer/tests/fixtures/top-categories-single-slice.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/server/victory-chart-renderer/tests/log.test.ts b/server/victory-chart-renderer/tests/log.test.ts new file mode 100644 index 000000000000..73615eca0ec4 --- /dev/null +++ b/server/victory-chart-renderer/tests/log.test.ts @@ -0,0 +1,185 @@ +import Log from '@server/libs/log'; +import {afterEach, beforeEach, describe, expect, test} from 'bun:test'; + +import vcrLog from '../src/log'; + +// VCR_LOG_DESTINATION=stderr forces the fallback path so these tests are deterministic regardless +// of whether /run/systemd/journal/syslog exists on the host (it does in the CI docker container, +// per ci/docker/syslog/rsyslog.conf, but not on a bare macOS dev machine). +const REQUEST_ID = 'VCLOG_TEST_STUB'; + +const TEST_CONFIG = { + processName: 'test-cli', + scriptName: 'test-cli', + sourceTag: 'script', + callerTag: 'test', +} as const; + +type CapturedStderr = { + writes: string[]; + restore: () => void; +}; + +function captureStderr(): CapturedStderr { + const writes: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stderr.write; + + return { + writes, + restore: () => { + process.stderr.write = originalWrite; + }, + }; +} + +describe('Log', () => { + let stderrCapture: CapturedStderr; + + beforeEach(() => { + process.env.VCR_LOG_DESTINATION = 'stderr'; + process.env.REQUEST_ID = REQUEST_ID; + stderrCapture = captureStderr(); + }); + + afterEach(() => { + stderrCapture.restore(); + delete process.env.VCR_LOG_DESTINATION; + delete process.env.REQUEST_ID; + delete process.env.RSYSLOG_SOCKET_PATH; + }); + + test('exports the levels the CLI relies on', () => { + const log = new Log(TEST_CONFIG); + + expect(typeof log.info).toBe('function'); + expect(typeof log.alert).toBe('function'); + expect(typeof log.warn).toBe('function'); + expect(typeof log.hmmm).toBe('function'); + }); + + test('falls back to stderr when rsyslog socket setup throws', () => { + delete process.env.VCR_LOG_DESTINATION; + process.env.RSYSLOG_SOCKET_PATH = 'x'.repeat(108); + + const log = new Log(TEST_CONFIG); + log.info('render succeeded'); + + expect(stderrCapture.writes).toEqual([`<6>test-cli: ${REQUEST_ID} test-cli !script! ?test? [info] render succeeded\n`]); + }); + + test('formats the prefix like Log.php with an empty email field', () => { + const log = new Log(TEST_CONFIG); + log.info('render succeeded'); + + expect(stderrCapture.writes).toEqual([`<6>test-cli: ${REQUEST_ID} test-cli !script! ?test? [info] render succeeded\n`]); + }); + + test('uses the correct bracketed tag per level', () => { + const log = new Log(TEST_CONFIG); + const cases = [ + {call: () => log.info('msg'), tag: '[info]'}, + {call: () => log.hmmm('msg'), tag: '[hmmm]'}, + {call: () => log.warn('msg'), tag: '[warn]'}, + {call: () => log.alert('msg'), tag: '[alrt]'}, + ] as const; + + for (const {call, tag} of cases) { + stderrCapture.writes.length = 0; + call(); + expect(stderrCapture.writes.at(0)).toContain(tag); + } + }); + + test('appends object params in ~~ key: value format', () => { + const log = new Log(TEST_CONFIG); + log.info('render succeeded', true, {outPath: '/tmp/chart.png', width: 680, height: 430}); + + expect(stderrCapture.writes.at(0)?.endsWith(" ~~ outPath: '/tmp/chart.png' width: '680' height: '430'\n")).toBe(true); + }); + + test('omits the ~~ separator when there are no params', () => { + const log = new Log(TEST_CONFIG); + log.info('no params here'); + + expect(stderrCapture.writes.at(0)?.includes('~~')).toBe(false); + }); + + test('appends string params directly', () => { + const log = new Log(TEST_CONFIG); + log.hmmm('msg', 'extra context'); + + expect(stderrCapture.writes.at(0)?.endsWith(' ~~ extra context\n')).toBe(true); + }); + + test('appends array-of-object params by flattening entries', () => { + const log = new Log(TEST_CONFIG); + log.warn('msg', [{a: '1'}, {b: '2'}]); + + expect(stderrCapture.writes.at(0)?.endsWith(" ~~ a: '1' b: '2'\n")).toBe(true); + }); + + test('redacts sensitive-looking param keys', () => { + const log = new Log(TEST_CONFIG); + log.alert('render failed', {authToken: 'super-secret', outPath: '/tmp/chart.png'}); + + const line = stderrCapture.writes.at(0) ?? ''; + expect(line).toContain("authToken: ''"); + expect(line.includes('super-secret')).toBe(false); + expect(line).toContain("outPath: '/tmp/chart.png'"); + }); + + test('serializes Error params using the stack', () => { + const log = new Log(TEST_CONFIG); + const error = new Error('boom'); + log.alert('render failed', error); + + const line = stderrCapture.writes.at(0) ?? ''; + expect(line).toContain(' ~~ '); + expect(line).toContain('boom'); + }); + + test('chunks messages that would exceed the rsyslog message limit', () => { + const log = new Log(TEST_CONFIG); + const longMessage = 'x'.repeat(8000); + log.alert(longMessage); + + const lines = stderrCapture.writes.map((write) => write.replace(/\n$/, '')); + const prefixMatch = /^.*\[alrt\] /.exec(lines.at(0) ?? ''); + const prefix = prefixMatch?.[0] ?? ''; + + expect(lines.length > 1).toBe(true); + expect(prefix.length > 0).toBe(true); + + let rejoined = ''; + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(7168); + expect(line.startsWith(prefix)).toBe(true); + rejoined += line.slice(prefix.length); + } + + expect(rejoined).toBe(longMessage); + }); + + test('victory-chart-renderer configured log uses VCR metadata', () => { + vcrLog.info('render succeeded', true, {outPath: '/tmp/chart.png'}); + + expect(stderrCapture.writes).toEqual([`<6>victory-chart-renderer: ${REQUEST_ID} victory-chart-renderer !script! ?vcr? [info] render succeeded ~~ outPath: '/tmp/chart.png'\n`]); + }); + + test('info() on the VCR log matches the VCR prefix', () => { + vcrLog.info('render succeeded', true, {outPath: '/tmp/chart.png'}); + + expect(stderrCapture.writes.at(0)).toContain(`<6>victory-chart-renderer: ${REQUEST_ID} victory-chart-renderer !script! ?vcr? [info]`); + }); + + test('alert() on the VCR log maps to the [alrt] level', () => { + vcrLog.alert('render failed', {message: 'boom'}); + + expect(stderrCapture.writes.at(0)).toContain('[alrt]'); + }); +}); diff --git a/server/victory-chart-renderer/tests/render.test.ts b/server/victory-chart-renderer/tests/render.test.ts index 8cf0737f4a1b..73b52ce099da 100644 --- a/server/victory-chart-renderer/tests/render.test.ts +++ b/server/victory-chart-renderer/tests/render.test.ts @@ -42,7 +42,7 @@ describe('victory-chart-renderer CLI', () => { describe('golden PNG renders', () => { test('fixture suite includes all expected charts', () => { - expect(FIXTURE_NAMES.length).toBe(6); + expect(FIXTURE_NAMES.length).toBe(8); }); for (const fixtureName of FIXTURE_NAMES) { diff --git a/server/victory-chart-renderer/tests/testUtils.ts b/server/victory-chart-renderer/tests/testUtils.ts index bff0d8e6d55f..849568497faa 100644 --- a/server/victory-chart-renderer/tests/testUtils.ts +++ b/server/victory-chart-renderer/tests/testUtils.ts @@ -19,6 +19,8 @@ const FIXTURE_EXPECTED_SIZES = new Map( ['top-categories-6', {width: 680, height: 530}], ['top-categories-6-label-indicators', {width: 680, height: 530}], ['top-categories-10', {width: 680, height: 610}], + ['top-categories-crowded-slices', {width: 680, height: 530}], + ['top-categories-single-slice', {width: 680, height: 530}], ['top-employees-by-spend', {width: 680, height: 464}], ['top-employees-by-spend-truncated-labels', {width: 680, height: 464}], ]); diff --git a/src/CONST/LOCALES.ts b/src/CONST/LOCALES.ts index 0ebe70e3a4bb..716a30579343 100644 --- a/src/CONST/LOCALES.ts +++ b/src/CONST/LOCALES.ts @@ -6,6 +6,7 @@ import type {ValueOf} from 'type-fest'; const FULLY_SUPPORTED_LOCALES = { EN: 'en', ES: 'es', + FR: 'fr', } as const; /** @@ -16,7 +17,6 @@ const FULLY_SUPPORTED_LOCALES = { */ const BETA_LOCALES = { DE: 'de', - FR: 'fr', IT: 'it', JA: 'ja', NL: 'nl', @@ -52,8 +52,8 @@ const {DEFAULT, EN, ...TRANSLATION_TARGET_LOCALES} = {...LOCALES} as const; const LOCALE_TO_LANGUAGE_STRING = { [FULLY_SUPPORTED_LOCALES.EN]: 'English', [FULLY_SUPPORTED_LOCALES.ES]: 'Español', + [FULLY_SUPPORTED_LOCALES.FR]: 'Français', [BETA_LOCALES.DE]: 'Deutsch', - [BETA_LOCALES.FR]: 'Français', [BETA_LOCALES.IT]: 'Italiano', [BETA_LOCALES.JA]: '日本語', [BETA_LOCALES.NL]: 'Nederlands', diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a59a76f88519..3928592e0954 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1491,6 +1491,12 @@ const CONST = { MARK_AS_CASH: 'markAsCash', MARK_AS_RESOLVED: 'markAsResolved', }, + // The way a Submit-workspace report can be submitted. Persisted per-user so the report-view button + // can default to the last-used method. + SUBMISSION_METHOD: { + SUBMIT: 'submit', + SUBMIT_VIA_PDF: 'submitViaPDF', + }, TRANSACTION_PRIMARY_ACTIONS: { REMOVE_HOLD: 'removeHold', REVIEW_DUPLICATES: 'reviewDuplicates', @@ -2218,6 +2224,7 @@ const CONST = { ATTRIBUTE_IOU_REQUEST_TYPE: 'iou_request_type', ATTRIBUTE_REPORT_ID: 'report_id', ATTRIBUTE_MESSAGE_LENGTH: 'message_length', + ATTRIBUTE_SEND_MESSAGE_SOURCE: 'send_message_source', ATTRIBUTE_CANCELED: 'canceled', ATTRIBUTE_CANCELED_BY_SKELETON: 'canceled_by_skeleton', ATTRIBUTE_ROUTE_FROM: 'route_from', @@ -2299,6 +2306,46 @@ const CONST = { PER_DIEM: 'per_diem', SEND_MONEY: 'send_money', }, + SEND_MESSAGE_SOURCE: { + CONCIERGE_SIDE_PANEL: 'concierge_side_panel', + // Side Panel hosting the workspace admins chat (admin onboarding), not Concierge. + SIDE_PANEL: 'side_panel', + // Expense report, split single- vs multi-expense (only multi has a heavy transactions table). e/:id and + // search/r/:id render the same component, so they share this base rather than owning a `_search` value. + EXPENSE_REPORT_SINGLE: 'expense_report_single', + EXPENSE_REPORT_MULTI: 'expense_report_multi', + // Single-transaction expense thread (chat-type, expense parent). + EXPENSE_TRANSACTION_THREAD: 'expense_transaction_thread', + CONCIERGE: 'concierge', + REPORT_THREAD: 'report_thread', + INVOICE_ROOM: 'invoice_room', + WORKSPACE_ROOM: 'workspace_room', + POLICY_EXPENSE_CHAT: 'policy_expense_chat', + TASK_REPORT: 'task_report', + GROUP_CHAT: 'group_chat', + DIRECT_MESSAGE: 'direct_message', + SELF_DM: 'self_dm', + OTHER_CHAT: 'other_chat', + }, + // Tab prefix on every send_message_source value. OTHER = a tab that doesn't host these surfaces + // (Workspaces) or an unresolved tab. + SEND_MESSAGE_SOURCE_TAB: { + HOME: 'home', + INBOX: 'inbox', + SPEND: 'spend', + SETTINGS: 'settings', + OTHER: 'other', + }, + // Surface suffix, added for the RHP report routes (e/:id, search/r/:id, search/view/:id), e.g. + // `inbox_report_thread_rhp`. Central pane has no suffix; the Side Panel lives in the base. + SEND_MESSAGE_SOURCE_SURFACE: { + RHP: 'rhp', + }, + // Extra suffix (alongside `_rhp`) marking that the RHP screen was drilled into from its parent expense + // report vs opened directly from a list, e.g. `spend_expense_transaction_thread_rhp_from_report`. + SEND_MESSAGE_SOURCE_RHP_ORIGIN: { + FROM_REPORT: 'from_report', + }, // Start type stamped on the navigate-to-reports spans: cold, warm on the first render (warm_first), // or warm on a cached re-visit (warm_subsequent). UNKNOWN is a fallback that signals a bug. NAVIGATE_TO_REPORTS_START_TYPE: { @@ -2523,6 +2570,9 @@ const CONST = { VALIDATE_FOR_LEADING_SPACES_HTML_TAG_REGEX: /<([\s]+.+[\s]*)>/g, + // Matches an opening or closing HTML tag that starts with a letter (e.g. , ,
) + HTML_TAG_REGEX: /<\/?[a-z][^>]*>/i, + WHITELISTED_TAGS: [/<>/, /< >/, /<->/, /<-->/, /
/, //], PUSHER: { @@ -2926,7 +2976,7 @@ const CONST = { /** Salesforce package install URLs for the Certinia Expensify bundles (see help: Connect to Certinia). */ CERTINIA_PSA_BUNDLE_INSTALL_URL: { - PRODUCTION: 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04t2M000002J0BH', + PRODUCTION: 'https://login.salesforce.com/packaging/installPackage.apexp?p0=04t2M000002J0BM', SANDBOX: 'https://test.salesforce.com/packaging/installPackage.apexp?p0=04t2M000002J0BH', }, CERTINIA_FFA_BUNDLE_INSTALL_URL: { @@ -3419,7 +3469,6 @@ const CONST = { DEFAULT_VENDORID: 'defaultVendorID', CREDIT_CARD_ACCOUNTCODE: 'creditCardAccountCode', EXPORT_TO_MULTIPLE_ACCOUNTS: 'exportToMultipleAccounts', - CARD_PROGRAM_ACCOUNTS: 'cardProgramAccounts', ACCOUNTING_METHOD: 'accountingMethod', AUTO_SYNC: 'autoSync', SYNC_REIMBURSED_REPORTS: 'syncReimbursedReports', @@ -3429,6 +3478,7 @@ const CONST = { SYNC_TRAVEL_INVOICING_SETTLEMENTS: 'syncTravelInvoicingSettlements', TRAVEL_INVOICING_SETTLEMENTS_BANK_ACCOUNT_ID: 'travelInvoicingSettlementsBankAccountID', FIELD_MAPPING_PREFIX: 'fieldMapping_', + CARD_PROGRAM_ACCOUNT_PREFIX: 'cardProgramAccount_', }, RILLET_MAPPING_VALUE: { @@ -4775,9 +4825,14 @@ const CONST = { NVP_QUICKBOOKS_DESKTOP_EXPORT_ACCOUNT_CREDIT: 'quickbooks_desktop_export_account_credit', /** - * Name of Card NVP for QuickBooks Desktop custom export accounts + * Name of Card NVP for Certinia custom export vendors */ NVP_FINANCIALFORCE_EXPORT_VENDOR: 'financialforce_export_vendor', + + /** + * Name of Card NVP for Rillet custom export accounts + */ + NVP_RILLET_EXPORT_ACCOUNT: 'rillet_export_account', }, EXPORT_CARD_POLICY_TYPES: { /** @@ -4817,9 +4872,14 @@ const CONST = { NVP_QUICKBOOKS_DESKTOP_EXPORT_ACCOUNT_CREDIT_POLICY_ID: 'quickbooks_desktop_export_account_credit_policy_id', /** - * Name of Card NVP for QuickBooks Desktop custom export accounts + * Name of Card NVP for Certinia custom export vendors */ NVP_FINANCIALFORCE_EXPORT_VENDOR_POLICY_ID: 'financialforce_export_vendor_policy_id', + + /** + * Name of Card NVP for Rillet custom export accounts + */ + NVP_RILLET_EXPORT_ACCOUNT_POLICY_ID: 'rillet_export_account_policy_id', }, }, AVATAR_ROW_SIZE: { @@ -5647,7 +5707,7 @@ const CONST = { SE: 'Sweden', }, - EXPENSIFY_UK_EU_SUPPORTED_COUNTRIES: ['BE', 'CY', 'EE', 'FI', 'DE', 'GR', 'IE', 'LV', 'LT', 'LU', 'MT', 'NL', 'PT', 'SK', 'SI', 'ES', 'GB', 'GI'], + EXPENSIFY_UK_EU_SUPPORTED_COUNTRIES: ['BE', 'DK', 'ES', 'FI', 'IE', 'LT', 'LU', 'LV', 'NL', 'PL', 'SE', 'GB', 'GI'], EU_REGISTRATION_NUMBER_REGEX: { AT: /^FN\d{6}[a-z]?$/i, @@ -5995,6 +6055,7 @@ const CONST = { EXPENSE_DEFAULTS: 'expenseDefaults', REQUIRE_FIELDS: 'requireFields', FLAG_FOR_REVIEW: 'flagForReview', + AGENTS: 'agents', }, SPLIT: { AMOUNT: 'amount', @@ -6558,6 +6619,13 @@ const CONST = { TRIP: 'trip', CHAT: 'chat', }, + // Terminal lifecycle state of a search snapshot's most recent request. Written by the search action lifecycle + // (loading on request start, loaded/error on resolve) so the snapshot always has an explicit, mutually exclusive state. + SNAPSHOT_STATE: { + LOADING: 'loading', + LOADED: 'loaded', + ERROR: 'error', + }, ACTION_FILTERS: { SUBMIT: 'submit', APPROVE: 'approve', @@ -6592,6 +6660,7 @@ const CONST = { SUBMIT: 'submit', HOLD: 'hold', MERGE: 'merge', + MERGE_REPORTS: 'mergeReports', UNHOLD: 'unhold', DELETE: 'delete', REJECT: 'reject', @@ -7829,6 +7898,11 @@ const CONST = { EXTERNAL_ID: 'externalID', MAX_AMOUNT_NO_RECEIPT: 'maxAmountNoReceipt', MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'maxAmountNoItemizedReceipt', + MERCHANT_IS: 'merchantIs', + MERCHANT_CONTAINS: 'merchantContains', + UPDATED_MERCHANT: 'updatedMerchant', + REIMBURSABLE: 'reimbursable', + BILLABLE: 'billable', }, IMPORT_SPREADSHEET: { @@ -8169,6 +8243,9 @@ const CONST = { BILLING_BANNER: { RIGHT_ICON: 'BillingBanner-RightIcon', }, + ACCOUNT_MANAGER_BOOK_CALL: { + BUTTON: 'AccountManagerBookCallButton-Button', + }, HIGH_CONTRAST_MODE_SWITCHER: { TOGGLE: 'HighContrastModeSwitcher-Toggle', }, @@ -8757,6 +8834,7 @@ const CONST = { MERCHANT_TYPE_RULE_SAVE: 'WorkspaceRules-MerchantTypeRuleSave', MERCHANT_TYPE_RULE_CATEGORY: 'WorkspaceRules-MerchantTypeRuleCategory', ADD_MERCHANT_RULE: 'WorkspaceRules-AddMerchantRule', + IMPORT_MERCHANT_RULES: 'WorkspaceRules-ImportMerchantRules', MERCHANT_RULE_SECTION_ITEM: 'WorkspaceRules-MerchantRuleSectionItem', MERCHANT_RULE_SAVE: 'WorkspaceRules-MerchantRuleSave', MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches', @@ -8774,6 +8852,7 @@ const CONST = { NEW_RULE_MENU_ITEM_FLAG_FOR_REVIEW: 'WorkspaceRules-NewRuleMenuItem-FlagForReview', NEW_RULE_MENU_ITEM_REQUIRE_FIELDS: 'WorkspaceRules-NewRuleMenuItem-RequireFields', NEW_RULE_MENU_ITEM_APPLY_EXPENSE_DEFAULTS: 'WorkspaceRules-NewRuleMenuItem-ApplyExpenseDefaults', + NEW_RULE_MENU_ITEM_CREATE_AGENT_RULE: 'WorkspaceRules-NewRuleMenuItem-CreateAgentRule', REQUIRE_RECEIPTS_SAVE: 'WorkspaceRules-RequireReceiptsSave', REQUIRE_FIELDS_SAVE: 'WorkspaceRules-RequireFieldsSave', FLAG_RECEIPT_LINE_ITEMS_SAVE: 'WorkspaceRules-FlagReceiptLineItemsSave', @@ -9033,6 +9112,12 @@ const CONST = { }, }, + AGENTS: { + BULK_ACTION_TYPES: { + DELETE: 'delete', + }, + }, + DOMAIN: { /** Onyx prefix for domain admin account IDs */ EXPENSIFY_ADMIN_ACCESS_PREFIX: 'expensify_adminPermissions_', diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index a97974d2cd15..3f23afdefabe 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -1,6 +1,6 @@ import type {NativeEventSubscription} from 'react-native'; -import {useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; import {Linking} from 'react-native'; import type {Route} from './ROUTES'; @@ -10,8 +10,9 @@ import useIsAuthenticated from './hooks/useIsAuthenticated'; import useOnyx from './hooks/useOnyx'; import {openReportFromDeepLink} from './libs/actions/Link'; import * as Report from './libs/actions/Report'; -import {hasAuthToken} from './libs/actions/Session'; +import {hasAuthToken, isAnonymousUser} from './libs/actions/Session'; import Log from './libs/Log'; +import {getReportIDFromLink} from './libs/ReportUtils'; import {endSpan} from './libs/telemetry/activeSpans'; import ONYXKEYS from './ONYXKEYS'; import {hasSeenTourSelector} from './selectors/Onboarding'; @@ -30,8 +31,11 @@ type DeepLinkHandlerProps = { function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const linkingChangeListener = useRef(null); const initialUrlProcessed = useRef(false); + const pendingPublicRoomReportID = useRef(''); + const hasRefetchedPublicRoom = useRef(false); const [allReports, allReportsMetadata] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); const [, sessionMetadata] = useOnyx(ONYXKEYS.SESSION); const [conciergeReportID, conciergeReportIDMetadata] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [introSelected, introSelectedMetadata] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); @@ -39,6 +43,17 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { const [betas, betasMetadata] = useOnyx(ONYXKEYS.BETAS); const isAuthenticated = useIsAuthenticated(); + // An anonymous deep link into a public room needs to be re-fetched after OpenApp settles (see the effect + // below). Track the pending reportID so both the initial-URL and the url-change paths stay in sync. + const trackPendingPublicRoomFromDeepLink = useCallback((url: string, isCurrentlyAuthenticated: boolean) => { + const deepLinkReportID = getReportIDFromLink(url); + if (!deepLinkReportID || isCurrentlyAuthenticated) { + return; + } + pendingPublicRoomReportID.current = deepLinkReportID; + hasRefetchedPublicRoom.current = false; + }, []); + useEffect(() => { if (isLoadingOnyxValue(allReportsMetadata, sessionMetadata, conciergeReportIDMetadata, introSelectedMetadata, isSelfTourViewedMetadata, betasMetadata)) { return; @@ -89,6 +104,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url}); } openReportFromDeepLink(url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas); + trackPendingPublicRoomFromDeepLink(url, isCurrentlyAuthenticated); } else { Report.doneCheckingPublicRoom(); } @@ -116,6 +132,7 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { } const isCurrentlyAuthenticated = hasAuthToken(); openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas); + trackPendingPublicRoomFromDeepLink(state.url, isCurrentlyAuthenticated); }); return () => { @@ -149,6 +166,27 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { Report.doneCheckingPublicRoom(); }, [isAuthenticated]); + // An anonymous user opening a public room via a cold deep link loads the room (OpenReport), but the + // OpenApp that follows anonymous session creation drops it from Onyx, so it never reaches the LHN. + // Once OpenApp settles, re-fetch the room if it went missing so it shows up in the LHN. See #92672. + useEffect(() => { + const reportID = pendingPublicRoomReportID.current; + if (!reportID || isLoadingApp || !isAnonymousUser()) { + return; + } + // The room made it into Onyx, so the cold-start race is over - stop tracking it. + if (allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportID) { + pendingPublicRoomReportID.current = ''; + hasRefetchedPublicRoom.current = false; + return; + } + if (hasRefetchedPublicRoom.current) { + return; + } + hasRefetchedPublicRoom.current = true; + Report.openReport({reportID, introSelected, betas}); + }, [isLoadingApp, allReports, introSelected, betas]); + return null; } diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 7c59f7fe8098..cf9b5e2dc339 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -757,6 +757,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', @@ -936,6 +940,9 @@ const ONYXKEYS = { NVP_EXPENSIFY_REPORT_PDF_FILENAME: 'nvp_expensify_report_PDFFilename_', + /** The last submission method (Submit / Submit via PDF) the user chose on a given workspace, so the Submit button can default to it. Keyed by policyID. */ + NVP_PREFERRED_REPORT_SUBMISSION_METHOD: 'preferredReportSubmissionMethod_', + /** Stores the information about the state of issuing a new card */ RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD: 'issueNewExpensifyCard_', @@ -1439,6 +1446,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.SELECTED_DISTANCE_REQUEST_TAB]: OnyxTypes.SelectedTabRequest; [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME]: string; + [ONYXKEYS.COLLECTION.NVP_PREFERRED_REPORT_SUBMISSION_METHOD]: ValueOf; [ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStepDeprecated; [ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER]: OnyxTypes.PolicyJoinMember; [ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS]: OnyxTypes.PolicyConnectionSyncProgress; @@ -1721,6 +1729,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/ROUTES.ts b/src/ROUTES.ts index f7b9df0bae9d..ba39e03b75f4 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -14,7 +14,7 @@ import type {ReplacementReason} from './libs/actions/Card'; import type {RootNavigatorParamList} from './libs/Navigation/types'; import type {Screen} from './SCREENS'; import type {ExpenseRuleFormFieldID} from './types/form/ExpenseRuleForm'; -import type {CompanyCardFeedWithDomainID} from './types/onyx'; +import type {CardFeedWithDomainID, CompanyCardFeedWithDomainID} from './types/onyx'; import type {ConnectionName, PolicyReportFieldType, SageIntacctMappingName} from './types/onyx/Policy'; import type {CustomFieldType} from './types/onyx/PolicyEmployee'; @@ -246,17 +246,6 @@ const DYNAMIC_ROUTES = { entryScreens: ['*'], getRoute: (accountID: number) => `avatar/${accountID}` as const, }, - SPLIT_EXPENSE_EDIT: { - path: 'edit/split-expense/:reportID/:transactionID/:splitExpenseTransactionID?', - entryScreens: [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE, SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_SEARCH], - getRoute: (reportID: string | undefined, transactionID: string | undefined, splitExpenseTransactionID?: string) => { - if (!reportID || !transactionID) { - Log.warn(`Invalid ${reportID}(reportID) or ${transactionID}(transactionID) is used to build the SPLIT_EXPENSE_EDIT dynamic route`); - } - - return `edit/split-expense/${reportID}/${transactionID}${splitExpenseTransactionID ? `/${splitExpenseTransactionID}` : ''}` as const; - }, - }, AVATAR_CROP: { path: 'avatar-crop', entryScreens: [ @@ -816,7 +805,7 @@ const DYNAMIC_ROUTES = { }, WORKSPACE_COMPANY_CARD_EXPORT: { path: 'edit/export', - entryScreens: [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_DETAILS], + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_DETAILS, SCREENS.WORKSPACE.ACCOUNTING.RILLET_CARD_ACCOUNT_CARD_LIST], }, WORKSPACE_COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE: { path: 'assign-card/:feed/:cardID/assignee', @@ -1002,7 +991,6 @@ const DYNAMIC_ROUTES = { path: 'public-domain-error', entryScreens: [SCREENS.TRAVEL.MY_TRIPS, SCREENS.WORKSPACE.TRAVEL, SCREENS.SEARCH.ROOT], getRoute: (policyID?: string) => getUrlWithParams('public-domain-error', {policyID}), - queryParams: ['policyID'], }, TRAVEL_TCS: { path: 'terms/:domain/accept/:policyID?', @@ -1025,7 +1013,6 @@ const DYNAMIC_ROUTES = { path: 'domain-selector', entryScreens: [SCREENS.TRAVEL.MY_TRIPS, SCREENS.WORKSPACE.TRAVEL, SCREENS.SEARCH.ROOT], getRoute: (policyID?: string) => getUrlWithParams('domain-selector', {policyID}), - queryParams: ['policyID'], }, TRAVEL_UPGRADE: { path: 'travel-upgrade', @@ -1692,9 +1679,10 @@ const ROUTES = { REPORT: 'r', REPORT_WITH_ID: { route: 'r/:reportID?/:reportActionID?', - getRoute: (reportID: string | undefined, reportActionID?: string, referrer?: string, backTo?: string) => { + getRoute: (reportID: string | undefined, reportActionID?: string, referrer?: string, backTo?: string, secureKey?: string) => { if (!reportID) { Log.warn('Invalid reportID is used to build the REPORT_WITH_ID route'); + return getUrlWithBackToParam(ROUTES.HOME, backTo); } const baseRoute = reportActionID ? (`r/${reportID}/${reportActionID}` as const) : (`r/${reportID}` as const); @@ -1702,6 +1690,10 @@ const ROUTES = { if (referrer) { queryParams.push(`referrer=${encodeURIComponent(referrer)}`); } + // Submit-via-PDF secure access link: lets an approver who opens the PDF link join and claim the report. + if (secureKey) { + queryParams.push(`secureKey=${encodeURIComponent(secureKey)}`); + } const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : ''; @@ -1786,6 +1778,16 @@ 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) => { @@ -3282,6 +3284,14 @@ const ROUTES = { route: 'workspaces/:policyID/rules/merchant-rules/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const, }, + RULES_MERCHANT_IMPORT: { + route: 'workspaces/:policyID/rules/merchant-rules/import', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/import` as const, + }, + RULES_MERCHANT_IMPORTED: { + route: 'workspaces/:policyID/rules/merchant-rules/imported', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/imported` as const, + }, RULES_SPEND_NEW: { route: 'workspaces/:policyID/rules/spend-rules/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new` as const, @@ -4114,6 +4124,22 @@ const ROUTES = { route: 'workspaces/:policyID/accounting/rillet/export/default-company-card-vendor', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/rillet/export/default-company-card-vendor` as const, }, + POLICY_ACCOUNTING_RILLET_CARD_PROGRAM_ACCOUNT: { + route: 'workspaces/:policyID/accounting/rillet/export/card-program-account', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/rillet/export/card-program-account` as const, + }, + POLICY_ACCOUNTING_RILLET_CARD_PROGRAM_ACCOUNT_SELECTOR: { + route: 'workspaces/:policyID/accounting/rillet/export/card-program-account/:feed', + getRoute: (policyID: string, feed: CardFeedWithDomainID) => `workspaces/${policyID}/accounting/rillet/export/card-program-account/${encodeURIComponent(feed)}` as const, + }, + POLICY_ACCOUNTING_RILLET_CARD_ACCOUNT: { + route: 'workspaces/:policyID/accounting/rillet/export/card-account', + getRoute: (policyID: string) => `workspaces/${policyID}/accounting/rillet/export/card-account` as const, + }, + POLICY_ACCOUNTING_RILLET_CARD_ACCOUNT_CARD_LIST: { + route: 'workspaces/:policyID/accounting/rillet/export/card-account/:feed', + getRoute: (policyID: string, feed: CardFeedWithDomainID) => `workspaces/${policyID}/accounting/rillet/export/card-account/${encodeURIComponent(feed)}` as const, + }, POLICY_ACCOUNTING_RILLET_ADVANCED: { route: 'workspaces/:policyID/accounting/rillet/advanced', getRoute: (policyID: string) => `workspaces/${policyID}/accounting/rillet/advanced` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 17513ec8312b..eb40b61b6080 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -366,7 +366,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', - DYNAMIC_SPLIT_EXPENSE_EDIT: 'Dynamic_Money_Request_Split_Expense_Edit', + SPLIT_EXPENSE_EDIT: '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', @@ -673,6 +673,10 @@ const SCREENS = { RILLET_VENDOR_BILL_DATE: 'Policy_Accounting_Rillet_Vendor_Bill_Date', RILLET_COMPANY_CARD_ACCOUNT: 'Policy_Accounting_Rillet_Company_Card_Account', RILLET_DEFAULT_COMPANY_CARD_VENDOR: 'Policy_Accounting_Rillet_Default_Company_Card_Vendor', + RILLET_CARD_PROGRAM_ACCOUNT: 'Policy_Accounting_Rillet_Card_Program_Account', + RILLET_CARD_PROGRAM_ACCOUNT_SELECTOR: 'Policy_Accounting_Rillet_Card_Program_Account_Selector', + RILLET_CARD_ACCOUNT: 'Policy_Accounting_Rillet_Card_Account', + RILLET_CARD_ACCOUNT_CARD_LIST: 'Policy_Accounting_Rillet_Card_Account_Card_List', RILLET_ADVANCED: 'Policy_Accounting_Rillet_Advanced', RILLET_EXPORT_METHOD: 'Policy_Accounting_Rillet_Export_Method', RILLET_BILL_PAYMENT_ACCOUNT: 'Policy_Accounting_Rillet_Bill_Payment_Account', @@ -879,6 +883,8 @@ const SCREENS = { RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_NEW: 'Rules_New', RULES_MERCHANT_NEW: 'Rules_Merchant_New', + RULES_MERCHANT_IMPORT: 'Rules_Merchant_Import', + RULES_MERCHANT_IMPORTED: 'Rules_Merchant_Imported', RULES_SPEND_NEW: 'Rules_Spend_New', RULES_REQUIRE_FIELDS_RULE_NEW: 'Rules_Require_Fields_Rule_New', RULES_REQUIRE_FIELDS_RULE_EDIT: 'Rules_Require_Fields_Rule_Edit', diff --git a/src/components/AccountManagerBookCallButton.tsx b/src/components/AccountManagerBookCallButton.tsx new file mode 100644 index 000000000000..2f037d77ca0e --- /dev/null +++ b/src/components/AccountManagerBookCallButton.tsx @@ -0,0 +1,85 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {openExternalLink} from '@libs/actions/Link'; +import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +import Avatar from './Avatar'; +import Button from './Button'; +import Text from './Text'; + +type AccountManagerBookCallButtonProps = { + /** The account manager's calendar link to open when the button is pressed */ + calendarLink: string; + + /** When provided, the account manager's avatar is displayed instead of a phone icon */ + accountManagerAccountID?: string; + + /** Whether this button is nested inside another pressable element */ + isNested?: boolean; + + /** Additional styles to apply to the button */ + style?: StyleProp; +}; + +function AccountManagerBookCallButton({calendarLink, accountManagerAccountID, isNested = false, style}: AccountManagerBookCallButtonProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['Phone']); + const [accountManagerDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { + selector: (personalDetails: PersonalDetailsList | undefined) => (accountManagerAccountID ? personalDetails?.[accountManagerAccountID] : undefined), + }); + + if (!calendarLink) { + return null; + } + + const label = translate('videoChatButtonAndMenu.tooltip'); + + const commonProps = { + onPress: callFunctionIfActionIsAllowed(() => openExternalLink(calendarLink)), + sentryLabel: CONST.SENTRY_LABEL.ACCOUNT_MANAGER_BOOK_CALL.BUTTON, + accessibilityLabel: label, + isNested, + medium: true as const, + style, + }; + + if (!accountManagerAccountID) { + return ( + + ); +} + +export default AccountManagerBookCallButton; diff --git a/src/components/AgentRules/AgentRulesList.tsx b/src/components/AgentRules/AgentRulesList.tsx new file mode 100644 index 000000000000..9201d7a178e2 --- /dev/null +++ b/src/components/AgentRules/AgentRulesList.tsx @@ -0,0 +1,71 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getAgentRuleDisplayTitle} from '@libs/AgentRulesUtils'; +import type {AgentRuleWithID} from '@libs/AgentRulesUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import {clearPolicyAgentRuleErrors} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +type AgentRulesListProps = { + policyID: string; + rules: AgentRuleWithID[]; + canWriteRules: boolean; + showReadOnlyModal: () => void; + listContainerStyle?: StyleProp; + menuItemWrapperStyle?: StyleProp; +}; + +function AgentRulesList({policyID, rules, canWriteRules, showReadOnlyModal, listContainerStyle, menuItemWrapperStyle}: AgentRulesListProps) { + const styles = useThemeStyles(); + + const handleEditAgentRule = (ruleID: string, pendingAction?: PendingAction) => { + if (!canWriteRules) { + showReadOnlyModal(); + return; + } + + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + + Navigation.navigate(ROUTES.RULES_AGENT_EDIT.getRoute(policyID, ruleID)); + }; + + return ( + + {rules.map((rule) => ( + clearPolicyAgentRuleErrors(policyID, rule.ruleID, rule)} + > + handleEditAgentRule(rule.ruleID, rule.pendingAction)} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.AGENT_RULE_ITEM} + disabled={rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} + /> + + ))} + + ); +} + +export default AgentRulesList; diff --git a/src/components/AgentRules/useAgentRulesSectionHeader.tsx b/src/components/AgentRules/useAgentRulesSectionHeader.tsx new file mode 100644 index 000000000000..41a068754e7c --- /dev/null +++ b/src/components/AgentRules/useAgentRulesSectionHeader.tsx @@ -0,0 +1,68 @@ +import Badge from '@components/Badge'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import Text from '@components/Text'; +import UserPill from '@components/UserPill'; + +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {isPolicyMemberWithoutPendingDelete} from '@libs/PolicyUtils'; + +import React from 'react'; +import {View} from 'react-native'; + +type UseAgentRulesSectionHeaderProps = { + policyID: string; + subtitle: string; + isBadgeCondensed?: boolean; +}; + +function useAgentRulesSectionHeader({policyID, subtitle, isBadgeCondensed = false}: UseAgentRulesSectionHeaderProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const policy = usePolicy(policyID); + const personalDetailsList = usePersonalDetails(); + + const ruleBotAccountID = policy?.ruleBotAccountID; + const ruleBot = ruleBotAccountID ? personalDetailsList?.[ruleBotAccountID] : undefined; + const ruleBotDisplayName = ruleBot?.displayName ?? ruleBot?.login ?? translate('workspace.rules.agentRules.ruleBotName'); + + // ruleBotAccountID stays set on the policy after RuleBot is removed from the workspace, so also require it to still be an active member before showing the "enforced by" line. + const isRuleBotActiveMember = isPolicyMemberWithoutPendingDelete(ruleBot?.login, policy); + + const renderTitle = () => ( + + {translate('workspace.rules.agentRules.title')} + + + ); + + const renderSubtitle = () => ( + + {subtitle} + {!!ruleBotAccountID && isRuleBotActiveMember && ( + + {translate('workspace.rules.agentRules.enforcedBy')} + + + )} + + ); + + return {renderTitle, renderSubtitle}; +} + +export default useAgentRulesSectionHeader; diff --git a/src/components/AnimatedFlatListWithCellRenderer.tsx b/src/components/AnimatedFlatListWithCellRenderer.tsx index 7ab4867d23dd..6a37ec2b31d0 100644 --- a/src/components/AnimatedFlatListWithCellRenderer.tsx +++ b/src/components/AnimatedFlatListWithCellRenderer.tsx @@ -1,5 +1,3 @@ -import genericMemo from '@libs/genericMemo'; - /** * This is a copy of the FlatList implementation from 'react-native-reanimated' in order to implement a custom CellRendererComponent. * This should be updated when the original implementation updates @@ -9,7 +7,7 @@ import type {Ref} from 'react'; import type {FlatListProps, CellRendererProps as RNCellRendererProps} from 'react-native'; import type {AnimatedProps, ILayoutAnimationBuilder} from 'react-native-reanimated'; -import React, {useRef} from 'react'; +import React, {createContext, useContext} from 'react'; import {FlatList} from 'react-native'; import Animated, {LayoutAnimationConfig} from 'react-native-reanimated'; @@ -17,24 +15,34 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); type CellRendererComponentProps = React.ComponentType> | null | undefined; -const createCellRendererComponent = (CellRendererComponentProp?: CellRendererComponentProps, itemLayoutAnimationRef?: React.RefObject) => { - // Make CellRendererComponent specifically use the 'Item' type from its parent scope - function CellRendererComponent(props: RNCellRendererProps) { - return ( - - {CellRendererComponentProp ? {props.children} : props.children} - - ); - } - - return CellRendererComponent; +type CellRendererConfig = { + itemLayoutAnimation?: ILayoutAnimationBuilder; + outerCellRenderer?: CellRendererComponentProps; }; + +const CellRendererConfigContext = createContext({}); + +/** + * Module-scope cell renderer so OXC's React Compiler can discover and memoize it. + * `itemLayoutAnimation` and the optional outer renderer are read from context because + * FlatList only passes standard cell props to `CellRendererComponent`. + */ +function CellRendererComponentImpl(props: RNCellRendererProps) { + const {itemLayoutAnimation, outerCellRenderer: OuterCellRenderer} = useContext(CellRendererConfigContext); + + return ( + + {OuterCellRenderer ? {props.children} : props.children} + + ); +} + type ReanimatedFlatListPropsWithLayout = { /** * Lets you pass layout animation directly to the FlatList item. @@ -57,10 +65,12 @@ type AnimatedFlatListWithCellRendererProps = Omit; }; -// We need explicit any here, because this is the exact same type that is used in React Native types. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function FlatListRender(props: AnimatedFlatListWithCellRendererProps) { - const {itemLayoutAnimation, skipEnteringExitingAnimations, ref, ...restProps} = props; +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function FlatListRenderImpl(props: AnimatedFlatListWithCellRendererProps) { + const {itemLayoutAnimation, skipEnteringExitingAnimations, ref, CellRendererComponent: outerCellRenderer, ...restProps} = props; // Set default scrollEventThrottle, because user expects // to have continuous scroll events and @@ -71,22 +81,16 @@ function FlatListRender(props: AnimatedFlatListWithCellRendererProps restProps.scrollEventThrottle = 1; } - const itemLayoutAnimationRef = useRef(itemLayoutAnimation); - itemLayoutAnimationRef.current = itemLayoutAnimation; - - const CellRendererComponent = React.useMemo( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - () => createCellRendererComponent(props.CellRendererComponent, itemLayoutAnimationRef), - [props.CellRendererComponent], - ); + const cellRendererConfig: CellRendererConfig = {itemLayoutAnimation, outerCellRenderer}; const animatedFlatList = ( - // @ts-expect-error In its current type state, createAnimatedComponent cannot create generic components. - + + + ); if (skipEnteringExitingAnimations === undefined) { @@ -103,7 +107,13 @@ function FlatListRender(props: AnimatedFlatListWithCellRendererProps ); } -const AnimatedFlatListWithCellRenderer = genericMemo(FlatListRender) as < +// We need explicit any here, because this is the exact same type that is used in React Native types. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function FlatListRender(props: AnimatedFlatListWithCellRendererProps) { + return )} />; +} + +const AnimatedFlatListWithCellRenderer = FlatListRender as < // We need explicit any here, because this is the exact same type that is used in React Native types. // eslint-disable-next-line @typescript-eslint/no-explicit-any ItemT = any, diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 3fa8cba4721f..6b8cde6c3ce9 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -11,6 +11,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {cleanFileName, showCameraPermissionsAlert, verifyFileFormat} from '@libs/fileDownload/FileUtils'; +import fileURIToPath from '@libs/fileURIToPath'; import Log from '@libs/Log'; import moveReceiptToDurableStorage from '@libs/moveReceiptToDurableStorage'; @@ -128,7 +129,7 @@ const getDataForUpload = (fileData: FileResponse): Promise => { const fileWithSize = fileResult.size ? Promise.resolve(fileResult) - : RNFetchBlob.fs.stat(fileData.uri.replace('file://', '')).then((stats) => { + : RNFetchBlob.fs.stat(fileURIToPath(fileData.uri)).then((stats) => { fileResult.size = stats.size; return fileResult; }); diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 61c57aa3cd67..ed8ce8aa5026 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -260,6 +260,21 @@ function AttachmentView({ const isSourcePDF = typeof source === 'string' && Str.isPDF(source); const isFilePDF = file && Str.isPDF(file.name ?? translate('attachmentView.unknownFilename')); if (!hasPDFFailedToLoad && !isUploading && (isSourcePDF || isFilePDF)) { + // Every mounted PDF viewer is a full PDF.js document parse (its own worker + parsed document), so in a + // carousel the memory cost scales with the number of PDF attachments — enough to OOM the WebContent + // process on iOS Safari and reload the tab when several PDFs are added at once. Only mount the viewer + // for the item the carousel currently focuses; off-screen items render a lightweight placeholder until + // they're swiped to. isFocused is undefined outside the carousel (single-attachment hosts), which must + // keep mounting immediately. + if (isFocused === false) { + return ( + + ); + } const encryptedSourceUrl = isAuthTokenRequired ? addEncryptedAuthTokenToURL(source as string, encryptedAuthToken) : (source as string); const onPDFLoadComplete = (path: string) => { diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx index 7a99b7508ba1..142fefc881bb 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -5,7 +5,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {hasHoverSupport} from '@libs/DeviceCapabilities'; -import genericMemo from '@libs/genericMemo'; import CONST from '@src/CONST'; @@ -20,7 +19,11 @@ import type {RenderSuggestionMenuItemProps} from './types'; type ExternalProps = Omit, 'left' | 'bottom'>; -function BaseAutoCompleteSuggestions({ +/** + * Non-generic implementation so OXC's React Compiler can memoize the component. + * OXC bails on type params inside components ("Unsupported declaration type for hoisting"). + */ +function BaseAutoCompleteSuggestionsImpl({ highlightedSuggestionIndex = 0, onSelect, accessibilityLabelExtractor, @@ -28,18 +31,18 @@ function BaseAutoCompleteSuggestions({ suggestions, keyExtractor, measuredHeightOfSuggestionRows, -}: ExternalProps) { +}: ExternalProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const rowHeight = useSharedValue(0); const prevRowHeightRef = useRef(measuredHeightOfSuggestionRows); const fadeInOpacity = useSharedValue(0); - const scrollRef = useRef>(null); + const scrollRef = useRef>(null); /** * Render a suggestion menu item component. */ const renderItem = useCallback( - ({item, index}: RenderSuggestionMenuItemProps): ReactElement => ( + ({item, index}: RenderSuggestionMenuItemProps): ReactElement => ( StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)} hoverDimmingValue={1} @@ -122,4 +125,8 @@ function BaseAutoCompleteSuggestions({ ); } -export default genericMemo(BaseAutoCompleteSuggestions); +function BaseAutoCompleteSuggestions(props: ExternalProps) { + return )} />; +} + +export default BaseAutoCompleteSuggestions; diff --git a/src/components/BaseVacationDelegateSelectionComponent.tsx b/src/components/BaseVacationDelegateSelectionComponent.tsx index 565226c7951e..4a1f138c38ae 100644 --- a/src/components/BaseVacationDelegateSelectionComponent.tsx +++ b/src/components/BaseVacationDelegateSelectionComponent.tsx @@ -6,7 +6,6 @@ import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelec import useThemeStyles from '@hooks/useThemeStyles'; import {searchUserInServer} from '@libs/actions/Report'; -import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import {filterOption, getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; @@ -56,7 +55,7 @@ function BaseVacationDelegateSelectionComponent({ additionalExcludeLogins, includeCurrentUser = true, }: BaseVacationDelegateSelectionComponentProps) { - const {translate} = useLocalize(); + const {translate, formatPhoneNumber} = useLocalize(); const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index ac36c4d29814..cb33ce2c1715 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -26,7 +26,7 @@ import type {ForwardedRef} from 'react'; import type {AccessibilityState, GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {StyleSheet, View} from 'react-native'; import {getButtonRole} from './utils'; @@ -214,28 +214,21 @@ function KeyboardShortcutComponent({ const isFocused = useIsFocused(); const activeElementRole = useActiveElementRole(); - const shouldDisableEnterShortcut = useMemo(() => accessibilityRoles.includes(activeElementRole ?? '') && activeElementRole !== CONST.ROLE.PRESENTATION, [activeElementRole]); + const shouldDisableEnterShortcut = accessibilityRoles.includes(activeElementRole ?? '') && activeElementRole !== CONST.ROLE.PRESENTATION; - const keyboardShortcutCallback = useCallback( - (event?: GestureResponderEvent | KeyboardEvent) => { - if (!validateSubmitShortcut(isDisabled, isLoading, event)) { - return; - } - onPress(); - }, - [isDisabled, isLoading, onPress], - ); + const keyboardShortcutCallback = (event?: GestureResponderEvent | KeyboardEvent) => { + if (!validateSubmitShortcut(isDisabled, isLoading, event)) { + return; + } + onPress(); + }; - const config = useMemo( - () => ({ - isActive: pressOnEnter && !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive), - shouldBubble: allowBubble, - priority: enterKeyEventListenerPriority, - shouldPreventDefault: false, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [shouldDisableEnterShortcut, isFocused], - ); + const config = { + isActive: pressOnEnter && !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive), + shouldBubble: allowBubble, + priority: enterKeyEventListenerPriority, + shouldPreventDefault: false, + }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, config); @@ -439,55 +432,33 @@ function Button({ buttonSize = CONST.DROPDOWN_BUTTON_SIZE.LARGE; } - const buttonStyles = useMemo>( - () => [ - styles.button, - StyleUtils.getButtonStyleWithIcon(styles, buttonSize, !!icon, !!(text?.length > 0), shouldShowRightIcon), - success ? styles.buttonSuccess : undefined, - danger ? styles.buttonDanger : undefined, - isDisabled && !shouldStayNormalOnDisable ? styles.buttonOpacityDisabled : undefined, - isDisabled && !danger && !success && !shouldStayNormalOnDisable ? styles.buttonDisabled : undefined, - shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, - shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, - text && shouldShowRightIcon ? styles.alignItemsStretch : undefined, - innerStyles, - link && styles.bgTransparent, - ], - [ - StyleUtils, - danger, - icon, - innerStyles, - isDisabled, - buttonSize, - link, - shouldRemoveLeftBorderRadius, - shouldRemoveRightBorderRadius, - shouldShowRightIcon, - styles, - success, - text, - shouldStayNormalOnDisable, - ], - ); - - const buttonContainerStyles = useMemo>( - () => [buttonStyles, shouldBlendOpacity && styles.buttonBlendContainer], - [buttonStyles, shouldBlendOpacity, styles.buttonBlendContainer], - ); - - const buttonBlendForegroundStyle = useMemo>(() => { - if (!shouldBlendOpacity) { - return undefined; - } - + const buttonStyles: StyleProp = [ + styles.button, + StyleUtils.getButtonStyleWithIcon(styles, buttonSize, !!icon, !!(text?.length > 0), shouldShowRightIcon), + success ? styles.buttonSuccess : undefined, + danger ? styles.buttonDanger : undefined, + isDisabled && !shouldStayNormalOnDisable ? styles.buttonOpacityDisabled : undefined, + isDisabled && !danger && !success && !shouldStayNormalOnDisable ? styles.buttonDisabled : undefined, + shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, + shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, + text && shouldShowRightIcon ? styles.alignItemsStretch : undefined, + innerStyles, + link && styles.bgTransparent, + ]; + + const buttonContainerStyles: StyleProp = [buttonStyles, shouldBlendOpacity && styles.buttonBlendContainer]; + + let buttonBlendForegroundStyle: StyleProp; + if (!shouldBlendOpacity) { + buttonBlendForegroundStyle = undefined; + } else { const {backgroundColor, opacity} = StyleSheet.flatten(buttonStyles); - return { + buttonBlendForegroundStyle = { backgroundColor, opacity, }; - }, [buttonStyles, shouldBlendOpacity]); + } let loadingIndicatorColor = theme.text; if (danger) { @@ -589,8 +560,6 @@ function Button({ ); } -// OXC's React Compiler bails on this file (missing memoization dependencies), so Button is not -// memoized on web. Memoize it explicitly to keep parent-driven re-renders cheap there. -export default withNavigationFallback(React.memo(Button)); +export default withNavigationFallback(Button); export type {ButtonProps}; diff --git a/src/components/ButtonComposed/context/ButtonContext.ts b/src/components/ButtonComposed/context/ButtonContext.ts index 2800e481ef9a..818f6e3cf75d 100644 --- a/src/components/ButtonComposed/context/ButtonContext.ts +++ b/src/components/ButtonComposed/context/ButtonContext.ts @@ -2,7 +2,7 @@ import CONST from '@src/CONST'; import {createContext, useContext} from 'react'; -import type {ButtonContextValue} from './types'; +import type ButtonContextValue from './types'; /** Fallback used when a Button primitive is rendered outside a ` + ); + + const headerButtons = shouldShowBulkActionsButton ? ( + > + variant={CONST.BUTTON_VARIANT.SUCCESS} + shouldAlwaysShowDropdownMenu + customText={translate('workspace.common.selected', {count: selectedAgentKeys.length})} + size={CONST.BUTTON_SIZE.MEDIUM} + onPress={() => null} + options={bulkActionsButtonOptions} + isSplitButton={false} + isDisabled={!selectedAgentKeys.length} + testID="AgentsPage-header-dropdown-menu-button" /> + ) : ( + newAgentButton ); if (!isCustomAgentEnabled) { @@ -129,23 +213,35 @@ function AgentsPage() { offlineIndicatorStyle={styles.mtAuto} > Navigation.goBack()} + icon={!selectionModeHeader ? illustrations.AiBot : undefined} + onBackButtonPress={() => { + if (isMobileSelectionModeEnabled) { + clearSelectedAgents(); + turnOffMobileSelectionMode(); + return; + } + Navigation.goBack(); + }} shouldShowBackButton={shouldUseNarrowLayout} - shouldUseHeadlineHeader + shouldUseHeadlineHeader={!selectionModeHeader} shouldDisplaySearchRouter shouldDisplayHelpButton - title={translate('agentsPage.title')} + title={selectionModeHeader ? translate('common.selectMultiple') : translate('agentsPage.title')} > - {!shouldUseNarrowLayout && newAgentButton} + {!shouldUseNarrowLayout && headerButtons} - {shouldUseNarrowLayout && {newAgentButton}} - - - - - - + {shouldUseNarrowLayout && {headerButtons}} + {hasAgents && ( + + + + )} + ); } diff --git a/src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx b/src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx index de52ea8efcaa..0fa4e5834c37 100644 --- a/src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx +++ b/src/pages/settings/ExitSurvey/DynamicExitSurveyConfirmPage.tsx @@ -1,7 +1,7 @@ -import Icon from '@components//Icon'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -62,6 +62,12 @@ function DynamicExitSurveyConfirmPage() { return `${parentBackPath.replace(/\/+$/, '')}/${reasonPathSuffix}` as Route; }, [isOffline, exitSurveyResponse, parentBackPath]); + const goToExpensifyClassic = () => { + switchToOldDot(exitSurveyResponse); + Navigation.dismissModal(); + openOldDotLink(CONST.OLDDOT_URLS.INBOX, true); + }; + return ( ); diff --git a/src/pages/settings/HelpPage/HelpPage.tsx b/src/pages/settings/HelpPage/HelpPage.tsx index 83f3c88cf921..0f9b76e3e6a1 100644 --- a/src/pages/settings/HelpPage/HelpPage.tsx +++ b/src/pages/settings/HelpPage/HelpPage.tsx @@ -1,3 +1,4 @@ +import AccountManagerBookCallButton from '@components/AccountManagerBookCallButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemList from '@components/MenuItemList'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -25,7 +26,7 @@ import colors from '@styles/theme/colors'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {hasSeenTourSelector} from '@src/selectors/Onboarding'; +import {guidedSetupAndTourStatusSelector} from '@src/selectors/Onboarding'; import React, {useEffect} from 'react'; import {View} from 'react-native'; @@ -45,7 +46,7 @@ function HelpPage() { const partnerManagerDetails = account?.partnerManagerAccountID ? personalDetails?.[account.partnerManagerAccountID] : null; const guideDetails = account?.guideDetails?.email ? getPersonalDetailByEmail(account.guideDetails.email) : null; const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const [betas] = useOnyx(ONYXKEYS.BETAS); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {openConciergeAnywhere} = useOpenConciergeAnywhere(); @@ -57,7 +58,16 @@ function HelpPage() { description: isApprovedAccountant ? translate('initialSettingsPage.helpPage.partnerManagerDescription') : undefined, icon: partnerManagerDetails.avatar, iconType: CONST.ICON_TYPE_AVATAR, - onPress: () => navigateToAndOpenReportWithAccountIDs([partnerManagerDetails.accountID], currentUserAccountID, introSelected, isSelfTourViewed, betas, personalDetails), + onPress: () => + navigateToAndOpenReportWithAccountIDs( + [partnerManagerDetails.accountID], + currentUserAccountID, + introSelected, + guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + betas, + personalDetails, + ), shouldShowRightIcon: true, wrapperStyle: [styles.sectionMenuItemTopDescription], sentryLabel: CONST.SENTRY_LABEL.SETTINGS_HELP.PARTNER_MANAGER, @@ -71,13 +81,23 @@ function HelpPage() { description: isApprovedAccountant ? translate('initialSettingsPage.helpPage.accountExecutiveDescription') : undefined, icon: guideDetails.avatar, iconType: CONST.ICON_TYPE_AVATAR, - onPress: () => navigateToAndOpenReportWithAccountIDs([guideDetails.accountID], currentUserAccountID, introSelected, isSelfTourViewed, betas, personalDetails), + onPress: () => + navigateToAndOpenReportWithAccountIDs( + [guideDetails.accountID], + currentUserAccountID, + introSelected, + guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + betas, + personalDetails, + ), shouldShowRightIcon: true, wrapperStyle: [styles.sectionMenuItemTopDescription], sentryLabel: CONST.SENTRY_LABEL.SETTINGS_HELP.GUIDE, } : null; + const accountManagerCalendarLink = account?.accountManagerCalendarLink; const accountManagerItem = accountManagerDetails ? { key: accountManagerDetails.login, @@ -85,8 +105,27 @@ function HelpPage() { description: isApprovedAccountant ? translate('initialSettingsPage.helpPage.accountManagerDescription') : undefined, icon: accountManagerDetails.avatar, iconType: CONST.ICON_TYPE_AVATAR, - onPress: () => navigateToAndOpenReportWithAccountIDs([accountManagerDetails.accountID], currentUserAccountID, introSelected, isSelfTourViewed, betas, personalDetails), - shouldShowRightIcon: true, + onPress: () => + navigateToAndOpenReportWithAccountIDs( + [accountManagerDetails.accountID], + currentUserAccountID, + introSelected, + guidedSetupAndTourStatus?.isSelfTourViewed, + guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + betas, + personalDetails, + ), + shouldShowRightIcon: !accountManagerCalendarLink, + shouldShowRightComponent: !!accountManagerCalendarLink, + + // Disable the row's accessibility grouping so screen readers can reach the nested Book a call button as its own element + shouldBeAccessible: !accountManagerCalendarLink, + rightComponent: accountManagerCalendarLink ? ( + + ) : undefined, wrapperStyle: [styles.sectionMenuItemTopDescription], sentryLabel: CONST.SENTRY_LABEL.SETTINGS_HELP.ACCOUNT_MANAGER, } diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index e576931aeed1..d5356b0d3fa8 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -45,7 +45,7 @@ function ContactMethodsPage({route}: ContactMethodsPageProps) { const options = useMemo(() => getContactMethodsOptions(translate, loginList, session?.email), [translate, loginList, session?.email]); - const onNewContactMethodButtonPress = useCallback(() => { + const addNewContactMethod = useCallback(() => { if (isActingAsDelegate) { showDelegateNoAccessModal(); return; @@ -96,12 +96,13 @@ function ContactMethodsPage({route}: ContactMethodsPageProps) { )} diff --git a/src/pages/settings/Security/DeviceManagementPage.tsx b/src/pages/settings/Security/DeviceManagementPage.tsx index df5490203a34..dd54bd91a912 100644 --- a/src/pages/settings/Security/DeviceManagementPage.tsx +++ b/src/pages/settings/Security/DeviceManagementPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; @@ -13,6 +13,7 @@ import {clearRevokeError, revokeDevice} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import {getDeviceDisplayName, getDeviceLogins, getLastLogin, getLoginKey} from '@libs/UserUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Credentials} from '@src/types/onyx'; import type {Login} from '@src/types/onyx/Logins'; @@ -56,11 +57,12 @@ function DeviceManagementPage() { {displayName} ); }; diff --git a/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx b/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx index 99e32e919e61..a175ef2dad3e 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import ScrollView from '@components/ScrollView'; import type {BaseTwoFactorAuthFormRef} from '@components/TwoFactorAuthForm/types'; @@ -85,9 +85,8 @@ function DisablePage() { ); diff --git a/src/pages/settings/Security/TwoFactorAuth/DisabledPage.tsx b/src/pages/settings/Security/TwoFactorAuth/DisabledPage.tsx index bcffe1da40cd..ac39d2650a37 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DisabledPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DisabledPage.tsx @@ -1,5 +1,5 @@ import BlockingView from '@components/BlockingViews/BlockingView'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; @@ -36,11 +36,12 @@ function DisabledPage() { /> ); diff --git a/src/pages/settings/Security/TwoFactorAuth/DynamicTwoFactorAuthPage.tsx b/src/pages/settings/Security/TwoFactorAuth/DynamicTwoFactorAuthPage.tsx index 92fa4f8f8c47..fcb0893d9360 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DynamicTwoFactorAuthPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DynamicTwoFactorAuthPage.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import PressableWithDelayToggle from '@components/Pressable/PressableWithDelayToggle'; @@ -182,10 +182,9 @@ function DynamicTwoFactorAuthPage() { )} {!!recoveryCodes && ( )} diff --git a/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx b/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx index 6f2ad7c1fddb..fc87903c3fc9 100644 --- a/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/DynamicVerifyPage.tsx @@ -1,6 +1,6 @@ import expensifyLogo from '@assets/images/expensify-logo-round-transparent.png'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import PressableWithDelayToggle from '@components/Pressable/PressableWithDelayToggle'; import QRCode from '@components/QRCode'; @@ -150,9 +150,8 @@ function DynamicVerifyPage() { ); diff --git a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx index f6013ab66532..ef9728166b28 100644 --- a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyNewPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; @@ -98,9 +98,8 @@ function ReplaceDeviceVerifyNewPage() { ); diff --git a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyOldPage.tsx b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyOldPage.tsx index 62ea1a12e2b3..6f512030859d 100644 --- a/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyOldPage.tsx +++ b/src/pages/settings/Security/TwoFactorAuth/ReplaceDeviceVerifyOldPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import ScrollView from '@components/ScrollView'; import TwoFactorAuthForm from '@components/TwoFactorAuthForm'; @@ -80,9 +80,8 @@ function ReplaceDeviceVerifyOldPage() { ); diff --git a/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx b/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx index 1564ac2f9172..58170ea7ecb9 100644 --- a/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx +++ b/src/pages/settings/Subscription/CancelSubscriptionPage/index.tsx @@ -1,5 +1,5 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import FeedbackSurvey from '@components/FeedbackSurvey'; import FixedFooter from '@components/FixedFooter'; @@ -99,11 +99,12 @@ function CancelSubscriptionPage() { )} @@ -118,11 +119,12 @@ function CancelSubscriptionPage() { )} diff --git a/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx b/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx index c93faf2f220b..b01457e39f1b 100644 --- a/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx +++ b/src/pages/settings/Subscription/SubscriptionPlan/SaveWithExpensifyButton/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import useLocalize from '@hooks/useLocalize'; @@ -16,12 +16,13 @@ function SaveWithExpensifyButton() { return ( ); } diff --git a/src/pages/settings/Subscription/SubscriptionPlan/index.tsx b/src/pages/settings/Subscription/SubscriptionPlan/index.tsx index 4ac160cbadbe..d67678c847c5 100644 --- a/src/pages/settings/Subscription/SubscriptionPlan/index.tsx +++ b/src/pages/settings/Subscription/SubscriptionPlan/index.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import Icon from '@components/Icon'; import Section from '@components/Section'; import Text from '@components/Text'; @@ -36,11 +36,12 @@ function SubscriptionPlan() { {translate('subscription.yourPlan.title')} ); }; diff --git a/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx b/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx index a047a7eb715f..3baee5eda401 100644 --- a/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx +++ b/src/pages/settings/Subscription/SubscriptionSize/subPages/Confirmation.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import Text from '@components/Text'; @@ -11,6 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getNewSubscriptionRenewalDate} from '@pages/settings/Subscription/utils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/SubscriptionSizeForm'; @@ -43,11 +44,12 @@ function Confirmation({onNext}: ConfirmationProps) { /> ); diff --git a/src/pages/settings/Wallet/ImportTransactionsPage.tsx b/src/pages/settings/Wallet/ImportTransactionsPage.tsx index 1c1643607949..06d9f85fd71d 100644 --- a/src/pages/settings/Wallet/ImportTransactionsPage.tsx +++ b/src/pages/settings/Wallet/ImportTransactionsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -98,11 +98,12 @@ function ImportTransactionsPage() { diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx index 34f3e81e2c17..bb301f2d36a0 100644 --- a/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx +++ b/src/pages/settings/Wallet/InternationalDepositAccount/PersonalInfo/PersonalInfo.tsx @@ -7,6 +7,7 @@ import type {SubStepProps} from '@hooks/useSubStep/types'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import {formatE164PhoneNumber} from '@libs/LoginUtils'; +import {getCurrentAddress, getStreetLines} from '@libs/PersonalDetailsUtils'; import Navigation from '@navigation/Navigation'; @@ -57,8 +58,24 @@ function PersonalInfoPage() { plaidAccessToken: plaidData?.plaidAccessToken ?? '', }; const finalPhoneNumber = personalBankAccount?.phoneNumber ?? privatePersonalDetails?.phoneNumber ?? ''; + + // When the Address substep is skipped (the profile already has a complete address), the flat + // addressStreet/addressCity/... keys that addPersonalBankAccount expects are never written to the form draft. + // Map the saved profile address (stored nested in the addresses array) to those flat keys so the address + // is still submitted. The form draft spread below wins, so a manually entered address still takes precedence. + const currentAddress = getCurrentAddress(privatePersonalDetails); + const [addressStreet, street2] = getStreetLines(currentAddress?.street); + // The unit/suite may be stored either embedded after a newline in `street` (extracted above) or in the + // separate `street2`/`addressLine2` fields; fall back to those so it isn't dropped, matching UpdatePersonalBankAccountPage. + const addressStreet2 = street2 ?? currentAddress?.street2 ?? currentAddress?.addressLine2; const accountData = { ...privatePersonalDetails, + addressStreet, + addressStreet2, + addressCity: currentAddress?.city, + addressState: currentAddress?.state, + addressZipCode: currentAddress?.zip, + country: currentAddress?.country, ...personalBankAccount, ...bankAccountWithToken, phoneNumber: formatE164PhoneNumber(finalPhoneNumber, countryCode), diff --git a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx index 5345781a8bbb..7d82e190508c 100644 --- a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx +++ b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import DatePicker from '@components/DatePicker'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -123,12 +123,13 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans addBottomSafeAreaPadding footerContent={ } listFooterContent={ dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM ? ( diff --git a/src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx b/src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx index 120ca7732e21..65c0d6439bdd 100644 --- a/src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx +++ b/src/pages/settings/Wallet/PersonalCards/upgrade/PersonalCardUpgradePage.tsx @@ -38,6 +38,8 @@ function PersonalCardUpgradePage() { const [betas] = useOnyx(ONYXKEYS.BETAS); const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {accountID, email = ''} = currentUserPersonalDetails; @@ -56,6 +58,7 @@ function PersonalCardUpgradePage() { policyID, lastUsedPaymentMethod: lastPaymentMethod?.[policyID] as LastPaymentMethodType, activePolicy, + conciergeChat, currentUserAccountIDParam: accountID, currentUserEmailParam: email, shouldCreateControlPolicy: false, diff --git a/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeIntro.tsx b/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeIntro.tsx index f4443bf982dc..5931968c4319 100644 --- a/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeIntro.tsx +++ b/src/pages/settings/Wallet/PersonalCards/upgrade/UpgradeIntro.tsx @@ -1,5 +1,5 @@ import Badge from '@components/Badge'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import Icon from '@components/Icon'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; @@ -78,13 +78,14 @@ function UpgradeIntro({onUpgrade, buttonDisabled}: Props) { diff --git a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx index 44cc5e3888cb..fa2d40ebad16 100644 --- a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx +++ b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import ConfirmModal from '@components/ConfirmModal'; import ErrorMessageRow from '@components/ErrorMessageRow'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -124,16 +124,20 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { }; const itemRightSideComponent = (item: ListItem) => { + const promptUnshare = () => setUnshareUser({login: item?.login, text: item?.text}); + const isUnshareButtonLoading = isLoading && unsharedBankAccountData?.email === item?.login; + return ( ); }; diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index e4bc7aa3498e..4ceaa0ef89d1 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -37,7 +37,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods, getPaymentMethodDescription} from '@libs/PaymentUtils'; import {getStreetLines} from '@libs/PersonalDetailsUtils'; -import {getActiveAdminWorkspaces, getDescriptionForPolicyDomainCard, hasActiveAdminWorkspaces, hasEligibleActiveAdminFromWorkspaces, isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {getActiveAdminWorkspaces, getDescriptionForPolicyDomainCard, hasActiveAdminWorkspaces, hasEligibleBankAccountShareRecipient, isPaidGroupPolicy} from '@libs/PolicyUtils'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -70,6 +70,7 @@ import {View} from 'react-native'; import type {CardPressHandlerParams, PaymentMethodPressHandlerParams} from './types'; import useWalletSectionIllustration from './useWalletSectionIllustration'; +import shouldOpenBankAccountByPolicy from './utils'; const fundListSelector = (allFunds: OnyxEntry) => Object.fromEntries(Object.entries(allFunds ?? {}).filter(([, item]) => item.accountData?.additionalData?.isP2PDebitCard === true)); @@ -134,7 +135,7 @@ function WalletPage() { const shouldShowGBDisclaimer = countryByIp === CONST.COUNTRY.GB; const isPendingOnfidoResult = userWallet?.isPendingOnfidoResult ?? false; const hasFailedOnfido = userWallet?.hasFailedOnfido ?? false; - const hasEligibleActiveAdmin = hasEligibleActiveAdminFromWorkspaces(allPolicies, currentUserLogin, paymentMethod?.selectedPaymentMethod?.bankAccountID?.toString()); + const hasEligibleShareRecipient = hasEligibleBankAccountShareRecipient(allPolicies, currentUserLogin, paymentMethod?.selectedPaymentMethod?.bankAccountID?.toString()); const paidGroupPolicy = Object.values(allPolicies ?? {}).find(isPaidGroupPolicy); const walletLoadingReasonAttributes: SkeletonSpanReasonAttributes = {context: 'WalletPage', shouldShowLoadingSpinner}; @@ -233,13 +234,8 @@ function WalletPage() { showLockedAccountModal(); return; } - if (accountPolicyID) { - navigateToBankAccountRoute({ - policyID: accountPolicyID, - backTo: ROUTES.SETTINGS_WALLET, - policyCurrency: allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${accountPolicyID}`]?.outputCurrency, - bankAccountState: accountData?.state, - }); + if (accountPolicyID && shouldOpenBankAccountByPolicy(accountData, allPolicies, currentUserLogin)) { + navigateToBankAccountRoute({policyID: accountPolicyID, backTo: ROUTES.SETTINGS_WALLET}); return; } navigateToBankAccountRoute({bankAccountID, backTo: ROUTES.SETTINGS_WALLET}); @@ -499,7 +495,7 @@ function WalletPage() { }, ] : []), - ...(shouldShowShareButton && hasEligibleActiveAdmin + ...(shouldShowShareButton && hasEligibleShareRecipient ? [ { text: translate('common.share'), @@ -576,7 +572,7 @@ function WalletPage() { icons.Trashcan, icons.Globe, shouldShowShareButton, - hasEligibleActiveAdmin, + hasEligibleShareRecipient, shouldShowUnshareButton, shouldShowEnableGlobalReimbursementsButton, isAccountLocked, diff --git a/src/pages/settings/Wallet/WalletPage/utils.ts b/src/pages/settings/Wallet/WalletPage/utils.ts new file mode 100644 index 000000000000..ee8d50758e57 --- /dev/null +++ b/src/pages/settings/Wallet/WalletPage/utils.ts @@ -0,0 +1,19 @@ +import {canMemberWrite} from '@libs/PolicyUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {AccountData, Policy} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +function shouldOpenBankAccountByPolicy(accountData: AccountData | undefined, policies: OnyxCollection | null, currentUserLogin: string | undefined): boolean { + const policyID = accountData?.additionalData?.policyID; + if (!policyID || !currentUserLogin) { + return false; + } + + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + return canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS); +} + +export default shouldOpenBankAccountByPolicy; diff --git a/src/pages/signin/SAMLSignInPage/index.native.tsx b/src/pages/signin/SAMLSignInPage/index.native.tsx index aff91eebb9a0..ef9a65b74fbb 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.tsx +++ b/src/pages/signin/SAMLSignInPage/index.native.tsx @@ -105,6 +105,11 @@ function SAMLSignInPage() { openAuthSessionAsync(SAMLUrl, CONST.SAML_REDIRECT_URL) .then((response: WebBrowserAuthSessionResult) => { if (response.type !== 'success') { + // The auth session closed without handing a callback URL back to the app (e.g. the in-app browser + // was dismissed/cancelled, or the redirect to the custom scheme never fired). Log the result type so + // we can distinguish "browser never returned a success result" from "returned but had no token" + // (which is already logged in handleNavigationStateChange) when debugging SAML sign-in loops. + Log.hmmm('SAMLSignInPage - Auth session closed without a successful result', {type: response.type}); handleExitSAMLFlow(); return; } diff --git a/src/pages/signin/SignInModal.tsx b/src/pages/signin/SignInModal.tsx index 8ffd661846fb..ec4a4484e01d 100644 --- a/src/pages/signin/SignInModal.tsx +++ b/src/pages/signin/SignInModal.tsx @@ -9,6 +9,7 @@ import useTheme from '@hooks/useTheme'; import {openApp} from '@libs/actions/App'; import {isMobileSafari} from '@libs/Browser'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import Navigation from '@libs/Navigation/Navigation'; import {waitForIdle} from '@libs/Network/SequentialQueue'; @@ -63,7 +64,11 @@ function SignInModal() { return; } + const shouldPreserveRevealedReport = isReportTopmostSplitNavigator(); Navigation.dismissModal(); + if (shouldPreserveRevealedReport) { + return; + } Navigation.navigate(ROUTES.HOME); }, [isLoadingApp]); diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx index 4e6c8811f708..043e7cae1d34 100644 --- a/src/pages/signin/SignInPage.tsx +++ b/src/pages/signin/SignInPage.tsx @@ -79,6 +79,7 @@ type GetRenderOptionsParams = { credentials: OnyxEntry; isAccountValidated?: boolean; isSupportalSession: boolean; + isAuthenticatingWithShortLivedToken: boolean; }; /** @@ -102,6 +103,7 @@ function getRenderOptions({ credentials, isAccountValidated, isSupportalSession, + isAuthenticatingWithShortLivedToken, }: GetRenderOptionsParams): RenderOption { const hasAccount = !isEmptyObject(account); const isSAMLEnabled = !!account?.isSAMLEnabled; @@ -112,7 +114,13 @@ function getRenderOptions({ // True, if the user has SAML required, and we haven't yet initiated SAML for their account. // Supportal sessions authenticate with a support auth token and must bypass SAML entirely, so we never // initiate SAML during a supportal session, even when the customer's account has SAML required. - const shouldInitiateSAMLLogin = hasAccount && hasLogin && isSAMLRequired && !hasInitiatedSAMLLogin && !!account.isLoading && !isSupportalSession; + // We must NOT (re-)initiate SAML while a short-lived-token redeem is in flight: after the SAML redirect returns to + // /transition, LogInWithShortLivedAuthTokenPage navigates HOME while still unauthenticated, remounting SignInPage + // with hasInitiatedSAMLLogin reset. If account.isLoading is still optimistically true (slow IdP/network) SAML would + // re-fire before the redeem lands -> infinite loop. isAuthenticatingWithShortLivedToken (RAM-only, set for the exact + // duration of the redeem) gates that off, independent of the isLoading timing race. + const shouldInitiateSAMLLogin = hasAccount && hasLogin && isSAMLRequired && !hasInitiatedSAMLLogin && !!account.isLoading && !isSupportalSession && !isAuthenticatingWithShortLivedToken; + const shouldShowChooseSSOOrMagicCode = hasAccount && hasLogin && isSAMLEnabled && !isSAMLRequired && !isUsingMagicCode; // SAML required users may reload the login page after having already entered their login details, in which @@ -165,6 +173,7 @@ function SignInPage({ref}: SignInPageProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const isAccountValidated = account?.validated; const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS); + const [isAuthenticatingWithShortLivedToken] = useOnyx(ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN); /** This variable is only added to make sure the component is re-rendered whenever the activeClients change, so that we call the @@ -227,6 +236,7 @@ function SignInPage({ref}: SignInPageProps) { credentials, isAccountValidated, isSupportalSession: isSupportalSessionUtils(), + isAuthenticatingWithShortLivedToken: !!isAuthenticatingWithShortLivedToken, }); if (shouldInitiateSAMLLogin) { diff --git a/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx b/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx index a4855df81186..674871cde30a 100644 --- a/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx +++ b/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx @@ -82,7 +82,7 @@ function ConnectExistingBusinessBankAccountPage({route}: ConnectExistingBusiness Navigation.setNavigationActionToMicrotaskQueue(() => { if (isBankAccountPartiallySetup(accountData?.state)) { - navigateToBankAccountRoute({policyID, backTo, navigationOptions: {forceReplace: true}, policyCurrency: policy?.outputCurrency, bankAccountState: accountData?.state}); + navigateToBankAccountRoute({policyID, backTo, navigationOptions: {forceReplace: true}}); } else { setReimbursementAccountLoading(false); Navigation.closeRHPFlow(); diff --git a/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx b/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx index 190346d0eefe..855af9a27098 100644 --- a/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx +++ b/src/pages/workspace/DynamicWorkspaceConfirmationPage.tsx @@ -33,6 +33,8 @@ function DynamicWorkspaceConfirmationPage() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const privateSubscription = usePrivateSubscription(); @@ -69,6 +71,7 @@ function DynamicWorkspaceConfirmationPage() { routeToNavigateAfterCreate: routeToNavigate, lastUsedPaymentMethod: lastPaymentMethod?.[policyID] as LastPaymentMethodType, activePolicy, + conciergeChat, currentUserAccountIDParam: currentUserPersonalDetails.accountID, currentUserEmailParam: currentUserPersonalDetails.email ?? '', shouldCreateControlPolicy: isSubscriptionTypeOfInvoicing(privateSubscription?.type), diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 6ca26bb334d2..6ee5d9799be5 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -44,7 +44,6 @@ import { import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; import {getLatestErrorMessageField} from '@libs/ErrorUtils'; import {getConnectedHRProvider, showMergeHRManualSyncLimitModalIfReached} from '@libs/HRUtils'; -import Log from '@libs/Log'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -338,7 +337,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const details = personalDetails?.[accountID]; if (!details) { - Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`); continue; } diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index 5d3398258ddc..d948e4f95d9b 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -13,6 +13,7 @@ import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePolicyData from '@hooks/usePolicyData'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -83,8 +84,10 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {showConfirmModal} = useConfirmModal(); + const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); const illustrations = useMemoizedLazyIllustrations([ 'FolderOpen', 'Accounting', @@ -92,6 +95,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro 'Workflows', 'InvoiceBlue', 'Rules', + 'Flash', 'Tag', 'PerDiem', 'HandCard', @@ -469,7 +473,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro }} /> >(); const {isRestrictedPolicyCreation} = usePreferredPolicy(); const [duplicateWorkspace] = useOnyx(ONYXKEYS.DUPLICATE_WORKSPACE); diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index 120e83fba20e..a7bfe791464f 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -1,5 +1,7 @@ import AccountingConnectionConfirmationModal from '@components/AccountingConnectionConfirmationModal'; +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -42,6 +44,8 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider const hasReusablePoliciesConnectedToQBD = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policyID); const hasReusablePoliciesConnectedToCertinia = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policyID); const hasReusablePoliciesConnectedToRillet = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policyID); + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); const startIntegrationFlow = useCallback( (newActiveIntegration: ActiveIntegration) => { @@ -69,6 +73,8 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting, undefined, accountingIcons, + cardFeeds, + cardLists, ); const workspaceUpgradeNavigationDetails = accountingIntegrationData?.workspaceUpgradeNavigationDetails; @@ -92,6 +98,8 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider hasReusablePoliciesConnectedToCertinia, hasReusablePoliciesConnectedToRillet, accountingIcons, + cardFeeds, + cardLists, ], ); @@ -144,6 +152,8 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider undefined, undefined, accountingIcons, + cardFeeds, + cardLists, )?.setupConnectionFlow; }; diff --git a/src/pages/workspace/accounting/ClaimOfferPage.tsx b/src/pages/workspace/accounting/ClaimOfferPage.tsx index d74ed853532c..1792d57109ca 100644 --- a/src/pages/workspace/accounting/ClaimOfferPage.tsx +++ b/src/pages/workspace/accounting/ClaimOfferPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; @@ -125,19 +125,21 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { {!!config.claimOfferLink && ( )} ); diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 9b96159d7254..b110735649e0 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -17,6 +17,8 @@ import TextLink from '@components/TextLink'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; import useConfirmModal from '@hooks/useConfirmModal'; import useEnvironment from '@hooks/useEnvironment'; import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; @@ -118,6 +120,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'QuestionMark', 'Send', 'Sync', 'Trashcan']); const accountingIcons = useMemoizedLazyExpensifyIcons(['IntacctSquare', 'QBOSquare', 'XeroSquare', 'NetSuiteSquare', 'QBDSquare', 'CertiniaSquare', 'RilletSquare']); const illustrations = useMemoizedLazyIllustrations(['Accounting']); + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); const canUseRilletIntegration = isBetaEnabled(CONST.BETAS.RILLET) || !!policy?.connections?.rillet; const accountingIntegrations = useMemo( @@ -427,6 +431,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { undefined, undefined, accountingIcons, + cardFeeds, + cardLists, ); if (!integrationData) { return undefined; @@ -509,6 +515,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { undefined, isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), accountingIcons, + cardFeeds, + cardLists, ); const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; @@ -543,10 +551,13 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { wrapperStyle: [styles.sectionMenuItemTopDescription], onPress: integrationData?.onExportPagePress, brickRoadIndicator: - areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || shouldShowQBOReimbursableExportDestinationAccountError(policy) + areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || + shouldShowQBOReimbursableExportDestinationAccountError(policy) || + integrationData?.externalSubscribedExportSettingsHasErrorFields ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - pendingAction: settingsPendingAction(integrationData?.subscribedExportSettings, integrationData?.pendingFields), + pendingAction: + settingsPendingAction(integrationData?.subscribedExportSettings, integrationData?.pendingFields) ?? integrationData?.externalSubscribedExportSettingsPendingAction, }, ...(shouldShowCardReconciliationOption && integrationData?.onCardReconciliationPagePress ? [ @@ -626,6 +637,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { translate, isBetaEnabled, accountingIcons, + cardFeeds, + cardLists, connectionSyncProgress?.stageInProgress, icons.Pencil, icons.ArrowRight, @@ -684,6 +697,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { undefined, undefined, accountingIcons, + cardFeeds, + cardLists, ); if (!integrationData) { return undefined; @@ -752,6 +767,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { startIntegrationFlow, popoverAnchorRefs, accountingIcons, + cardFeeds, + cardLists, canWriteAccounting, showReadOnlyModal, ]); diff --git a/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx b/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx index 7a11e6489ce5..e83c196274d1 100644 --- a/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx +++ b/src/pages/workspace/accounting/intacct/DynamicSageIntacctPrerequisitesPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; @@ -41,6 +41,8 @@ function DynamicSageIntacctPrerequisitesPage({route}: DynamicSageIntacctPrerequi const policyID: string = route.params.policyID; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path); + const navigateToEnterCredentials = () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); + const menuItems = useMemo( () => [ { @@ -113,12 +115,13 @@ function DynamicSageIntacctPrerequisitesPage({route}: DynamicSageIntacctPrerequi addBottomSafeAreaPadding > ); diff --git a/src/pages/workspace/accounting/intacct/import/SageIntacctUserDimensionsPage.tsx b/src/pages/workspace/accounting/intacct/import/SageIntacctUserDimensionsPage.tsx index 5f70d82e6393..909f061b2dc0 100644 --- a/src/pages/workspace/accounting/intacct/import/SageIntacctUserDimensionsPage.tsx +++ b/src/pages/workspace/accounting/intacct/import/SageIntacctUserDimensionsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import ConnectionLayout from '@components/ConnectionLayout'; import FixedFooter from '@components/FixedFooter'; import Icon from '@components/Icon'; @@ -36,6 +36,8 @@ function SageIntacctUserDimensionsPage({policy}: WithPolicyProps) { const config = policy?.connections?.intacct?.config; const userDimensions = policy?.connections?.intacct?.config?.mappings?.dimensions ?? []; + const addUserDefinedDimension = () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ADD_USER_DIMENSION.getRoute(policyID)); + return ( ); diff --git a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldPage.tsx b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldPage.tsx index 3577720eb9c3..8034beb9cd14 100644 --- a/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldPage.tsx +++ b/src/pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import ConnectionLayout from '@components/ConnectionLayout'; import FixedFooter from '@components/FixedFooter'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; @@ -148,8 +148,8 @@ function NetSuiteImportCustomFieldPage({ ); diff --git a/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx b/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx index 4ef6e358d795..9250da6bd9af 100644 --- a/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx +++ b/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupPage.tsx @@ -1,6 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import CopyTextToClipboard from '@components/CopyTextToClipboard'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -86,6 +86,10 @@ function RequireQuickBooksDesktopModal({route}: RequireQuickBooksDesktopModalPro hasResultOfFetchingSetupLink, }; + const navigateToFirstSync = () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRIGGER_FIRST_SYNC.getRoute(policyID)); + }; + const children = ( <> {shouldShowError && ( @@ -124,12 +128,13 @@ function RequireQuickBooksDesktopModal({route}: RequireQuickBooksDesktopModalPro addBottomSafeAreaPadding > )} diff --git a/src/pages/workspace/accounting/qbd/RequireQuickBooksDesktopPage.tsx b/src/pages/workspace/accounting/qbd/RequireQuickBooksDesktopPage.tsx index c7243cc3f0a2..c4d4cfd887b6 100644 --- a/src/pages/workspace/accounting/qbd/RequireQuickBooksDesktopPage.tsx +++ b/src/pages/workspace/accounting/qbd/RequireQuickBooksDesktopPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ImageSVG from '@components/ImageSVG'; @@ -12,6 +12,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; + import React from 'react'; import {View} from 'react-native'; @@ -20,6 +22,8 @@ function RequireQuickBooksDesktopModal() { const styles = useThemeStyles(); const illustrations = useMemoizedLazyIllustrations(['LaptopWithSecondScreenX']); + const confirm = () => Navigation.goBack(); + return ( diff --git a/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx b/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx index 9928dea9e0f9..95f128c82834 100644 --- a/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx +++ b/src/pages/workspace/accounting/rillet/advanced/RilletAdvancedPage.tsx @@ -123,7 +123,7 @@ function RilletAdvancedPage({policy}: WithPolicyConnectionsProps) { > (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_BILL_PAYMENT_ACCOUNT.getRoute(policyID)) : undefined)} shouldShowRightIcon @@ -153,7 +153,7 @@ function RilletAdvancedPage({policy}: WithPolicyConnectionsProps) { > (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_EXPENSIFY_CARD_SETTLEMENT_ACCOUNT.getRoute(policyID)) : undefined)} shouldShowRightIcon @@ -187,7 +187,9 @@ function RilletAdvancedPage({policy}: WithPolicyConnectionsProps) { > (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_TRAVEL_INVOICING_SETTLEMENT_ACCOUNT.getRoute(policyID)) : undefined)} shouldShowRightIcon diff --git a/src/pages/workspace/accounting/rillet/advanced/RilletBillPaymentAccountPage.tsx b/src/pages/workspace/accounting/rillet/advanced/RilletBillPaymentAccountPage.tsx index 95e3d64ddc07..a97ae9552335 100644 --- a/src/pages/workspace/accounting/rillet/advanced/RilletBillPaymentAccountPage.tsx +++ b/src/pages/workspace/accounting/rillet/advanced/RilletBillPaymentAccountPage.tsx @@ -46,7 +46,7 @@ function RilletBillPaymentAccountPage({policy}: WithPolicyConnectionsProps) { ?.filter((accountItem) => accountItem.type === CONST.RILLET_ACCOUNT_TYPE.ASSET && accountItem.status === CONST.RILLET_ACCOUNT_STATUS.ACTIVE) .map((accountItem) => ({ value: accountItem.code, - text: accountItem.name, + text: `${accountItem.code} ${accountItem.name}`, keyForList: accountItem.code, isSelected: billPaymentAccountCode === accountItem.code, })) ?? []; diff --git a/src/pages/workspace/accounting/rillet/advanced/RilletExpensifyCardSettlementAccountPage.tsx b/src/pages/workspace/accounting/rillet/advanced/RilletExpensifyCardSettlementAccountPage.tsx index 704779c9c5bd..5a29e30275b3 100644 --- a/src/pages/workspace/accounting/rillet/advanced/RilletExpensifyCardSettlementAccountPage.tsx +++ b/src/pages/workspace/accounting/rillet/advanced/RilletExpensifyCardSettlementAccountPage.tsx @@ -46,7 +46,7 @@ function RilletExpensifyCardSettlementAccountPage({policy}: WithPolicyConnection ?.filter((bankAccountItem) => bankAccountItem.status === CONST.RILLET_ACCOUNT_STATUS.ACTIVE) .map((bankAccountItem) => ({ value: bankAccountItem.id, - text: bankAccountItem.name, + text: `${bankAccountItem.accountCode} ${bankAccountItem.name}`, keyForList: bankAccountItem.id, isSelected: settlementsBankAccountID === bankAccountItem.id, })) ?? []; diff --git a/src/pages/workspace/accounting/rillet/advanced/RilletTravelInvoicingSettlementAccountPage.tsx b/src/pages/workspace/accounting/rillet/advanced/RilletTravelInvoicingSettlementAccountPage.tsx index 006d0b289aad..f82c306196e8 100644 --- a/src/pages/workspace/accounting/rillet/advanced/RilletTravelInvoicingSettlementAccountPage.tsx +++ b/src/pages/workspace/accounting/rillet/advanced/RilletTravelInvoicingSettlementAccountPage.tsx @@ -46,7 +46,7 @@ function RilletTravelInvoicingSettlementAccountPage({policy}: WithPolicyConnecti ?.filter((bankAccountItem) => bankAccountItem.status === CONST.RILLET_ACCOUNT_STATUS.ACTIVE) .map((bankAccountItem) => ({ value: bankAccountItem.id, - text: bankAccountItem.name, + text: `${bankAccountItem.accountCode} ${bankAccountItem.name}`, keyForList: bankAccountItem.id, isSelected: travelInvoicingSettlementsBankAccountID === bankAccountItem.id, })) ?? []; diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx new file mode 100644 index 000000000000..4c257b8e6dc5 --- /dev/null +++ b/src/pages/workspace/accounting/rillet/export/RilletCardAccount.tsx @@ -0,0 +1,90 @@ +import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Text from '@components/Text'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {areCardsCustomExportInErrorFields, getCardsCustomExportPendingAction, getCardsUsingCustomExportCount} from '@libs/CardFeedUtils'; +import {getCardFeedWithDomainID, getCustomOrFormattedFeedName} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +function RilletCardAccount({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); + const rilletConfig = policy?.connections?.rillet?.config; + const rilletData = policy?.connections?.rillet?.data; + const creditCardAccountCode = rilletConfig?.export?.creditCardAccountCode; + const cardProgramsUsingCustomAccounts = rilletConfig?.export?.cardProgramAccounts; + const cardsUsingCustomAccountsCount = getCardsUsingCustomExportCount(cardFeeds ?? {}, cardLists, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT); + + return ( + + + {translate('workspace.rillet.cardAccount.description')} + + {Object.values(cardFeeds ?? {}).map((cardFeed) => { + const feedKey = cardFeed.feed; + const feedName = getCustomOrFormattedFeedName(translate, feedKey, cardFeed.customFeedName, false); + const feedDomainID = cardFeed.domainID ?? CONST.DEFAULT_MISSING_ID; + const feedWithDomainID = getCardFeedWithDomainID(feedKey, feedDomainID); + const cardProgramAccountCode = cardProgramsUsingCustomAccounts?.[feedKey] ?? creditCardAccountCode; + const isUsingDefaultAccount = cardProgramAccountCode === creditCardAccountCode; + const cardProgramAccount = rilletData?.accounts?.find((account) => account.code === cardProgramAccountCode); + const cardProgramAccountDisplayName = cardProgramAccount + ? `${cardProgramAccount.code} ${cardProgramAccount.name}${isUsingDefaultAccount ? ` (${translate('common.default').toLocaleLowerCase()})` : ''}` + : ''; + return ( + + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_ACCOUNT_CARD_LIST.getRoute(policyID, feedWithDomainID)) : undefined)} + shouldShowRightIcon + brickRoadIndicator={ + areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists ?? {}, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, feedKey) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined + } + /> + + ); + })} + + ); +} + +export default withPolicyConnections(RilletCardAccount); diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardAccountCardList.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardAccountCardList.tsx new file mode 100644 index 000000000000..17d3ec8d35d1 --- /dev/null +++ b/src/pages/workspace/accounting/rillet/export/RilletCardAccountCardList.tsx @@ -0,0 +1,114 @@ +import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Text from '@components/Text'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsList from '@hooks/useCardsList'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {areCardsCustomExportInErrorFields, getCardsCustomExportPendingAction} from '@libs/CardFeedUtils'; +import {getCardDescription, getCustomOrFormattedFeedName, isCard, splitCardFeedWithDomainID} from '@libs/CardUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {appendParam} from '@libs/Url'; + +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; +import {View} from 'react-native'; + +type RilletCardAccountCardListProps = WithPolicyConnectionsProps & PlatformStackScreenProps; + +function RilletCardAccountCardList({ + policy, + route: { + params: {feed: feedWithDomainID}, + }, +}: RilletCardAccountCardListProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const [cardList] = useCardsList(feedWithDomainID); + const [cardFeeds] = useCardFeeds(policyID); + const cardFeed = cardFeeds?.[feedWithDomainID]; + const feedKey = splitCardFeedWithDomainID(feedWithDomainID)?.feedName; + const rilletConfig = policy?.connections?.rillet?.config; + const rilletData = policy?.connections?.rillet?.data; + const creditCardAccountCode = rilletConfig?.export?.creditCardAccountCode; + const cardProgramsUsingCustomAccounts = rilletConfig?.export?.cardProgramAccounts; + const cardProgramAccountCode = (feedKey ? cardProgramsUsingCustomAccounts?.[feedKey] : undefined) ?? creditCardAccountCode; + const cardProgramAccount = rilletData?.accounts?.find((account) => account.code === cardProgramAccountCode); + const title = getCustomOrFormattedFeedName(translate, feedKey, cardFeed?.customFeedName, false); + + return ( + + + {translate('workspace.rillet.cardAccount.descriptionLevel2')} + + {Object.values(cardList ?? {}) + .filter(isCard) + .map((card) => { + const cardID = card.cardID; + const isUsingCustomAccount = typeof card.nameValuePairs === 'object' && CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT in card.nameValuePairs; + const cardAccountID = + (typeof card.nameValuePairs === 'object' ? card.nameValuePairs[CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT] : undefined) ?? + cardProgramAccount?.id; + const cardAccount = rilletData?.accounts?.find((account) => account.id === cardAccountID); + const cardAccountDisplayName = cardAccount + ? `${cardAccount.code} ${cardAccount.name}${isUsingCustomAccount ? '' : ` (${translate('common.default').toLocaleLowerCase()})`}` + : ''; + return ( + + Navigation.navigate(appendParam(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_COMPANY_CARD_EXPORT.path), 'cardID', cardID.toString()))} + shouldShowRightIcon + brickRoadIndicator={ + areCardsCustomExportInErrorFields( + cardFeeds ?? {}, + {[feedWithDomainID]: cardList}, + CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, + feedKey, + cardID, + ) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined + } + /> + + ); + })} + + ); +} + +export default withPolicyConnections(RilletCardAccountCardList); diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx new file mode 100644 index 000000000000..91c16541541c --- /dev/null +++ b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccount.tsx @@ -0,0 +1,91 @@ +import ConnectionLayout from '@components/ConnectionLayout'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Text from '@components/Text'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getCardsUsingCustomExportCount} from '@libs/CardFeedUtils'; +import {getCardFeedWithDomainID, getCustomOrFormattedFeedName} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; + +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; +import {View} from 'react-native'; + +function RilletCardProgramAccount({policy}: WithPolicyConnectionsProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = policy?.id; + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); + const rilletConfig = policy?.connections?.rillet?.config; + const rilletData = policy?.connections?.rillet?.data; + const creditCardAccountCode = rilletConfig?.export?.creditCardAccountCode; + const cardProgramsUsingCustomAccounts = rilletConfig?.export?.cardProgramAccounts; + const cardsUsingCustomAccountsCount = getCardsUsingCustomExportCount(cardFeeds ?? {}, cardLists, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT); + + return ( + + + {translate('workspace.rillet.cardProgramAccount.description')} + + {Object.values(cardFeeds ?? {}).map((cardFeed) => { + const feedKey = cardFeed.feed; + const feedName = getCustomOrFormattedFeedName(translate, feedKey, cardFeed.customFeedName, false); + const feedDomainID = cardFeed.domainID ?? CONST.DEFAULT_MISSING_ID; + const feedWithDomainID = getCardFeedWithDomainID(feedKey, feedDomainID); + const isUsingCustomAccount = !!cardProgramsUsingCustomAccounts?.[feedKey]; + const cardProgramAccountCode = cardProgramsUsingCustomAccounts?.[feedKey] ?? creditCardAccountCode; + const cardProgramAccount = rilletData?.accounts?.find((account) => account.code === cardProgramAccountCode); + const cardProgramAccountDisplayName = cardProgramAccount + ? `${cardProgramAccount.code} ${cardProgramAccount.name}${isUsingCustomAccount ? '' : ` (${translate('common.default').toLocaleLowerCase()})`}` + : ''; + return ( + + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_PROGRAM_ACCOUNT_SELECTOR.getRoute(policyID, feedWithDomainID)) : undefined)} + shouldShowRightIcon + brickRoadIndicator={ + areSettingsInErrorFields([`${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], rilletConfig?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined + } + /> + + ); + })} + + ); +} + +export default withPolicyConnections(RilletCardProgramAccount); diff --git a/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccountSelector.tsx b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccountSelector.tsx new file mode 100644 index 000000000000..885dbd1d4922 --- /dev/null +++ b/src/pages/workspace/accounting/rillet/export/RilletCardProgramAccountSelector.tsx @@ -0,0 +1,125 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; +import type {ListItem} from '@components/SelectionList/types'; +import SelectionScreen from '@components/SelectionScreen'; +import Text from '@components/Text'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearRilletErrorField, updateRilletCardProgramAccount} from '@libs/actions/connections/Rillet'; +import {getCustomOrFormattedFeedName, splitCardFeedWithDomainID} from '@libs/CardUtils'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {settingsPendingAction} from '@libs/PolicyUtils'; + +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {RilletAccount} from '@src/types/onyx/Policy'; + +import React from 'react'; +import {View} from 'react-native'; + +type AccountListItem = ListItem & { + value: RilletAccount['code']; +}; + +type RilletCardProgramAccountSelectorProps = WithPolicyConnectionsProps & + PlatformStackScreenProps; + +function RilletCardProgramAccountSelector({ + policy, + route: { + params: {feed: feedWithDomainID}, + }, +}: RilletCardProgramAccountSelectorProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const policyID = policy?.id; + const [cardFeeds] = useCardFeeds(policyID); + const cardFeed = cardFeeds?.[feedWithDomainID]; + const feedKey = splitCardFeedWithDomainID(feedWithDomainID)?.feedName; + const rilletConfig = policy?.connections?.rillet?.config; + const rilletData = policy?.connections?.rillet?.data; + const creditCardAccountCode = rilletConfig?.export?.creditCardAccountCode; + const cardProgramsUsingCustomAccounts = rilletConfig?.export?.cardProgramAccounts; + const cardProgramAccountCode = (feedKey ? cardProgramsUsingCustomAccounts?.[feedKey] : undefined) ?? creditCardAccountCode; + const title = getCustomOrFormattedFeedName(translate, feedKey, cardFeed?.customFeedName, false); + const backPath = policyID ? ROUTES.POLICY_ACCOUNTING_RILLET_CARD_PROGRAM_ACCOUNT.getRoute(policyID) : undefined; + + const data: AccountListItem[] = + rilletData?.accounts + ?.filter( + (accountItem) => + accountItem.type === CONST.RILLET_ACCOUNT_TYPE.LIABILITY && + accountItem.subtype === CONST.RILLET_ACCOUNT_SUBTYPE.CREDIT_CARD && + accountItem.status === CONST.RILLET_ACCOUNT_STATUS.ACTIVE, + ) + .map((accountItem) => ({ + value: accountItem.code, + text: `${creditCardAccountCode === accountItem.code ? `${translate('common.default')} - ` : ''}${accountItem.code} ${accountItem.name}`, + keyForList: accountItem.code, + isSelected: cardProgramAccountCode === accountItem.code, + })) ?? []; + + const headerContent = ( + + {translate('workspace.rillet.cardProgramAccount.descriptionLevel2')} + + ); + + const listEmptyContent = ( + + ); + + const selectCreditCardAccount = (item: AccountListItem) => { + if (item.value !== cardProgramAccountCode && policyID && feedKey) { + // Choosing the default account clears the custom account + const value = item.value === creditCardAccountCode ? '' : item.value; + const oldValue = cardProgramAccountCode === creditCardAccountCode ? undefined : cardProgramAccountCode; + updateRilletCardProgramAccount(policyID, feedKey, value, oldValue); + } + Navigation.goBack(backPath); + }; + + return ( + Navigation.goBack(backPath)} + connectionName={CONST.POLICY.CONNECTIONS.NAME.RILLET} + pendingAction={settingsPendingAction([`${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`], rilletConfig?.pendingFields)} + errors={getLatestErrorField(rilletConfig, `${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`)} + errorRowStyles={[styles.ph5, styles.pv3]} + onClose={() => policyID && clearRilletErrorField(policyID, `${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${feedKey}`)} + /> + ); +} + +export default withPolicyConnections(RilletCardProgramAccountSelector); diff --git a/src/pages/workspace/accounting/rillet/export/RilletCompanyCardAccountPage.tsx b/src/pages/workspace/accounting/rillet/export/RilletCompanyCardAccountPage.tsx index 8d797969cdae..8cdd725b270d 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletCompanyCardAccountPage.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletCompanyCardAccountPage.tsx @@ -48,7 +48,7 @@ function RilletCompanyCardAccountPage({policy}: WithPolicyConnectionsProps) { ) .map((accountItem) => ({ value: accountItem.code, - text: accountItem.name, + text: `${accountItem.code} ${accountItem.name}`, keyForList: accountItem.code, isSelected: creditCardAccountCode === accountItem.code, })) ?? []; diff --git a/src/pages/workspace/accounting/rillet/export/RilletExportPage.tsx b/src/pages/workspace/accounting/rillet/export/RilletExportPage.tsx index 1cf17b707469..4210dd6c69f1 100644 --- a/src/pages/workspace/accounting/rillet/export/RilletExportPage.tsx +++ b/src/pages/workspace/accounting/rillet/export/RilletExportPage.tsx @@ -1,16 +1,24 @@ +import Accordion from '@components/Accordion'; import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; +import useAccordionAnimation from '@hooks/useAccordionAnimation'; +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {clearRilletErrorField, updateRilletExportToMultipleAccounts} from '@libs/actions/connections/Rillet'; +import {areCardsCustomExportInErrorFields, getCardsCustomExportPendingAction, getCardsUsingCustomExportCount} from '@libs/CardFeedUtils'; +import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -22,6 +30,8 @@ function RilletExportPage({policy}: WithPolicyConnectionsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id; + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); const policyOwner = policy?.owner; const rilletConfig = policy?.connections?.rillet?.config; const rilletData = policy?.connections?.rillet?.data; @@ -31,6 +41,13 @@ function RilletExportPage({policy}: WithPolicyConnectionsProps) { const exportCompanyCard = rilletConfig?.export?.companyCard ?? CONST.RILLET_EXPORT_COMPANY_CARD.CREDIT_CARD; const defaultCompanyCardVendor = rilletData?.vendors?.find((vendor) => vendor.id === rilletConfig?.export?.defaultVendorID); const companyCardAccount = rilletData?.accounts?.find((account) => account.code === rilletConfig?.export?.creditCardAccountCode); + const exportToMultipleAccounts = rilletConfig?.export?.exportToMultipleAccounts ?? false; + const cardProgramsUsingCustomAccountsCount = Object.keys(rilletConfig?.export?.cardProgramAccounts ?? {}).length; + const cardProgramsOfflineFeedbackKeys = Object.values(cardFeeds ?? {}).map((program) => `${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${program.feed}`); + const cardsUsingCustomAccountsCount = getCardsUsingCustomExportCount(cardFeeds ?? {}, cardLists, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT); + + const {isAccordionExpanded: isExportToMultipleAccountsAccordionExpanded, shouldAnimateAccordionSection: shouldAnimateExportToMultipleAccountsAccordionSection} = + useAccordionAnimation(exportToMultipleAccounts); return ( - - {translate('workspace.rillet.exportDescription')} + + {translate('workspace.rillet.exportDescription')} (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_COMPANY_CARD_ACCOUNT.getRoute(policyID)) : undefined)} shouldShowRightIcon @@ -105,6 +122,50 @@ function RilletExportPage({policy}: WithPolicyConnectionsProps) { } /> + {Object.keys(cardFeeds ?? {}).length > 0 && ( + <> + policyID && updateRilletExportToMultipleAccounts(policyID, !exportToMultipleAccounts, exportToMultipleAccounts)} + pendingAction={settingsPendingAction([CONST.RILLET_CONFIG.EXPORT_TO_MULTIPLE_ACCOUNTS], rilletConfig?.pendingFields)} + errors={getLatestErrorField(rilletConfig ?? {}, CONST.RILLET_CONFIG.EXPORT_TO_MULTIPLE_ACCOUNTS)} + onCloseError={() => policyID && clearRilletErrorField(policyID, CONST.RILLET_CONFIG.EXPORT_TO_MULTIPLE_ACCOUNTS)} + /> + + + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_PROGRAM_ACCOUNT.getRoute(policyID)) : undefined)} + shouldShowRightIcon + brickRoadIndicator={ + areSettingsInErrorFields(cardProgramsOfflineFeedbackKeys, rilletConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined + } + /> + + + (policyID ? Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_CARD_ACCOUNT.getRoute(policyID)) : undefined)} + shouldShowRightIcon + brickRoadIndicator={ + areCardsCustomExportInErrorFields(cardFeeds ?? {}, cardLists, CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined + } + /> + + + + )} ); } diff --git a/src/pages/workspace/accounting/rillet/import/RilletImportPage.tsx b/src/pages/workspace/accounting/rillet/import/RilletImportPage.tsx index a1a09d01ca56..8d88050ba609 100644 --- a/src/pages/workspace/accounting/rillet/import/RilletImportPage.tsx +++ b/src/pages/workspace/accounting/rillet/import/RilletImportPage.tsx @@ -39,8 +39,8 @@ function RilletImportPage({policy}: WithPolicyConnectionsProps) { connectionName={CONST.POLICY.CONNECTIONS.NAME.RILLET} shouldBeBlocked > - - {translate('workspace.rillet.importDescription')} + + {translate('workspace.rillet.importDescription')} void; subscribedExportSettings?: string[]; + externalSubscribedExportSettingsPendingAction?: PendingAction; + externalSubscribedExportSettingsHasErrorFields?: boolean; onAdvancedPagePress: () => void; subscribedAdvancedSettings?: string[]; onCardReconciliationPagePress?: () => void; diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index e064cde379e3..dd1ea014c5d0 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -10,6 +10,7 @@ import Text from '@components/Text'; import TextLink from '@components/TextLink'; import {isAuthenticationError} from '@libs/actions/connections'; +import {getCardsCustomExportPendingAction, areCardsCustomExportInErrorFields} from '@libs/CardFeedUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {canUseTaxNetSuite} from '@libs/PolicyUtils'; @@ -21,7 +22,7 @@ import {getTrackingCategories} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; -import type {Policy} from '@src/types/onyx'; +import type {CombinedCardFeeds, Policy, WorkspaceCardsList} from '@src/types/onyx'; import type {Account, ConnectionName, Connections, PolicyConnectionName, QBDNonReimbursableExportAccountType, QBDReimbursableExportAccountType} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -51,6 +52,7 @@ import { } from './netsuite/utils'; import getQuickbooksDesktopSetupEntryRoute from './qbd/utils'; +// eslint-disable-next-line @typescript-eslint/max-params function getAccountingIntegrationData( connectionName: PolicyConnectionName, policyID: string, @@ -62,6 +64,8 @@ function getAccountingIntegrationData( shouldDisconnectIntegrationBeforeConnecting?: boolean, canUseNetSuiteUSATax?: boolean, expensifyIcons?: Record<'IntacctSquare' | 'QBOSquare' | 'XeroSquare' | 'NetSuiteSquare' | 'QBDSquare' | 'CertiniaSquare' | 'RilletSquare', IconAsset>, + cardFeeds?: CombinedCardFeeds, + cardList?: Record, ): AccountingIntegration | undefined { const basePath = ROUTES.POLICY_ACCOUNTING.getRoute(policyID); const qboConfig = policy?.connections?.quickbooksOnline?.config; @@ -425,9 +429,18 @@ function getAccountingIntegrationData( CONST.RILLET_CONFIG.DEFAULT_VENDORID, CONST.RILLET_CONFIG.CREDIT_CARD_ACCOUNTCODE, CONST.RILLET_CONFIG.EXPORT_TO_MULTIPLE_ACCOUNTS, - CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNTS, + ...Object.values(cardFeeds ?? {}).map((program) => `${CONST.RILLET_CONFIG.CARD_PROGRAM_ACCOUNT_PREFIX}${program.feed}`), ], - onCardReconciliationPagePress: () => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, CONST.POLICY.CONNECTIONS.ROUTE.RILLET)), + externalSubscribedExportSettingsPendingAction: getCardsCustomExportPendingAction( + cardFeeds ?? {}, + cardList ?? {}, + CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, + ), + externalSubscribedExportSettingsHasErrorFields: areCardsCustomExportInErrorFields( + cardFeeds ?? {}, + cardList ?? {}, + CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT, + ), onAdvancedPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_ADVANCED.getRoute(policyID)), subscribedAdvancedSettings: [ CONST.RILLET_CONFIG.ACCOUNTING_METHOD, diff --git a/src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx b/src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx index 53efdf1c9112..a16e23fd330b 100644 --- a/src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx +++ b/src/pages/workspace/categories/DynamicDefaultCategorySelectorPage.tsx @@ -31,18 +31,21 @@ function DynamicDefaultCategorySelectorPage({route}: DynamicDefaultCategorySelec const styles = useThemeStyles(); const {translate} = useLocalize(); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`); - const currentCategory = policy?.customUnits?.[customUnitID]?.defaultCategory ?? ''; + const customUnit = policy?.customUnits?.[customUnitID]; + const currentCategory = customUnit?.defaultCategory ?? ''; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.DEFAULT_CATEGORY_SELECTOR.path); const onCategorySelected = (selectedCategory: ListItem) => { - if (!selectedCategory.searchText) { + const isNoneSelected = selectedCategory.keyForList === CONST.SEARCH.NONE_OPTION_KEY; + if (!isNoneSelected && !selectedCategory.searchText) { return; } - if (currentCategory === selectedCategory.searchText) { + const newCategory = isNoneSelected ? '' : (selectedCategory.searchText ?? ''); + if (currentCategory === newCategory) { Navigation.goBack(backPath); return; } - setPolicyCustomUnitDefaultCategory(policyID, customUnitID, currentCategory, selectedCategory.searchText); + setPolicyCustomUnitDefaultCategory(policyID, customUnitID, currentCategory, newCategory, customUnit); Navigation.goBack(backPath); }; @@ -67,6 +70,7 @@ function DynamicDefaultCategorySelectorPage({route}: DynamicDefaultCategorySelec policyID={policyID} selectedCategory={currentCategory} onSubmit={onCategorySelected} + shouldShowNoneOption addBottomSafeAreaPadding /> diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx index b74b643219dc..e55ca1780307 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardEditTransactionStartDatePage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import DatePicker from '@components/DatePicker'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -138,12 +138,13 @@ function WorkspaceCompanyCardEditTransactionStartDatePage({route}: WorkspaceComp addBottomSafeAreaPadding footerContent={ } listFooterContent={ dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM ? ( diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx index 9ab8fb552ac1..c30bfbeb1e32 100644 --- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx @@ -1,5 +1,5 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import DatePicker from '@components/DatePicker'; import InteractiveStepWrapper from '@components/InteractiveStepWrapper'; import SelectionList from '@components/SelectionList'; @@ -136,12 +136,13 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { addBottomSafeAreaPadding footerContent={ } listFooterContent={ dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM ? ( diff --git a/src/pages/workspace/companyCards/utils.tsx b/src/pages/workspace/companyCards/utils.tsx index aece23835df8..8a434223808c 100644 --- a/src/pages/workspace/companyCards/utils.tsx +++ b/src/pages/workspace/companyCards/utils.tsx @@ -368,6 +368,54 @@ function getExportMenuItem( })), }; } + case CONST.POLICY.CONNECTIONS.NAME.RILLET: { + const rilletConfig = policy?.connections?.rillet?.config; + const rilletData = policy?.connections?.rillet?.data; + const exportType = CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT; + const exportReimbursable = rilletConfig?.export?.reimbursable ?? CONST.RILLET_EXPORT_REIMBURSABLE.VENDOR_BILL; + const exportCompanyCard = rilletConfig?.export?.companyCard ?? CONST.RILLET_EXPORT_COMPANY_CARD.CREDIT_CARD; + const shouldShowMenuItem = + rilletConfig?.export?.exportToMultipleAccounts && + exportReimbursable === CONST.RILLET_EXPORT_REIMBURSABLE.VENDOR_BILL && + exportCompanyCard === CONST.RILLET_EXPORT_COMPANY_CARD.CREDIT_CARD; + const creditCardAccountCode = rilletConfig?.export?.creditCardAccountCode; + const cardProgramsUsingCustomAccounts = rilletConfig?.export?.cardProgramAccounts; + const cardProgramAccountCode = (companyCard?.bank ? cardProgramsUsingCustomAccounts?.[companyCard.bank] : undefined) ?? creditCardAccountCode; + const cardProgramAccount = rilletData?.accounts?.find((account) => account.code === cardProgramAccountCode); + const isUsingCustomAccount = companyCard?.nameValuePairs && CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT in companyCard.nameValuePairs; + const cardAccountID = + (companyCard?.nameValuePairs && CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT in companyCard.nameValuePairs + ? companyCard.nameValuePairs[CONST.COMPANY_CARDS.EXPORT_CARD_TYPES.NVP_RILLET_EXPORT_ACCOUNT] + : undefined) ?? cardProgramAccount?.id; + const cardAccount = rilletData?.accounts?.find((account) => account.id === cardAccountID); + const cardAccountDisplayName = cardAccount ? `${cardAccount.code} ${cardAccount.name}${isUsingCustomAccount ? '' : ` (${translate('common.default').toLocaleLowerCase()})`}` : ''; + const title = cardAccountDisplayName; + const description = currentConnectionName + ? translate('workspace.moreFeatures.companyCards.integrationExport', currentConnectionName, translate('workspace.rillet.cardAccount.label')) + : undefined; + + return { + title, + description, + exportType, + shouldShowMenuItem, + exportPageLink: ROUTES.POLICY_ACCOUNTING_RILLET_EXPORT.getRoute(policyID), + data: + rilletData?.accounts + ?.filter( + (accountItem) => + accountItem.type === CONST.RILLET_ACCOUNT_TYPE.LIABILITY && + accountItem.subtype === CONST.RILLET_ACCOUNT_SUBTYPE.CREDIT_CARD && + accountItem.status === CONST.RILLET_ACCOUNT_STATUS.ACTIVE, + ) + .map((accountItem) => ({ + value: cardProgramAccount?.id === accountItem.id ? '' : accountItem.id, + text: `${cardProgramAccount?.id === accountItem.id ? `${translate('common.default')} - ` : ''}${accountItem.code} ${accountItem.name}`, + keyForList: accountItem.id, + isSelected: cardAccountID === accountItem.id, + })) ?? [], + }; + } default: return undefined; diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx index bf65a1058f3c..9435451db862 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsConfirmPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import CheckboxWithLabel from '@components/CheckboxWithLabel'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -168,12 +168,13 @@ function CopyPolicySettingsConfirmPage() { )} diff --git a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx index e07c603f9c35..e7a559a40edd 100644 --- a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx +++ b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx @@ -13,12 +13,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; import {calculateBillNewDot, deleteWorkspace, dismissWorkspaceError} from '@libs/actions/Policy/Policy'; -import {filterInactiveCards} from '@libs/CardUtils'; +import {filterInactiveCards, getCardSettings} from '@libs/CardUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {isPendingDeletePolicy, shouldBlockWorkspaceDeletionForInvoicifyUser} from '@libs/PolicyUtils'; import {isSubscriptionTypeOfInvoicing} from '@libs/SubscriptionUtils'; +import {getIsTravelInvoicingEnabled, getTravelInvoicingCardSettingsKey} from '@libs/TravelInvoicingUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -83,6 +84,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork const [cardsList, cardsListResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, { selector: filterInactiveCards, }); + const [travelCardSettings, travelCardSettingsResult] = useOnyx(getTravelInvoicingCardSettingsKey(workspaceAccountID)); const {reportsToArchive, transactionViolations, reportsResult, transactionsResult, transactionViolationsResult} = useTransactionViolationOfWorkspace(policyID); const [accountIDToLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: accountIDToLoginSelector(reportsToArchive)}); @@ -93,6 +95,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork privateSubscriptionResult, cardFeedsResult, cardsListResult, + travelCardSettingsResult, reportsResult, transactionsResult, transactionViolationsResult, @@ -104,6 +107,8 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.policyAccountID); const hasExpensifyCardsEnabledOnWorkspace = !!policy?.areExpensifyCardsEnabled && !!policy?.policyAccountID; + const hasTravelInvoicingEnabledOnWorkspace = getIsTravelInvoicingEnabled(getCardSettings(travelCardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US)); + // While offline we can't get the real rejection reason from the backend, so if we already know locally that the workspace has active Expensify Cards, block the delete up front instead of queuing one that will fail on reconnect. const hasDeleteWorkspaceExpensifyCardsError = !!hasExpensifyCardsEnabledOnWorkspace && !isEmptyObject(cardsList) && !!isOffline; const policyLatestErrorMessage = getLatestErrorMessage(policy); @@ -133,7 +138,8 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork prompt: ( { closeModal(); dismissDeleteWorkspaceFlow(); @@ -148,7 +154,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork }).then(() => { dismissDeleteWorkspaceFlow(); }); - }, [closeModal, dismissDeleteWorkspaceFlow, isFocused, showConfirmModal, styles.flexRow, styles.renderHTML, translate]); + }, [closeModal, dismissDeleteWorkspaceFlow, hasExpensifyCardsEnabledOnWorkspace, isFocused, showConfirmModal, styles.flexRow, styles.renderHTML, translate]); const showGenericDeleteWorkspaceErrorModal = useCallback( (errorMessage: string) => { @@ -157,9 +163,23 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork return; } + const prompt = CONST.HTML_TAG_REGEX.test(errorMessage) ? ( + + { + closeModal(); + dismissDeleteWorkspaceFlow(); + }} + /> + + ) : ( + errorMessage + ); + showConfirmModal({ title: translate('workspace.common.delete'), - prompt: errorMessage, + prompt, confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, success: false, @@ -168,7 +188,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork dismissDeleteWorkspaceFlow(); }); }, - [dismissDeleteWorkspaceFlow, isFocused, showConfirmModal, translate], + [closeModal, dismissDeleteWorkspaceFlow, isFocused, showConfirmModal, styles.flexRow, styles.renderHTML, translate], ); // Always invoked after a re-render (from the start effect below for normal deletes, or from usePayAndDowngrade for billed deletes), @@ -260,7 +280,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork closeModal(); - if (policyLatestErrorMessage && hasExpensifyCardsEnabledOnWorkspace) { + if (policyLatestErrorMessage && (hasExpensifyCardsEnabledOnWorkspace || hasTravelInvoicingEnabledOnWorkspace)) { showDeleteWorkspaceErrorModal(); return; } @@ -278,6 +298,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork prevIsPendingDelete, policyLatestErrorMessage, hasExpensifyCardsEnabledOnWorkspace, + hasTravelInvoicingEnabledOnWorkspace, closeModal, onDeleteComplete, onDismiss, diff --git a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx index b5c3d1ddc85f..87ccb8a249e1 100644 --- a/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyCommuterExclusionsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -206,11 +206,12 @@ function PolicyCommuterExclusionsPage({route}: PolicyCommuterExclusionsPageProps /> )} diff --git a/src/pages/workspace/downgrade/DowngradeIntro.tsx b/src/pages/workspace/downgrade/DowngradeIntro.tsx index fb63d64b1cc9..84c658372460 100644 --- a/src/pages/workspace/downgrade/DowngradeIntro.tsx +++ b/src/pages/workspace/downgrade/DowngradeIntro.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import Icon from '@components/Icon'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; @@ -101,19 +101,21 @@ function DowngradeIntro({onDowngrade, buttonDisabled, loading, policyID, backTo} {policyID ? ( ) : ( )} ); diff --git a/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx b/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx index 42009f020d7a..590f14a62951 100644 --- a/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx +++ b/src/pages/workspace/downgrade/DynamicPayAndDowngradePage.tsx @@ -1,6 +1,6 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; @@ -18,6 +18,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; import {clearBillingReceiptDetailsErrors, payAndDowngrade} from '@src/libs/actions/Policy/Policy'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -121,13 +122,14 @@ function DynamicPayAndDowngradePage() { )} diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx index 58e006ca26b1..3f3247ff7f1a 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardsListLabel.tsx @@ -3,6 +3,7 @@ import Icon from '@components/Icon'; import Popover from '@components/Popover'; import {PressableWithFeedback} from '@components/Pressable'; import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import useCurrencyForExpensifyCard from '@hooks/useCurrencyForExpensifyCard'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -19,7 +20,9 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {getCardSettings} from '@libs/CardUtils'; import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import {buildQueryStringFromFilterFormValues} from '@libs/SearchQueryUtils'; +import Navigation from '@navigation/Navigation'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import variables from '@styles/variables'; @@ -30,6 +33,7 @@ import {navigateToConciergeChat} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -121,6 +125,17 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr queueExpensifyCardForBilling(CONST.COUNTRY.US, defaultFundID); }; + const handleViewTransactionsPress = () => { + const fundIDForFeedKey = defaultFundID === CONST.DEFAULT_NUMBER_ID ? undefined : String(defaultFundID); + const feedKey = fundIDForFeedKey ? `${fundIDForFeedKey}_${CONST.EXPENSIFY_CARD.BANK}` : CONST.EXPENSIFY_CARD.BANK; + const query = buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + feed: [feedKey], + withdrawalStatus: [CONST.SEARCH.SETTLEMENT_STATUS.NEVER, CONST.SEARCH.SETTLEMENT_STATUS.PENDING], + }); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query})); + }; + return ( @@ -156,6 +171,14 @@ function WorkspaceCardsListLabel({type, value, style}: WorkspaceCardsListLabelPr )} + {isCurrentBalanceType && ( + + {translate('workspace.common.viewTransactions')} + + )} {isSettleDateTextDisplayed && {translate('workspace.expensifyCard.balanceWillBeSettledOn', settlementDate)}} ({policyID, config}: addBottomSafeAreaPadding > diff --git a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx index 65ee37bcac6a..25380e80ede6 100644 --- a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx +++ b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -124,12 +124,13 @@ function MergeHRGroupsPage({ addBottomSafeAreaPadding > diff --git a/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx b/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx index 930a94d11f05..325bda953a34 100644 --- a/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx +++ b/src/pages/workspace/invoices/WorkspaceInvoiceVBASection.tsx @@ -144,12 +144,7 @@ function WorkspaceInvoiceVBASection({policyID, canWriteMoreFeatures, showReadOnl const accountPolicyID = accountData?.additionalData?.policyID; if (accountPolicyID) { - navigateToBankAccountRoute({ - policyID: accountPolicyID, - backTo: ROUTES.WORKSPACE_INVOICES.getRoute(policyID), - policyCurrency: accountData?.additionalData?.currency, - bankAccountState: accountData?.state, - }); + navigateToBankAccountRoute({policyID: accountPolicyID, backTo: ROUTES.WORKSPACE_INVOICES.getRoute(policyID)}); } }; diff --git a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx index e48fe6868145..3eb623b1667f 100644 --- a/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx +++ b/src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx @@ -22,7 +22,7 @@ import {setWorkspaceInviteMessageDraft} from '@libs/actions/Policy/Policy'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; -import {getPersonalDetailByEmail, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getNewAccountIDsAndLogins, getPersonalDetailByEmail, getPersonalDetailsOnyxDataForOptimisticUsers, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import { canMemberAssignElevatedRole, canMemberAssignRole, @@ -182,16 +182,16 @@ function WorkspaceInviteMessageComponent({ Keyboard.dismiss(); const filteredReportActions = getAllPolicyExpenseChatReportActions(allReports, allReportActions); const policyMemberAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)); + const {newAccountIDs, newLogins} = getNewAccountIDsAndLogins(invitedEmailsToAccountIDsDraft, allPersonalDetails); // Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details // See https://github.com/Expensify/App/blob/main/README.md#workspace, we set conditions about who can leave the workspace addMembersToWorkspace( invitedEmailsToAccountIDsDraft ?? {}, + getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs, formatPhoneNumber), `${welcomeNoteSubject}\n\n${welcomeNote}`, policy, policyMemberAccountIDs, workspaceInviteRoleDraft, - formatPhoneNumber, - allPersonalDetails, { accountID: currentUserPersonalDetails?.accountID, displayName: currentUserPersonalDetails?.displayName, diff --git a/src/pages/workspace/members/WorkspaceOwnerChangeCheck.tsx b/src/pages/workspace/members/WorkspaceOwnerChangeCheck.tsx index 21a153bda317..1faccc64c0fb 100644 --- a/src/pages/workspace/members/WorkspaceOwnerChangeCheck.tsx +++ b/src/pages/workspace/members/WorkspaceOwnerChangeCheck.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import Text from '@components/Text'; @@ -83,11 +83,12 @@ function WorkspaceOwnerChangeCheck({policy, accountID, error}: WorkspaceOwnerCha {displayTexts.text} ); diff --git a/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx index e8a244d6a9a6..7d61b1218256 100644 --- a/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx +++ b/src/pages/workspace/rooms/WorkspaceRoomsPage.tsx @@ -16,6 +16,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {openPolicyRoomsPage} from '@libs/actions/Policy/Room'; +import {openReport} from '@libs/actions/Report'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -54,6 +55,8 @@ function WorkspaceRoomsPage({route}: WorkspaceRoomsPageProps) { const reportAttributes = useReportAttributes(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const personalDetails = usePersonalDetails(); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [betas] = useOnyx(ONYXKEYS.BETAS); const [policyReports] = useOnyx( ONYXKEYS.COLLECTION.REPORT, @@ -74,8 +77,15 @@ function WorkspaceRoomsPage({route}: WorkspaceRoomsPageProps) { name: getReportName(report, reportAttributes), memberCount: getParticipantsAccountIDsForDisplay(report, true, false, false, undefined, personalDetails).length, action: () => { - const targetRoute = isAdmin ? createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS.getRoute(report.reportID)) : ROUTES.REPORT_WITH_ID.getRoute(report.reportID); - Navigation.navigate(targetRoute); + if (isAdmin) { + // Admins open the details RHP directly instead of the room report, so the report is never fetched via ReportScreen. + // Fetch it here so the RHP has full data (participants, metadata) for Join, Invite and renaming. + // shouldMarkAsRead is false because the user only views the room details, not the conversation itself. + openReport({reportID: report.reportID, introSelected, betas, shouldMarkAsRead: false}); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.REPORT_DETAILS.getRoute(report.reportID))); + return; + } + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID)); }, })); diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index ccb4cb021a53..45f24dd25f4b 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -15,6 +15,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; +import Tab from '@libs/actions/Tab'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -33,7 +34,7 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/AddAgentRuleForm'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {TextInputKeyPressEvent} from 'react-native'; +import type {StyleProp, TextInputKeyPressEvent, ViewStyle} from 'react-native'; import React, {useRef} from 'react'; import {View} from 'react-native'; @@ -51,6 +52,8 @@ function AddAgentRulePage({ const shouldUseScrollableLayout = useIsInLandscapeMode(); const {isBetaEnabled} = usePermissions(); const isCustomAgentEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); + const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); + const shouldUseExpandedRevampFormLayout = isRulesRevampEnabled && !shouldUseScrollableLayout; const policy = usePolicy(policyID); const formRef = useRef(null); const linkPressedRef = useRef(false); @@ -73,13 +76,23 @@ function AddAgentRulePage({ return errors; }; + const navigateBackToAgentsTab = () => { + if (isRulesRevampEnabled) { + Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.AGENTS); + Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); + return; + } + + Navigation.goBack(); + }; + const saveRule = (values: FormOnyxValues): void => { // When the workspace has no agent rules yet, the backend creates the "RuleBot" agent and adds it as // an admin. Surface a one-time modal explaining this side effect before navigating back. const isFirstRule = isEmptyObject(policy?.rules?.agentRules); addPolicyAgentRule(policyID, rand64(), values[INPUT_IDS.PROMPT]); if (!isFirstRule) { - Navigation.goBack(); + navigateBackToAgentsTab(); return; } linkPressedRef.current = false; @@ -87,6 +100,11 @@ function AddAgentRulePage({ linkPressedRef.current = true; closeModal(); }; + + if (isRulesRevampEnabled) { + Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.AGENTS); + } + Navigation.dismissModal({ afterTransition: () => { showConfirmModal({ @@ -100,7 +118,7 @@ function AddAgentRulePage({ /> ), - confirmText: translate('common.buttonConfirm'), + confirmText: isRulesRevampEnabled ? translate('workspace.rules.agentRules.gotIt') : translate('common.buttonConfirm'), shouldShowCancelButton: false, shouldUseSuccessStyleForConfirm: true, iconSource: BotAvatarBlue, @@ -108,7 +126,11 @@ function AddAgentRulePage({ shouldCenterIcon: true, iconWidth: variables.iconSizeUltraLarge, iconHeight: variables.iconSizeUltraLarge, - iconAdditionalStyles: {borderRadius: variables.iconSizeUltraLarge / 2, overflow: 'hidden', marginTop: 12}, + iconAdditionalStyles: { + borderRadius: variables.iconSizeUltraLarge / 2, + overflow: 'hidden', + marginTop: 12, + }, }).then(() => { if (!linkPressedRef.current) { return; @@ -119,6 +141,10 @@ function AddAgentRulePage({ }); }; + const inputWrapperStyles: StyleProp = shouldUseExpandedRevampFormLayout + ? [styles.flex1, styles.mnh0, styles.agentRulePromptInput] + : [styles.flex1, shouldUseScrollableLayout && styles.minHeight42]; + return ( - + - + (null); + const describeRuleLabel = isRulesRevampEnabled ? translate('workspace.rules.agentRules.describeRuleForConcierge') : translate('workspace.rules.agentRules.describeRuleTitle'); const handleKeyPress = (e: TextInputKeyPressEvent | KeyboardEvent) => { if (!('key' in e)) { @@ -103,6 +106,10 @@ function EditAgentRulePage({ return ; } + const inputWrapperStyles: StyleProp = shouldUseExpandedRevampFormLayout + ? [styles.flex1, styles.mnh0, styles.agentRulePromptInput] + : [styles.flex1, shouldUseScrollableLayout && styles.minHeight42]; + return ( - + !!rule) - .map(([ruleID, rule]) => ({...rule, ruleID})) - .sort((a, b) => { - if (a.created && b.created) { - return a.created < b.created ? 1 : -1; - } - return 0; - }); - - // Exclude pending-delete rules when online because OfflineWithFeedback hides them visually. - // When offline, keep them so OfflineWithFeedback can show strikethrough styling. - const visibleRules = sortedRules.filter((rule) => isOffline || rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - const renderTitle = () => ( - - {translate('workspace.rules.agentRules.title')} - - - ); - - const renderSubtitle = () => ( - - {translate('workspace.rules.agentRules.subtitle')} - {!!ruleBotAccountID && isRuleBotActiveMember && ( - - {translate('workspace.rules.agentRules.enforcedBy')} - - - )} - - ); + const visibleRules = getVisibleAgentRules(policy?.rules?.agentRules, isOffline); + const hasRules = visibleRules.length > 0; + const {renderTitle, renderSubtitle} = useAgentRulesSectionHeader({ + policyID, + subtitle: translate('workspace.rules.agentRules.subtitle'), + isBadgeCondensed: true, + }); return (

{hasRules && ( - - {visibleRules.map((rule) => { - return ( - - clearPolicyAgentRuleErrors(policyID, rule.ruleID, rule)} - > - { - if (!canWriteRules) { - showReadOnlyModal(); - return; - } - Navigation.navigate(ROUTES.RULES_AGENT_EDIT.getRoute(policyID, rule.ruleID)); - }} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.AGENT_RULE_ITEM} - disabled={rule.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} - /> - - - ); - })} - + )} ; + +function ImportMerchantRulesPage({route}: ImportMerchantRulesPageProps) { + const policyID = route.params.policyID; + + return ( + + + + ); +} + +export default ImportMerchantRulesPage; diff --git a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx new file mode 100644 index 000000000000..9e3ac42895aa --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx @@ -0,0 +1,295 @@ +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {ColumnRole} from '@components/ImportColumn'; +import ImportSpreadsheetColumns from '@components/ImportSpreadsheetColumns'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useCloseImportPage from '@hooks/useCloseImportPage'; +import useImportSpreadsheetConfirmModal from '@hooks/useImportSpreadsheetConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; + +import type {ImportedMerchantRule} from '@libs/actions/Policy/Rules'; +import {importMerchantRulesSpreadsheet} from '@libs/actions/Policy/Rules'; +import {findDuplicate, generateColumnNames} from '@libs/importSpreadsheetUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {rand64} from '@libs/NumberUtils'; +import Parser from '@libs/Parser'; +import {escapeTagName} from '@libs/PolicyUtils'; +import {trimTag} from '@libs/TagUtils'; +import {getTagArrayFromName} from '@libs/TransactionUtils'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {CodingRule} from '@src/types/onyx/Policy'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import React, {useState} from 'react'; + +/** Column roles that update the matched expense; at least one must be mapped alongside a merchant filter */ +const ACTION_COLUMNS: string[] = [ + CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT, + CONST.CSV_IMPORT_COLUMNS.CATEGORY, + CONST.CSV_IMPORT_COLUMNS.TAG, + CONST.CSV_IMPORT_COLUMNS.COMMENT, + CONST.CSV_IMPORT_COLUMNS.REIMBURSABLE, + CONST.CSV_IMPORT_COLUMNS.BILLABLE, +]; + +/** + * Serializes the importable fields of a rule so identical rules can be detected. Used to skip + * spreadsheet rows that would recreate a rule the policy already has (e.g. the same spreadsheet + * imported twice) as well as duplicate rows within the same spreadsheet. + */ +function getRuleContentKey(rule: Pick): string { + return JSON.stringify([ + rule.filters.operator, + rule.filters.right.toLowerCase(), + rule.merchant ?? '', + rule.category ?? '', + rule.tag ?? '', + rule.comment ?? '', + rule.reimbursable ?? null, + rule.billable ?? null, + ]); +} + +/** + * Normalizes an imported tag cell into the encoding the manual "Add tag" flow stores. + * + * On a multi-level policy ":" separates levels, so the levels are trimmed and re-joined (e.g. "Parent: Child" → + * "Parent:Child"), matching trimTag(levels.join(':')). Without this, the cell would be persisted verbatim and later + * split into ["Parent", " Child"] on display, so the rule row would render "Parent, Child" and the tag field would + * only resolve the first level. + * + * On a single-level policy the whole cell is one literal tag name, so its colons are escaped instead (e.g. "ab:cd" → + * "ab\:cd"), matching how tag names are stored on the policy. Internal spaces are part of the name and are preserved. + */ +function normalizeImportedTag(tag: string, hasMultipleTagLists: boolean): string { + if (!tag) { + return ''; + } + if (!hasMultipleTagLists) { + return escapeTagName(tag); + } + return trimTag( + getTagArrayFromName(tag) + .map((level) => level.trim()) + .join(':'), + ); +} + +/** Parses a CSV cell into a boolean, or undefined when the cell is empty or unrecognized so the field is left unset */ +function parseCsvBooleanValue(raw: string | undefined): boolean | undefined { + const trimmed = raw?.trim().toLowerCase() ?? ''; + if (['true', 'yes'].includes(trimmed)) { + return true; + } + if (['false', 'no'].includes(trimmed)) { + return false; + } + return undefined; +} + +type ImportedMerchantRulesPageProps = PlatformStackScreenProps; + +function ImportedMerchantRulesPage({route}: ImportedMerchantRulesPageProps) { + const {translate} = useLocalize(); + const [spreadsheet, spreadsheetMetadata] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); + const [isImportingRules, setIsImportingRules] = useState(false); + const {containsHeader = true} = spreadsheet ?? {}; + const [isValidationEnabled, setIsValidationEnabled] = useState(false); + const policyID = route.params.policyID; + const policy = usePolicy(policyID); + + const {setIsClosing} = useCloseImportPage(); + const showImportSpreadsheetConfirmModal = useImportSpreadsheetConfirmModal(); + + const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); + + const columnRoles: ColumnRole[] = [ + {text: translate('common.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, + {text: translate('workspace.rules.merchantRules.importColumnMerchantIs'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS}, + {text: translate('workspace.rules.merchantRules.importColumnMerchantContains'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT_CONTAINS}, + {text: translate('workspace.rules.merchantRules.importColumnUpdatedMerchant'), value: CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT}, + {text: translate('workspace.rules.merchantRules.importColumnUpdatedCategory'), value: CONST.CSV_IMPORT_COLUMNS.CATEGORY}, + {text: translate('workspace.rules.merchantRules.importColumnUpdatedTag'), value: CONST.CSV_IMPORT_COLUMNS.TAG}, + {text: translate('workspace.rules.merchantRules.importColumnUpdatedDescription'), value: CONST.CSV_IMPORT_COLUMNS.COMMENT}, + {text: translate('common.reimbursable'), value: CONST.CSV_IMPORT_COLUMNS.REIMBURSABLE}, + {text: translate('common.billable'), value: CONST.CSV_IMPORT_COLUMNS.BILLABLE}, + ]; + + const validate = () => { + const columns = Object.values(spreadsheet?.columns ?? {}); + let errors: Errors = {}; + + const hasMerchantFilterColumn = columns.includes(CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS) || columns.includes(CONST.CSV_IMPORT_COLUMNS.MERCHANT_CONTAINS); + const hasActionColumn = ACTION_COLUMNS.some((actionColumn) => columns.includes(actionColumn)); + + if (!hasMerchantFilterColumn || !hasActionColumn) { + errors.required = translate('spreadsheet.importMerchantRulesRequiredColumns'); + } else { + const duplicate = findDuplicate(columns); + const duplicateColumn = columnRoles.find((role) => role.value === duplicate); + + if (duplicateColumn) { + errors.duplicates = translate('spreadsheet.singleFieldMultipleColumns', duplicateColumn.text); + } else { + errors = {}; + } + } + return errors; + }; + + const closeImportPageAndModal = () => { + setIsClosing(true); + setIsImportingRules(false); + Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); + }; + + const importRules = async () => { + setIsValidationEnabled(true); + const errors = validate(); + if (Object.keys(errors).length > 0) { + return; + } + + const columns = Object.values(spreadsheet?.columns ?? {}); + const merchantIsColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS); + const merchantContainsColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.MERCHANT_CONTAINS); + const updatedMerchantColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT); + const categoryColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.CATEGORY); + const tagColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.TAG); + const commentColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.COMMENT); + const reimbursableColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.REIMBURSABLE); + const billableColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.BILLABLE); + + const rowCount = (spreadsheet?.data.at(0)?.length ?? 0) - (containsHeader ? 1 : 0); + const getCellValue = (columnIndex: number, rowIndex: number): string => { + if (columnIndex === -1) { + return ''; + } + const dataIndex = containsHeader ? rowIndex + 1 : rowIndex; + return spreadsheet?.data.at(columnIndex)?.at(dataIndex)?.toString().trim() ?? ''; + }; + + // Seed the duplicate check with the policy's current rules so re-importing a spreadsheet doesn't recreate them + const seenRuleKeys = new Set( + Object.values(policy?.rules?.codingRules ?? {}) + .filter((rule) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && rule.filters?.right) + .map(getRuleContentKey), + ); + let skippedDuplicateCount = 0; + + const rules: Record = {}; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + // "Merchant is" wins when both filter columns have a value for the same row + const merchantIsValue = getCellValue(merchantIsColumn, rowIndex); + const merchantToMatch = merchantIsValue || getCellValue(merchantContainsColumn, rowIndex); + if (!merchantToMatch) { + continue; + } + + const updatedMerchant = getCellValue(updatedMerchantColumn, rowIndex); + const category = getCellValue(categoryColumn, rowIndex); + const tag = normalizeImportedTag(getCellValue(tagColumn, rowIndex), !!policy?.hasMultipleTagLists); + const comment = getCellValue(commentColumn, rowIndex); + const reimbursable = parseCsvBooleanValue(getCellValue(reimbursableColumn, rowIndex)); + const billable = parseCsvBooleanValue(getCellValue(billableColumn, rowIndex)); + + // Skip rows where every action cell is empty since the resulting rule would never change anything + if (!updatedMerchant && !category && !tag && !comment && reimbursable === undefined && billable === undefined) { + continue; + } + + const rule: ImportedMerchantRule = { + filters: { + left: 'merchant', + operator: merchantIsValue ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO : CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + right: merchantToMatch, + }, + ...(updatedMerchant && {merchant: updatedMerchant}), + ...(category && {category}), + ...(tag && {tag}), + ...(comment && {comment: Parser.replace(comment)}), + ...(reimbursable !== undefined && {reimbursable}), + ...(billable !== undefined && {billable}), + created: new Date().toISOString(), + }; + + const ruleKey = getRuleContentKey(rule); + if (seenRuleKeys.has(ruleKey)) { + skippedDuplicateCount++; + continue; + } + seenRuleKeys.add(ruleKey); + + rules[rand64()] = rule; + } + + setIsImportingRules(true); + // When every row duplicates an existing rule, skip the API call and confirm that nothing was added + const importFinalModal: ImportFinalModal = + Object.keys(rules).length === 0 && skippedDuplicateCount > 0 + ? {titleKey: 'spreadsheet.importSuccessfulTitle', promptKey: 'spreadsheet.importMerchantRulesSuccessfulDescription', promptKeyParams: {rules: 0}} + : await importMerchantRulesSpreadsheet(policyID, rules); + const didShowImportFinalModal = await showImportSpreadsheetConfirmModal(importFinalModal, {shouldHandleNavigationBack: false}); + if (!didShowImportFinalModal) { + setIsImportingRules(false); + return; + } + closeImportPageAndModal(); + }; + + if (!spreadsheet && isLoadingOnyxValue(spreadsheetMetadata)) { + return null; + } + + const spreadsheetColumns = spreadsheet?.data; + + if (!spreadsheetColumns) { + return ; + } + + return ( + + + Navigation.goBack(ROUTES.RULES_MERCHANT_IMPORT.getRoute(policyID))} + /> + + + + ); +} + +export default ImportedMerchantRulesPage; +export {normalizeImportedTag}; diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx index 59b8e8c87925..3628e417bf5e 100644 --- a/src/pages/workspace/rules/PolicyRulesPage.tsx +++ b/src/pages/workspace/rules/PolicyRulesPage.tsx @@ -1,13 +1,16 @@ import AgentPromotionalBanner from '@components/AgentPromotionalBanner'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import SpendRulesSection from '@components/SpendRules/SpendRulesSection'; -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -25,6 +28,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type DismissedProductTraining from '@src/types/onyx/DismissedProductTraining'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type {OnyxEntry} from 'react-native-onyx'; @@ -32,6 +36,7 @@ import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import AgentRulesSection from './AgentRulesSection'; +import getImportMerchantRulesOption from './getImportMerchantRulesOption'; import IndividualExpenseRulesSection from './IndividualExpenseRulesSection'; import MerchantRulesSection from './MerchantRulesSection'; import PolicyRulesPageRevamp from './PolicyRulesPageRevamp'; @@ -48,7 +53,9 @@ function PolicyRulesPage(props: PolicyRulesPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.rules'); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const illustrations = useMemoizedLazyIllustrations(['Rules']); + const icons = useMemoizedLazyExpensifyIcons(['Table']); const {canWrite: canWriteRules, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); const {isBetaEnabled} = usePermissions(); const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); @@ -71,6 +78,23 @@ function PolicyRulesPage(props: PolicyRulesPageProps) { return ; } + const moreOptions: Array>> = [ + getImportMerchantRulesOption({policyID, canWriteRules, showReadOnlyModal, translate, icon: icons.Table}), + ]; + + const headerButtons = ( + {}} + shouldAlwaysShowDropdownMenu + customText={translate('common.more')} + options={moreOptions} + isSplitButton={false} + wrapperStyle={styles.flexGrow0} + style={[shouldDisplayButtonsInSeparateLine && styles.w100]} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.IMPORT_MERCHANT_RULES} + /> + ); + return ( + {shouldDisplayButtonsInSeparateLine && {headerButtons}} {isCustomAgentBetaEnabled && !isAgentsRulesBannerDismissed && ( ; - const RULES_TAB_VALUES = new Set(Object.values(RULES_TAB)); function isRulesTab(key: string): key is RulesTab { return RULES_TAB_VALUES.has(key); } -function isTableSelectionTab(tab: RulesTab): tab is Exclude { - return tab !== RULES_TAB.GENERAL; -} - function updateSelectionKeysIfChanged(previousKeys: string[], nextKeys: string[]) { if (previousKeys.length === nextKeys.length && previousKeys.every((key, index) => key === nextKeys.at(index))) { return previousKeys; @@ -83,19 +82,22 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.rules'); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isOffline} = useNetwork(); const illustrations = useMemoizedLazyIllustrations(['Flash']); - const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Feed', 'CreditCardExclamation', 'DocumentMagicWand', 'Task', 'Flag', 'Trashcan']); + const icons = useMemoizedLazyExpensifyIcons(['Plus', 'Feed', 'CreditCardExclamation', 'DocumentMagicWand', 'Task', 'Flag', 'Bot', 'Trashcan', 'Table']); const {canWrite: canWriteRules, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); const {isBetaEnabled} = usePermissions(); const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); + const isCustomAgentBetaEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); const isMobileSelectionModeEnabled = useMobileSelectionMode(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const [isAgentsRulesBannerDismissed = false] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {selector: agentsRulesBannerDismissedSelector}); const [lastSelectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RULES_TAB_TYPE}`); const lastSelectedTabStr = lastSelectedTab as string | undefined; - const activeTab: RulesTab = lastSelectedTabStr && isRulesTab(lastSelectedTabStr) ? lastSelectedTabStr : RULES_TAB.GENERAL; - const [selectedRuleKeysByTab, setSelectedRuleKeysByTab] = useState, string[]>>>({}); + const resolvedTab: RulesTab = lastSelectedTabStr && isRulesTab(lastSelectedTabStr) ? lastSelectedTabStr : RULES_TAB.GENERAL; + const activeTab: RulesTab = resolvedTab === RULES_TAB.AGENTS && !isCustomAgentBetaEnabled ? RULES_TAB.GENERAL : resolvedTab; + const [selectedRuleKeysByTab, setSelectedRuleKeysByTab] = useState>>({}); const {showConfirmModal} = useConfirmModal(); @@ -126,7 +128,7 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { turnOffMobileSelectionMode(); }, [activeTab]); - const updateTabSelectionKeys = useCallback((tab: Exclude, selectedRowKeys: string[]) => { + const updateTabSelectionKeys = useCallback((tab: TableSelectionTab, selectedRowKeys: string[]) => { setSelectedRuleKeysByTab((prev) => { const nextKeys = updateSelectionKeysIfChanged(prev[tab] ?? [], selectedRowKeys); if (prev[tab] === nextKeys) { @@ -154,6 +156,9 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { const hasSelectedRules = selectedRuleKeys.length > 0; const isTableTab = activeTab === RULES_TAB.CARD_RESTRICTIONS || activeTab === RULES_TAB.EXPENSE_DEFAULTS || activeTab === RULES_TAB.REQUIRE_FIELDS || activeTab === RULES_TAB.FLAG_FOR_REVIEW; + const isAgentsTab = activeTab === RULES_TAB.AGENTS; + const hasAgentRules = isAgentsTab && getVisibleAgentRules(policy?.rules?.agentRules, isOffline).length > 0; + const shouldUseFullWidthAgentsTabLayout = isAgentsTab && !hasAgentRules; const shouldShowBulkActions = canWriteRules && isTableTab && (shouldUseNarrowLayout ? isMobileSelectionModeEnabled : hasSelectedRules); const shouldShowAddRuleButton = activeTab === RULES_TAB.GENERAL || !shouldShowBulkActions; @@ -170,7 +175,9 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { return [ { icon: icons.Trashcan, - text: translate('workspace.rules.bulkActions.deleteMultiple', {count: selectedRuleKeys.length}), + text: translate('workspace.rules.bulkActions.deleteMultiple', { + count: selectedRuleKeys.length, + }), value: CONST.POLICY.BULK_ACTION_TYPES.DELETE, onSelected: async () => { const {action} = await showConfirmModal({ @@ -217,6 +224,15 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { title: translate('workspace.rules.tabs.flagForReview'), icon: icons.Flag, }, + ...(isCustomAgentBetaEnabled + ? [ + { + key: RULES_TAB.AGENTS, + title: translate('workspace.rules.tabs.agents'), + icon: icons.Bot, + }, + ] + : []), ]; const handleNewRule = () => { @@ -249,14 +265,41 @@ function PolicyRulesPageRevamp({route}: PolicyRulesPageRevampProps) { return null; } + if (activeTab !== RULES_TAB.EXPENSE_DEFAULTS) { + return ( + )} diff --git a/src/pages/workspace/rules/RulesRequireFieldsPage.tsx b/src/pages/workspace/rules/RulesRequireFieldsPage.tsx index cc0a90ef3e2f..916d7b90aabd 100644 --- a/src/pages/workspace/rules/RulesRequireFieldsPage.tsx +++ b/src/pages/workspace/rules/RulesRequireFieldsPage.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -147,12 +147,13 @@ function RulesRequireFieldsPage({ addOfflineIndicatorBottomSafeAreaPadding > diff --git a/src/pages/workspace/rules/getImportMerchantRulesOption.ts b/src/pages/workspace/rules/getImportMerchantRulesOption.ts new file mode 100644 index 000000000000..1465e16508e8 --- /dev/null +++ b/src/pages/workspace/rules/getImportMerchantRulesOption.ts @@ -0,0 +1,53 @@ +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type ImportMerchantRulesOptionParams = { + /** The policy ID the merchant rules belong to */ + policyID: string; + + /** Whether the current user can write rules on the policy */ + canWriteRules: boolean; + + /** Shows the read-only explanation modal when the user cannot write rules */ + showReadOnlyModal: () => void; + + /** Locale translate function */ + translate: LocaleContextProps['translate']; + + /** Icon shown next to the option */ + icon: IconAsset; +}; + +/** + * Builds the "Import spreadsheet" dropdown option for merchant rules, shared by the + * legacy Rules page and the revamped Rules page so both stay in sync. + */ +function getImportMerchantRulesOption({ + policyID, + canWriteRules, + showReadOnlyModal, + translate, + icon, +}: ImportMerchantRulesOptionParams): DropdownOption> { + return { + icon, + text: translate('workspace.rules.merchantRules.importRulesTitle'), + value: CONST.POLICY.SECONDARY_ACTIONS.IMPORT_SPREADSHEET, + onSelected: () => { + if (!canWriteRules) { + showReadOnlyModal(); + return; + } + Navigation.navigate(ROUTES.RULES_MERCHANT_IMPORT.getRoute(policyID)); + }, + }; +} + +export default getImportMerchantRulesOption; diff --git a/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx new file mode 100644 index 000000000000..49e6896c165a --- /dev/null +++ b/src/pages/workspace/rules/tabs/RulesAgentsTab.tsx @@ -0,0 +1,110 @@ +import AgentRulesList from '@components/AgentRules/AgentRulesList'; +import useAgentRulesSectionHeader from '@components/AgentRules/useAgentRulesSectionHeader'; +import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; +import ScrollView from '@components/ScrollView'; +import Section from '@components/Section'; + +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePolicy from '@hooks/usePolicy'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getVisibleAgentRules} from '@libs/AgentRulesUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import variables from '@styles/variables'; + +import ROUTES from '@src/ROUTES'; + +import React from 'react'; + +type RulesAgentsTabProps = { + policyID: string; + canWriteRules: boolean; + showReadOnlyModal: () => void; +}; + +function RulesAgentsTab({policyID, canWriteRules, showReadOnlyModal}: RulesAgentsTabProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {isOffline} = useNetwork(); + const policy = usePolicy(policyID); + const illustrations = useMemoizedLazyIllustrations(['AgentsIceCream']); + const icons = useMemoizedLazyExpensifyIcons(['Plus']); + + const visibleRules = getVisibleAgentRules(policy?.rules?.agentRules, isOffline); + const hasRules = visibleRules.length > 0; + const {renderTitle, renderSubtitle} = useAgentRulesSectionHeader({ + policyID, + subtitle: translate('workspace.rules.agentRules.revampSubtitle'), + }); + + const handleAddAgentRule = () => { + if (!canWriteRules) { + showReadOnlyModal(); + return; + } + + Navigation.navigate(ROUTES.RULES_AGENT_NEW.getRoute(policyID)); + }; + + if (!hasRules) { + return ( + + + + ); + } + + return ( + +
+ +
+
+ ); +} + +export default RulesAgentsTab; diff --git a/src/pages/workspace/rules/tabs/RulesCardRestrictionsTab.tsx b/src/pages/workspace/rules/tabs/RulesCardRestrictionsTab.tsx index 4406ab24298d..07b7af2f02ce 100644 --- a/src/pages/workspace/rules/tabs/RulesCardRestrictionsTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesCardRestrictionsTab.tsx @@ -73,6 +73,7 @@ function RulesCardRestrictionsTab({policyID, canWriteRules, selectedKeys, onSele searchTokens: [], action: showBuiltInProtectionModal, }; + const customSpendRules: SpendRuleTableItem[] = cardRules.map((rule) => { const ruleSummary = rule.summaryParts.map((part) => part.text).join(', '); return { @@ -89,6 +90,7 @@ function RulesCardRestrictionsTab({policyID, canWriteRules, selectedKeys, onSele action: () => Navigation.navigate(ROUTES.RULES_SPEND_EDIT.getRoute(policyID, rule.ruleID)), }; }); + const spendRulesTableData: SpendRuleTableItem[] = [defaultSpendRule, ...customSpendRules]; const handleGetExpensifyCardPress = () => { diff --git a/src/pages/workspace/rules/tabs/RulesGeneralTab.tsx b/src/pages/workspace/rules/tabs/RulesGeneralTab.tsx index d73a0a3c695c..e41399098abf 100644 --- a/src/pages/workspace/rules/tabs/RulesGeneralTab.tsx +++ b/src/pages/workspace/rules/tabs/RulesGeneralTab.tsx @@ -4,13 +4,12 @@ import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; +import Tab from '@libs/actions/Tab'; import {dismissProductTraining} from '@libs/actions/Welcome'; -import Navigation from '@libs/Navigation/Navigation'; import IndividualExpenseRulesSectionRevamp from '@pages/workspace/rules/IndividualExpenseRulesSectionRevamp'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import React from 'react'; @@ -37,7 +36,7 @@ function RulesGeneralTab({policyID, canWriteRules, isAgentsRulesBannerDismissed} title={translate('workspace.rules.agentsPromoBanner.title')} subtitle={translate('workspace.rules.agentsPromoBanner.subtitle')} ctaText={translate('workspace.rules.agentsPromoBanner.cta')} - onCtaPress={() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID))} + onCtaPress={() => Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.AGENTS)} ctaSentryLabel={CONST.SENTRY_LABEL.AGENTS_RULES_BANNER.CTA} onDismiss={() => dismissProductTraining(CONST.AGENTS_RULES_BANNER, true)} dismissSentryLabel={CONST.SENTRY_LABEL.AGENTS_RULES_BANNER.DISMISS} diff --git a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts index 5df265201621..e587fd3e78fd 100644 --- a/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts +++ b/src/pages/workspace/rules/tabs/useRulesTableBulkActions.ts @@ -38,7 +38,7 @@ const DEFAULT_SPEND_RULE_ID = 'default-rule'; const RULES_TAB = CONST.TAB.RULES; type RulesTab = ValueOf; -type TableSelectionTab = Exclude; +type TableSelectionTab = Exclude; type UseRulesTableBulkActionsParams = { policyID: string; @@ -49,7 +49,7 @@ type UseRulesTableBulkActionsParams = { }; function isTableSelectionTab(tab: RulesTab): tab is TableSelectionTab { - return tab !== RULES_TAB.GENERAL; + return tab !== RULES_TAB.GENERAL && tab !== RULES_TAB.AGENTS; } function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, canWriteRules, clearTableSelection}: UseRulesTableBulkActionsParams) { @@ -274,4 +274,6 @@ function useRulesTableBulkActions({policyID, activeTab, selectedRuleKeysByTab, c }; } +export {isTableSelectionTab}; +export type {RulesTab, TableSelectionTab}; export default useRulesTableBulkActions; diff --git a/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx b/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx index 104ec20b7707..7d4161fdfb65 100644 --- a/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx +++ b/src/pages/workspace/tags/ImportMultiLevelTagsSettingsPage.tsx @@ -1,5 +1,5 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -162,14 +162,15 @@ function ImportMultiLevelTagsSettingsPage({route}: ImportMultiLevelTagsSettingsP addBottomSafeAreaPadding > diff --git a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx index 7936cd349bf3..bec0f8ddf074 100644 --- a/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx +++ b/src/pages/workspace/travel/WorkspaceTravelInvoicingSection.tsx @@ -1,4 +1,4 @@ -import Button from '@components/Button'; +import Button from '@components/ButtonComposed'; import ConfirmModal from '@components/ConfirmModal'; import FormHelpMessageRowWithRetryButton from '@components/Domain/FormHelpMessageRowWithRetryButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -333,11 +333,12 @@ function WorkspaceTravelInvoicingSection({policyID}: WorkspaceTravelInvoicingSec {shouldShowPayButton && canWriteMoreFeatures && ( )} + size={CONST.BUTTON_SIZE.LARGE} + > + {translate('common.upgrade')} + ) : ( )} ); diff --git a/src/pages/workspace/upgrade/UpgradeIntroView.tsx b/src/pages/workspace/upgrade/UpgradeIntroView.tsx index e40c0c0342d5..417a6b47dea9 100644 --- a/src/pages/workspace/upgrade/UpgradeIntroView.tsx +++ b/src/pages/workspace/upgrade/UpgradeIntroView.tsx @@ -121,7 +121,7 @@ function UpgradeIntroView({