diff --git a/Mobile-Expensify b/Mobile-Expensify index da6363868ad6..7afb93138ae6 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit da6363868ad6ee8950454d3b2d8ad9600dc71b5e +Subproject commit 7afb93138ae617149fc3de9ce5c0aab8bfc39e79 diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index e7fbf9baab1c..f183a5775d29 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -32,6 +32,7 @@ function ButtonWithDropdownMenu({ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP }, + popoverHorizontalOffsetType, buttonRef, onPress, options, @@ -74,8 +75,16 @@ function ButtonWithDropdownMenu({ } if ('measureInWindow' in dropdownAnchor.current) { dropdownAnchor.current.measureInWindow((x, y, w, h) => { + let horizontalPosition = x + w; + + if (popoverHorizontalOffsetType === 'left') { + horizontalPosition = x; + } else if (popoverHorizontalOffsetType === 'center') { + horizontalPosition = x + w / 2; + } + setPopoverAnchorPosition({ - horizontal: x + w, + horizontal: horizontalPosition, vertical: anchorAlignment.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP ? y + h + CONST.MODAL.POPOVER_MENU_PADDING // if vertical anchorAlignment is TOP, menu will open below the button and we need to add the height of button and padding @@ -83,7 +92,7 @@ function ButtonWithDropdownMenu({ }); }); } - }, [windowWidth, windowHeight, isMenuVisible, anchorAlignment.vertical]); + }, [windowWidth, windowHeight, isMenuVisible, anchorAlignment.vertical, popoverHorizontalOffsetType]); useKeyboardShortcut( CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index dd7782bb39ba..457c655588ca 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -79,6 +79,14 @@ type ButtonWithDropdownMenuProps = { /** The anchor alignment of the popover menu */ anchorAlignment?: AnchorAlignment; + /** + * Determines how the popover menu should be horizontally positioned relative to the button. + * - 'right': Anchors to the right edge of the button (default) + * - 'left': Anchors to the left edge of the button + * - 'center': Anchors to the center of the button + */ + popoverHorizontalOffsetType?: ValueOf; + /* ref for the button */ buttonRef?: RefObject; diff --git a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx index 09f03326c555..fba02f066e7f 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx @@ -1,36 +1,21 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useMemo, useState} from 'react'; -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; -import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; -import ConfirmModal from '@components/ConfirmModal'; -import DecisionModal from '@components/DecisionModal'; import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; import {useSearchContext} from '@components/Search/SearchContext'; -import type {PaymentData, SearchQueryJSON} from '@components/Search/types'; +import type {SearchQueryJSON} from '@components/Search/types'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; -import useActiveWorkspace from '@hooks/useActiveWorkspace'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import { - approveMoneyRequestOnSearch, - deleteMoneyRequestOnSearch, - exportSearchItemsToCSV, - getLastPolicyPaymentMethod, - payMoneyRequestOnSearch, - unholdMoneyRequestOnSearch, - updateAdvancedFilters, -} from '@libs/actions/Search'; +import {updateAdvancedFilters} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getAllTaxRates, hasVBBA} from '@libs/PolicyUtils'; +import {getAllTaxRates} from '@libs/PolicyUtils'; import {buildFilterFormValuesFromQuery} from '@libs/SearchQueryUtils'; import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import variables from '@styles/variables'; @@ -46,20 +31,15 @@ type SearchPageHeaderProps = { searchRouterListVisible?: boolean; hideSearchRouterList?: () => void; onSearchRouterFocus?: () => void; + headerButtonsOptions: Array>; }; type SearchHeaderOptionValue = DeepValueOf | undefined; -function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus}: SearchPageHeaderProps) { - const {translate} = useLocalize(); - const theme = useTheme(); +function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideSearchRouterList, onSearchRouterFocus, headerButtonsOptions}: SearchPageHeaderProps) { const styles = useThemeStyles(); - const {isOffline} = useNetwork(); - const {activeWorkspaceID} = useActiveWorkspace(); - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); - const {selectedTransactions, clearSelectedTransactions, selectedReports} = useSearchContext(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {selectedTransactions} = useSearchContext(); const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); @@ -70,10 +50,6 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [lastPaymentMethods = {}] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); - const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false); - const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); - const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); const [isScreenFocused, setIsScreenFocused] = useState(false); const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( @@ -81,8 +57,6 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS isScreenFocused, ); - const {status, hash} = queryJSON; - useFocusEffect( useCallback(() => { setIsScreenFocused(true); @@ -94,240 +68,6 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); - const handleDeleteExpenses = () => { - if (selectedTransactionsKeys.length === 0) { - return; - } - - setIsDeleteExpensesConfirmModalVisible(false); - deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys); - - // Translations copy for delete modal depends on amount of selected items, - // We need to wait for modal to fully disappear before clearing them to avoid translation flicker between singular vs plural - InteractionManager.runAfterInteractions(() => { - clearSelectedTransactions(); - }); - }; - - const headerButtonsOptions = useMemo(() => { - if (selectedTransactionsKeys.length === 0) { - return []; - } - - const options: Array> = []; - const isAnyTransactionOnHold = Object.values(selectedTransactions).some((transaction) => transaction.isHeld); - - const shouldShowApproveOption = - !isOffline && - !isAnyTransactionOnHold && - (selectedReports.length - ? selectedReports.every((report) => report.action === CONST.SEARCH.ACTION_TYPES.APPROVE) - : selectedTransactionsKeys.every((id) => selectedTransactions[id].action === CONST.SEARCH.ACTION_TYPES.APPROVE)); - - if (shouldShowApproveOption) { - options.push({ - icon: Expensicons.ThumbsUp, - text: translate('search.bulkActions.approve'), - value: CONST.SEARCH.BULK_ACTION_TYPES.APPROVE, - shouldCloseModalOnSelect: true, - onSelected: () => { - if (isOffline) { - setIsOfflineModalVisible(true); - return; - } - - const transactionIDList = selectedReports.length ? undefined : Object.keys(selectedTransactions); - const reportIDList = !selectedReports.length - ? Object.values(selectedTransactions).map((transaction) => transaction.reportID) - : selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? []; - approveMoneyRequestOnSearch(hash, reportIDList, transactionIDList); - }, - }); - } - - const shouldShowPayOption = - !isOffline && - !isAnyTransactionOnHold && - (selectedReports.length - ? selectedReports.every((report) => report.action === CONST.SEARCH.ACTION_TYPES.PAY && report.policyID && getLastPolicyPaymentMethod(report.policyID, lastPaymentMethods)) - : selectedTransactionsKeys.every( - (id) => - selectedTransactions[id].action === CONST.SEARCH.ACTION_TYPES.PAY && - selectedTransactions[id].policyID && - getLastPolicyPaymentMethod(selectedTransactions[id].policyID, lastPaymentMethods), - )); - - if (shouldShowPayOption) { - options.push({ - icon: Expensicons.MoneyBag, - text: translate('search.bulkActions.pay'), - value: CONST.SEARCH.BULK_ACTION_TYPES.PAY, - shouldCloseModalOnSelect: true, - onSelected: () => { - if (isOffline) { - setIsOfflineModalVisible(true); - return; - } - - const activeRoute = Navigation.getActiveRoute(); - const transactionIDList = selectedReports.length ? undefined : Object.keys(selectedTransactions); - const items = selectedReports.length ? selectedReports : Object.values(selectedTransactions); - - for (const item of items) { - const policyID = item.policyID; - const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(policyID, lastPaymentMethods); - - if (!lastPolicyPaymentMethod) { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: item.reportID, backTo: activeRoute})); - return; - } - - const hasPolicyVBBA = hasVBBA(policyID); - - if (lastPolicyPaymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE && !hasPolicyVBBA) { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: item.reportID, backTo: activeRoute})); - return; - } - } - - const paymentData = ( - selectedReports.length - ? selectedReports.map((report) => ({ - reportID: report.reportID, - amount: report.total, - paymentType: getLastPolicyPaymentMethod(report.policyID, lastPaymentMethods), - })) - : Object.values(selectedTransactions).map((transaction) => ({ - reportID: transaction.reportID, - amount: transaction.amount, - paymentType: getLastPolicyPaymentMethod(transaction.policyID, lastPaymentMethods), - })) - ) as PaymentData[]; - - payMoneyRequestOnSearch(hash, paymentData, transactionIDList); - }, - }); - } - - options.push({ - icon: Expensicons.Download, - text: translate('common.download'), - value: CONST.SEARCH.BULK_ACTION_TYPES.EXPORT, - shouldCloseModalOnSelect: true, - onSelected: () => { - if (isOffline) { - setIsOfflineModalVisible(true); - return; - } - - const reportIDList = selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? []; - exportSearchItemsToCSV( - { - query: status, - jsonQuery: JSON.stringify(queryJSON), - reportIDList, - transactionIDList: selectedTransactionsKeys, - policyIDs: activeWorkspaceID ? [activeWorkspaceID] : [''], - }, - () => { - setIsDownloadErrorModalVisible(true); - }, - ); - }, - }); - - const shouldShowHoldOption = !isOffline && selectedTransactionsKeys.every((id) => selectedTransactions[id].canHold); - - if (shouldShowHoldOption) { - options.push({ - icon: Expensicons.Stopwatch, - text: translate('search.bulkActions.hold'), - value: CONST.SEARCH.BULK_ACTION_TYPES.HOLD, - shouldCloseModalOnSelect: true, - onSelected: () => { - if (isOffline) { - setIsOfflineModalVisible(true); - return; - } - - Navigation.navigate(ROUTES.TRANSACTION_HOLD_REASON_RHP); - }, - }); - } - - const shouldShowUnholdOption = !isOffline && selectedTransactionsKeys.every((id) => selectedTransactions[id].canUnhold); - - if (shouldShowUnholdOption) { - options.push({ - icon: Expensicons.Stopwatch, - text: translate('search.bulkActions.unhold'), - value: CONST.SEARCH.BULK_ACTION_TYPES.UNHOLD, - shouldCloseModalOnSelect: true, - onSelected: () => { - if (isOffline) { - setIsOfflineModalVisible(true); - return; - } - - unholdMoneyRequestOnSearch(hash, selectedTransactionsKeys); - }, - }); - } - - const shouldShowDeleteOption = !isOffline && selectedTransactionsKeys.every((id) => selectedTransactions[id].canDelete); - - if (shouldShowDeleteOption) { - options.push({ - icon: Expensicons.Trashcan, - text: translate('search.bulkActions.delete'), - value: CONST.SEARCH.BULK_ACTION_TYPES.DELETE, - shouldCloseModalOnSelect: true, - onSelected: () => { - if (isOffline) { - setIsOfflineModalVisible(true); - return; - } - setIsDeleteExpensesConfirmModalVisible(true); - }, - }); - } - - if (options.length === 0) { - const emptyOptionStyle = { - interactive: false, - iconFill: theme.icon, - iconHeight: variables.iconSizeLarge, - iconWidth: variables.iconSizeLarge, - numberOfLinesTitle: 2, - titleStyle: {...styles.colorMuted, ...styles.fontWeightNormal, ...styles.textWrap}, - }; - - options.push({ - icon: Expensicons.Exclamation, - text: translate('search.bulkActions.noOptionsAvailable'), - value: undefined, - ...emptyOptionStyle, - }); - } - - return options; - }, [ - selectedTransactionsKeys, - selectedTransactions, - isOffline, - selectedReports, - translate, - hash, - lastPaymentMethods, - status, - queryJSON, - activeWorkspaceID, - theme.icon, - styles.colorMuted, - styles.fontWeightNormal, - styles.textWrap, - ]); - const onFiltersButtonPress = useCallback(() => { hideProductTrainingTooltip(); const filterFormValues = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, allCards, reports, taxRates); @@ -337,17 +77,7 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS }, [allCards, currencyList, hideProductTrainingTooltip, personalDetails, policyCategories, policyTagsLists, queryJSON, reports, taxRates]); const InputRightComponent = useMemo(() => { - return headerButtonsOptions.length > 0 && (!shouldUseNarrowLayout || selectionMode?.isEnabled) ? ( - null} - shouldAlwaysShowDropdownMenu - buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {count: selectedTransactionsKeys.length})} - options={headerButtonsOptions} - isSplitButton={false} - shouldUseStyleUtilityForAnchorPosition - /> - ) : ( + return ( ); }, [ - headerButtonsOptions, onFiltersButtonPress, renderProductTrainingTooltip, - selectedTransactionsKeys.length, shouldShowProductTrainingTooltip, styles.bgTransparent, styles.borderNone, styles.productTrainingTooltipWrapper, styles.searchAutocompleteInputResults, - translate, - selectionMode, - shouldUseNarrowLayout, ]); if (shouldUseNarrowLayout && selectionMode?.isEnabled) { @@ -389,81 +114,19 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS options={headerButtonsOptions} itemsLength={selectedTransactionsKeys.length} /> - { - setIsDeleteExpensesConfirmModalVisible(false); - }} - title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})} - prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - setIsOfflineModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isOfflineModalVisible} - onClose={() => setIsOfflineModalVisible(false)} - /> - setIsDownloadErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadErrorModalVisible} - onClose={() => setIsDownloadErrorModalVisible(false)} - /> ); } return ( - <> - - { - setIsDeleteExpensesConfirmModalVisible(false); - }} - title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})} - prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - setIsOfflineModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isOfflineModalVisible} - onClose={() => setIsOfflineModalVisible(false)} - /> - setIsDownloadErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadErrorModalVisible} - onClose={() => setIsDownloadErrorModalVisible(false)} - /> - + ); } diff --git a/src/components/Search/SearchPageHeader/SearchStatusBar.tsx b/src/components/Search/SearchPageHeader/SearchStatusBar.tsx index 787971c7a324..844c0bae0327 100644 --- a/src/components/Search/SearchPageHeader/SearchStatusBar.tsx +++ b/src/components/Search/SearchPageHeader/SearchStatusBar.tsx @@ -1,13 +1,18 @@ -import React, {useRef} from 'react'; +import React, {useMemo, useRef} from 'react'; +import {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import * as Expensicons from '@components/Icon/Expensicons'; import ScrollView from '@components/ScrollView'; import {useSearchContext} from '@components/Search/SearchContext'; import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryJSON, TripSearchStatus} from '@components/Search/types'; import SearchStatusSkeleton from '@components/Skeletons/SearchStatusSkeleton'; import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSingleExecution from '@hooks/useSingleExecution'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -16,12 +21,15 @@ import Navigation from '@libs/Navigation/Navigation'; import {buildSearchQueryString} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {SearchHeaderOptionValue} from './SearchPageHeader'; type SearchStatusBarProps = { queryJSON: SearchQueryJSON; onStatusChange?: () => void; + headerButtonsOptions: Array>; }; const expenseOptions: Array<{status: ExpenseSearchStatus; type: SearchDataTypes; icon: IconAsset; text: TranslationPaths}> = [ @@ -158,63 +166,87 @@ function getOptions(type: SearchDataTypes) { } } -function SearchStatusBar({queryJSON, onStatusChange}: SearchStatusBarProps) { +function SearchStatusBar({queryJSON, onStatusChange, headerButtonsOptions}: SearchStatusBarProps) { const {singleExecution} = useSingleExecution(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {selectedTransactions} = useSearchContext(); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); const options = getOptions(queryJSON.type); const scrollRef = useRef(null); const isScrolledRef = useRef(false); const {shouldShowStatusBarLoading} = useSearchContext(); + const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions ?? {}), [selectedTransactions]); + const shouldShowSelectedDropdown = headerButtonsOptions.length > 0 && (!shouldUseNarrowLayout || (!!selectionMode && selectionMode.isEnabled)); + if (shouldShowStatusBarLoading) { return ; } return ( - - {options.map((item, index) => { - const onPress = singleExecution(() => { - onStatusChange?.(); - const query = buildSearchQueryString({...queryJSON, status: item.status}); - Navigation.setParams({q: query}); - }); - const isActive = Array.isArray(queryJSON.status) ? queryJSON.status.includes(item.status) : queryJSON.status === item.status; - const isFirstItem = index === 0; - const isLastItem = index === options.length - 1; + + {shouldShowSelectedDropdown ? ( + null} + shouldAlwaysShowDropdownMenu + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {count: selectedTransactionsKeys.length})} + options={headerButtonsOptions} + isSplitButton={false} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }} + popoverHorizontalOffsetType={CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT} + /> + ) : ( + + {options.map((item, index) => { + const onPress = singleExecution(() => { + onStatusChange?.(); + const query = buildSearchQueryString({...queryJSON, status: item.status}); + Navigation.setParams({q: query}); + }); + const isActive = Array.isArray(queryJSON.status) ? queryJSON.status.includes(item.status) : queryJSON.status === item.status; + const isFirstItem = index === 0; + const isLastItem = index === options.length - 1; - return ( -