From 36367f06b1ff4e36c7e0b3d73a4d0d2f30b7327a Mon Sep 17 00:00:00 2001 From: Cong Pham Date: Wed, 21 Oct 2026 18:09:29 +0700 Subject: [PATCH 1/7] Add Trips table to Spend behind the TravelApproval beta --- src/CONST/index.ts | 30 +++- .../Search/FilterDropdowns/SortByPopup.tsx | 3 + .../SearchList/ListItem/TripListItem.tsx | 70 ++++++++ .../SearchList/ListItem/TripListItemRow.tsx | 157 ++++++++++++++++++ .../SearchList/ListItem/TripStatusCell.tsx | 44 +++++ .../Search/SearchList/ListItem/types.ts | 23 +++ .../Search/SearchSelectionFooter.tsx | 15 +- src/components/Search/SearchTableHeader.tsx | 56 ++++++- src/components/Search/TripSearchView.tsx | 136 +++++++++++++++ .../Search/hooks/useSearchColumnsToShow.ts | 3 + .../Search/hooks/useSearchSnapshot.ts | 27 ++- src/components/Search/index.tsx | 27 ++- src/components/Search/types.ts | 1 + src/hooks/useSearchAutoRefetch.ts | 8 + src/hooks/useSearchPageSetup.ts | 26 ++- src/hooks/useShouldReturnTripRooms.ts | 15 ++ src/languages/de.ts | 9 + src/languages/el.ts | 9 + src/languages/en.ts | 9 + src/languages/es.ts | 9 + src/languages/fr.ts | 9 + src/languages/it.ts | 9 + src/languages/ja.ts | 9 + src/languages/nl.ts | 9 + src/languages/pl.ts | 9 + src/languages/pt-BR.ts | 9 + src/languages/zh-hans.ts | 9 + src/libs/SearchUIUtils.ts | 101 ++++++++++- src/libs/actions/Search.ts | 5 + src/pages/Search/SearchColumnsPage.tsx | 8 +- src/pages/Search/SearchPage.tsx | 20 ++- src/pages/Search/SearchPageNarrow/index.tsx | 20 ++- src/styles/utils/index.ts | 4 + src/types/onyx/SearchResults.ts | 25 ++- tests/ui/TripListItemRowTest.tsx | 130 +++++++++++++++ tests/unit/Search/ColumnAvailabilityTest.ts | 5 + tests/unit/Search/SearchUIUtilsTest.ts | 121 +++++++++++++- .../Search/searchReturnTripRoomsFlagTest.ts | 68 ++++++++ 38 files changed, 1208 insertions(+), 39 deletions(-) create mode 100644 src/components/Search/SearchList/ListItem/TripListItem.tsx create mode 100644 src/components/Search/SearchList/ListItem/TripListItemRow.tsx create mode 100644 src/components/Search/SearchList/ListItem/TripStatusCell.tsx create mode 100644 src/components/Search/TripSearchView.tsx create mode 100644 src/hooks/useShouldReturnTripRooms.ts create mode 100644 tests/ui/TripListItemRowTest.tsx create mode 100644 tests/unit/Search/searchReturnTripRoomsFlagTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5e4cd447075b..5a69b3513603 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8252,7 +8252,15 @@ const CONST = { }, INVOICE: {}, TASK: {}, - TRIP: {}, + TRIP: { + START_DATE: this.TABLE_COLUMNS.START_DATE, + END_DATE: this.TABLE_COLUMNS.END_DATE, + STATUS: this.TABLE_COLUMNS.STATUS, + TRIP_NAME: this.TABLE_COLUMNS.TRIP_NAME, + TRAVELER: this.TABLE_COLUMNS.TRAVELER, + TOTAL: this.TABLE_COLUMNS.TOTAL, + ACTION: this.TABLE_COLUMNS.ACTION, + }, CHAT: {}, }; }, @@ -8356,7 +8364,15 @@ const CONST = { ], INVOICE: [], TASK: [], - TRIP: [], + TRIP: [ + this.TABLE_COLUMNS.START_DATE, + this.TABLE_COLUMNS.END_DATE, + this.TABLE_COLUMNS.STATUS, + this.TABLE_COLUMNS.TRIP_NAME, + this.TABLE_COLUMNS.TRAVELER, + this.TABLE_COLUMNS.TOTAL, + this.TABLE_COLUMNS.ACTION, + ], CHAT: [], }; }, @@ -8425,6 +8441,12 @@ const CONST = { }, }, GROUP_COLUMN_PREFIX: 'group', + // Status the backend works out for each trip row, from the trip's dates and Spotnana status + TRIP_STATUS: { + UPCOMING: 'upcoming', + DONE: 'done', + CANCELED: 'canceled', + }, TABLE_COLUMNS: { RECEIPT: 'receipt', DATE: 'date', @@ -8502,6 +8524,10 @@ const CONST = { GROUP_WITHDRAWAL_STATUS: 'groupWithdrawalStatus', GROUP_AMOUNT_DEBITED: 'groupAmountDebited', GROUP_AMOUNT_REIMBURSED: 'groupAmountReimbursed', + START_DATE: 'startDate', + END_DATE: 'endDate', + TRIP_NAME: 'tripName', + TRAVELER: 'traveler', }, SYNTAX_OPERATORS: { AND: 'and', diff --git a/src/components/Search/FilterDropdowns/SortByPopup.tsx b/src/components/Search/FilterDropdowns/SortByPopup.tsx index de00d08e8586..635efe5b1fe1 100644 --- a/src/components/Search/FilterDropdowns/SortByPopup.tsx +++ b/src/components/Search/FilterDropdowns/SortByPopup.tsx @@ -11,6 +11,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsVendorColumnAvailable from '@hooks/useIsVendorColumnAvailable'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useThemeStyles from '@hooks/useThemeStyles'; import {close} from '@libs/actions/Modal'; @@ -55,6 +56,7 @@ function SortByPopup({searchResults, queryJSON, groupBy, onSort, onSortOrderPres const isVendorColumnAvailable = useIsVendorColumnAvailable(); const searchDataType = shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type; + const shouldReturnTripRooms = useShouldReturnTripRooms(searchDataType); const currentColumns = !searchResults?.data ? [] : getColumnsToShow({ @@ -66,6 +68,7 @@ function SortByPopup({searchResults, queryJSON, groupBy, onSort, onSortOrderPres sortBy: queryJSON.sortBy, shouldShowViolationsColumn: queryHasViolationFilter(queryJSON), isVendorColumnAvailable, + shouldReturnTripRooms, }); const sortableColumns = getSortByOptions(currentColumns, translate, searchDataType); const sortOrder = queryJSON.sortOrder; diff --git a/src/components/Search/SearchList/ListItem/TripListItem.tsx b/src/components/Search/SearchList/ListItem/TripListItem.tsx new file mode 100644 index 000000000000..5314298515da --- /dev/null +++ b/src/components/Search/SearchList/ListItem/TripListItem.tsx @@ -0,0 +1,70 @@ +import ListItemComposed from '@components/SelectionList/ListItemComposed'; +import type {ListItem} from '@components/SelectionList/types'; + +import useLayoutSpacing from '@hooks/useLayoutSpacing'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {TripListItemProps, TripListItemType} from './types'; + +import TripListItemRow from './TripListItemRow'; + +/** + * A trip row in search results. Tapping the row or its View action opens the trip room. + */ +function TripListItem({item, columns = [], isFocused, showTooltip, isDisabled, onSelectRow, onFocus, shouldSyncFocus, isLastItem}: TripListItemProps) { + const tripItem = item as unknown as TripListItemType; + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {pageGutterMargin} = useLayoutSpacing(); + const {isLargeScreenWidth} = useResponsiveLayout(); + + const listItemPressableStyle = [ + styles.selectionListPressableItemWrapper, + styles.pv3, + styles.ph3, + // Background is applied on the parent wrapper, so keep this transparent + styles.bgTransparent, + styles.mh0, + isLargeScreenWidth && StyleUtils.getSearchTableRowPressableStyle(!!isLastItem, false, {vertical: variables.tableRowPaddingVertical}), + ]; + + return ( + + + onSelectRow(item)} + /> + + + ); +} + +export default TripListItem; diff --git a/src/components/Search/SearchList/ListItem/TripListItemRow.tsx b/src/components/Search/SearchList/ListItem/TripListItemRow.tsx new file mode 100644 index 000000000000..b73cb613a077 --- /dev/null +++ b/src/components/Search/SearchList/ListItem/TripListItemRow.tsx @@ -0,0 +1,157 @@ +import Icon from '@components/Icon'; +import {useSearchColumnStyles} from '@components/Search/SearchColumnWidthsContext'; +import type {SearchColumnType} from '@components/Search/types'; +import {useListItemHovered} from '@components/SelectionList/ListItemContext'; +import Text from '@components/Text'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import React, {Fragment} from 'react'; +import {View} from 'react-native'; + +import type {TripListItemType} from './types'; + +import ActionCell from './ActionCell'; +import DateCell from './DateCell'; +import TextCell from './TextCell'; +import TotalCell from './TotalCell'; +import TripStatusCell from './TripStatusCell'; +import UserInfoCell from './UserInfoCell'; + +type TripListItemRowProps = { + /** The trip to show */ + item: TripListItemType; + + /** Columns to show on wide screens */ + columns: SearchColumnType[]; + + /** Whether to show tooltips */ + showTooltip: boolean; + + /** Opens the trip */ + onViewPress: () => void; +}; + +/** + * A trip row in search results showing the trip's dates, status, name, traveler and total, with a View action. + */ +function TripListItemRow({item, columns, showTooltip, onViewPress}: TripListItemRowProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + const getSearchColumnStyles = useSearchColumnStyles(); + const {isLargeScreenWidth} = useResponsiveLayout(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowRight']); + const isHovered = useListItemHovered(); + + const startDate = ( + + ); + const endDate = ( + + ); + const traveler = ( + + ); + const total = ( + + ); + + if (!isLargeScreenWidth) { + return ( + + + {traveler} + + + + + + + + + {startDate} + - + {endDate} + + + {total} + + + ); + } + + const columnComponents: Partial> = { + [CONST.SEARCH.TABLE_COLUMNS.START_DATE]: {startDate}, + [CONST.SEARCH.TABLE_COLUMNS.END_DATE]: {endDate}, + [CONST.SEARCH.TABLE_COLUMNS.STATUS]: ( + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME]: ( + + + + ), + [CONST.SEARCH.TABLE_COLUMNS.TRAVELER]: {traveler}, + [CONST.SEARCH.TABLE_COLUMNS.TOTAL]: {total}, + [CONST.SEARCH.TABLE_COLUMNS.ACTION]: ( + + + + ), + }; + + return ( + + + {columns.map((column) => ( + {columnComponents[column]} + ))} + + + + ); +} + +export default TripListItemRow; diff --git a/src/components/Search/SearchList/ListItem/TripStatusCell.tsx b/src/components/Search/SearchList/ListItem/TripStatusCell.tsx new file mode 100644 index 000000000000..2d0aca212fcb --- /dev/null +++ b/src/components/Search/SearchList/ListItem/TripStatusCell.tsx @@ -0,0 +1,44 @@ +import StatusBadge from '@components/StatusBadge'; + +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import type {SearchTrip} from '@src/types/onyx/SearchResults'; + +import React from 'react'; +import {View} from 'react-native'; + +type TripStatusCellProps = { + /** The trip's status */ + status: SearchTrip['status']; +}; + +function TripStatusCell({status}: TripStatusCellProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + + if (!status) { + return null; + } + + const badgeColors = { + [CONST.SEARCH.TRIP_STATUS.UPCOMING]: theme.reportStatusBadge.approved, + [CONST.SEARCH.TRIP_STATUS.DONE]: theme.reportStatusBadge.paid, + [CONST.SEARCH.TRIP_STATUS.CANCELED]: theme.reportStatusBadge.unreported, + }[status]; + + return ( + + + + ); +} + +export default TripStatusCell; diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 69bc088aa173..b4b37fb4e872 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -34,6 +34,7 @@ import type { SearchTagGroup, SearchTask, SearchTransactionAction, + SearchTrip, SearchWeekGroup, SearchWithdrawalIDGroup, SearchYearGroup, @@ -391,6 +392,26 @@ type TaskListItemType = ListItem & shouldShowYear: boolean; }; +type TripListItemType = ListItem & + SearchTrip & { + /** The personal details of the traveler */ + traveler: PersonalDetails; + + /** Final and formatted "traveler" value used for displaying and sorting */ + formattedTraveler: string; + + /** The trip room's name, used as the trip name */ + tripName: string; + + /** Key used internally by React */ + keyForList: string; + }; + +type TripListItemProps = ListItemProps & { + /** Columns to show */ + columns?: SearchColumnType[]; +}; + type ReportActionListItemType = ListItem & ReportAction & { /** The personal details of the user posting comment */ @@ -611,6 +632,8 @@ export type { SearchListItem, TaskListItemProps, TaskListItemType, + TripListItemProps, + TripListItemType, TransactionGroupListItemType, TransactionListItemType, TransactionCardGroupListItemType, diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 90140bc9bf99..6c8bee0777c8 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -3,6 +3,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import {close} from '@libs/actions/Modal'; import {getFooterConvertedAmounts, search} from '@libs/actions/Search'; @@ -161,6 +162,7 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp const {currentSearchResults, currentSearchTransactionsByReportID, shouldUseLiveData} = useSearchResultsContext(); const {currentSearchHash, currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); + const shouldReturnTripRooms = useShouldReturnTripRooms(currentSearchQueryJSON?.type); const {isOffline} = useNetwork(); const activePolicy = useActivePolicy(); const [accountID = CONST.DEFAULT_NUMBER_ID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); @@ -696,6 +698,7 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp shouldCalculateTotals: true, isLoading: false, shouldShowLoading: false, + shouldReturnTripRooms, }), ).finally(() => setIsAwaitingBreakdownTotal(false)); } @@ -711,8 +714,16 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp if (!isSnapshotTotalForOtherBreakdown || hasPartialSelection || isAwaitingBreakdownTotal || !currentSearchQueryJSON) { return; } - search({queryJSON: currentSearchQueryJSON, searchKey: currentSearchKey, offset: metadata?.offset ?? 0, shouldCalculateTotals: true, isLoading: false, shouldShowLoading: false}); - }, [currentSearchKey, currentSearchQueryJSON, hasPartialSelection, isAwaitingBreakdownTotal, isSnapshotTotalForOtherBreakdown, metadata?.offset]); + search({ + queryJSON: currentSearchQueryJSON, + searchKey: currentSearchKey, + offset: metadata?.offset ?? 0, + shouldCalculateTotals: true, + isLoading: false, + shouldShowLoading: false, + shouldReturnTripRooms, + }); + }, [currentSearchKey, currentSearchQueryJSON, hasPartialSelection, isAwaitingBreakdownTotal, isSnapshotTotalForOtherBreakdown, metadata?.offset, shouldReturnTripRooms]); const handleFooterCountChange = (nextCountType: SearchFooterCount) => { applyFooterSelection({footerCount: nextCountType}); diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index b1477a3c0545..22e4534b32ea 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -1,5 +1,6 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useThemeStyles from '@hooks/useThemeStyles'; import {isCreatedDateType} from '@libs/SearchUIUtils'; @@ -249,6 +250,45 @@ const taskHeaders: SearchColumnConfig[] = [ }, ]; +// The dates render in DateCell's editable cell frame, so their headers take the same inset to line up +const tripHeaders: SearchColumnConfig[] = [ + { + columnName: CONST.SEARCH.TABLE_COLUMNS.START_DATE, + translationKey: 'iou.startDate', + isColumnSortable: false, + canEdit: true, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.END_DATE, + translationKey: 'iou.endDate', + isColumnSortable: false, + canEdit: true, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.STATUS, + translationKey: 'common.status', + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME, + translationKey: 'search.trip.tripName', + isColumnSortable: false, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.TRAVELER, + translationKey: 'search.trip.traveler', + isColumnSortable: false, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.TOTAL, + translationKey: 'common.total', + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.ACTION, + translationKey: 'common.action', + isColumnSortable: false, + }, +]; + const getExpenseReportHeaders = (profileIcon?: IconAsset): SearchColumnConfig[] => [ { columnName: CONST.SEARCH.TABLE_COLUMNS.AVATAR, @@ -518,7 +558,13 @@ const getTransactionGroupHeaders = (groupBy: SearchGroupBy, icons: SearchHeaderI } }; -function getSearchColumns(type: ValueOf, icons: SearchHeaderIcons, groupBy?: SearchGroupBy, isExpenseReportView?: boolean): SearchColumnConfig[] | null { +function getSearchColumns( + type: ValueOf, + icons: SearchHeaderIcons, + groupBy?: SearchGroupBy, + isExpenseReportView?: boolean, + shouldReturnTripRooms?: boolean, +): SearchColumnConfig[] | null { switch (type) { case CONST.SEARCH.DATA_TYPES.EXPENSE: if (!isExpenseReportView && groupBy) { @@ -528,7 +574,7 @@ function getSearchColumns(type: ValueOf, icons: case CONST.SEARCH.DATA_TYPES.INVOICE: return getExpenseHeaders(groupBy); case CONST.SEARCH.DATA_TYPES.TRIP: - return getExpenseHeaders(groupBy); + return shouldReturnTripRooms ? tripHeaders : getExpenseHeaders(groupBy); case CONST.SEARCH.DATA_TYPES.TASK: return taskHeaders; case CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT: @@ -588,6 +634,7 @@ function SearchTableHeader({ // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout(); const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth; + const shouldReturnTripRooms = useShouldReturnTripRooms(type); // Only load Profile icon when it's needed for EXPENSE_REPORT type or grouped transactions const icons = useMemoizedLazyExpensifyIcons(type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || !!groupBy ? ['Profile', 'Bank', 'CreditCard'] : []) satisfies SearchHeaderIcons; @@ -599,7 +646,10 @@ function SearchTableHeader({ [columns], ); - const columnConfig = useMemo(() => getSearchColumns(type, icons, groupBy, isExpenseReportView), [type, groupBy, icons, isExpenseReportView]); + const columnConfig = useMemo( + () => getSearchColumns(type, icons, groupBy, isExpenseReportView, shouldReturnTripRooms), + [type, groupBy, icons, isExpenseReportView, shouldReturnTripRooms], + ); const orderedColumnConfig = useMemo(() => { if (!columnConfig) { diff --git a/src/components/Search/TripSearchView.tsx b/src/components/Search/TripSearchView.tsx new file mode 100644 index 000000000000..a23b8359ec50 --- /dev/null +++ b/src/components/Search/TripSearchView.tsx @@ -0,0 +1,136 @@ +import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; + +import CONST from '@src/CONST'; + +import type {NativeSyntheticEvent} from 'react-native'; + +import React from 'react'; + +import type {SearchListItem} from './SearchList/ListItem/types'; +import type {CommonSearchViewProps} from './searchViewProps'; + +import useSearchListViewState from './hooks/useSearchListViewState'; +import AnimatedExitRow from './primitives/AnimatedExitRow'; +import SelectionTopBar from './primitives/SelectionTopBar'; +import BaseSearchList from './SearchList/BaseSearchList'; +import TripListItem from './SearchList/ListItem/TripListItem'; +import SearchListViewLayout from './SearchListViewLayout'; + +type TripSearchViewProps = CommonSearchViewProps; + +const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; + +const isRowDeleted = (item: SearchListItem) => item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + +/** + * The trip Search list, one row per trip room. + * + * Rendered by `` as a child of the selection providers, like the task list. Trip rows have no checkbox, + * no exit animation and no long press, and tapping a row or its View action opens the trip room. + */ +function TripSearchView({ + queryJSON, + data, + columns, + canSelectMultiple, + isActionColumnWide, + columnSizeOptions, + isMobileSelectionModeEnabled, + SearchTableHeader: searchTableHeader, + tableHeaderVisible, + hasLoadedAllTransactions, + newTransactions, + onSelectRow, + ListFooterComponent, + onEndReached, + onLayout, + onScroll, + onViewableItemsChanged, + contentContainerStyle, + containerStyle, +}: TripSearchViewProps) { + const {type} = queryJSON; + + const {isOffline, isKeyboardShown, safeAreaPaddingBottomStyle, isLargeScreenWidth, toggleAll, selectedTransactions, listRef, modal, handleSelectRow, scrollToListIndex} = + useSearchListViewState({data, isMobileSelectionModeEnabled, onSelectRow, shouldPreventLongPressRow: true}); + + // Trips are a flat list, so visibility and selection counts are a single pass over the rows. + const isItemVisible = (item: SearchListItem) => !isRowDeleted(item) || isOffline; + const lastVisibleIndex = data.findLastIndex(isItemVisible); + + const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); + const totalItems = data.filter((item) => !isRowDeleted(item)).length; + + const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( + // Trip rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. + + + + ); + + const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems && hasLoadedAllTransactions; + const selectAllButtonVisible = canSelectMultiple && !searchTableHeader; + + return ( + + {tableHeaderVisible && ( + + )} + + {modal} + + ); +} + +export default TripSearchView; diff --git a/src/components/Search/hooks/useSearchColumnsToShow.ts b/src/components/Search/hooks/useSearchColumnsToShow.ts index 72ad560b39b2..608b0bc8a39c 100644 --- a/src/components/Search/hooks/useSearchColumnsToShow.ts +++ b/src/components/Search/hooks/useSearchColumnsToShow.ts @@ -5,6 +5,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsVendorColumnAvailable from '@hooks/useIsVendorColumnAvailable'; import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import {isDefaultExpensesQuery, queryHasViolationFilter} from '@libs/SearchQueryUtils'; import {getColumnsToShow, getValidGroupBy} from '@libs/SearchUIUtils'; @@ -31,6 +32,7 @@ function useSearchColumnsToShow(queryJSON: Readonly | undefined const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); const isVendorColumnAvailable = useIsVendorColumnAvailable(); const searchDataType = useSearchDataType(searchResults); + const shouldReturnTripRooms = useShouldReturnTripRooms(searchDataType); if (!searchResults?.data) { return EMPTY_COLUMNS; @@ -47,6 +49,7 @@ function useSearchColumnsToShow(queryJSON: Readonly | undefined sortBy: queryJSON?.sortBy, shouldShowViolationsColumn: queryHasViolationFilter(queryJSON), isVendorColumnAvailable, + shouldReturnTripRooms, }); } diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index e889874c1b1d..e0e5f317c3c5 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -12,6 +12,7 @@ import useOnyx from '@hooks/useOnyx'; import {useAllPersonalDetails} from '@hooks/usePersonalDetails'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportAttributes from '@hooks/useReportAttributes'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import {getSections, getSortedSections, getSortedTransactionData, getValidGroupBy, isSearchDataLoaded, isTransactionGroupListItemType} from '@libs/SearchUIUtils'; import {shouldShowAttendees} from '@libs/TransactionUtils'; @@ -123,6 +124,7 @@ const hashToString = (queryHash?: number) => (queryHash || queryHash === 0 ? Str */ function useSearchSnapshot({queryJSON, searchResults, transactions, reportActions, visibleRowLimit, selectedTransactions}: UseSearchSnapshotParams): SearchSnapshotResult { const {type, sortBy, sortOrder, hash, groupBy} = queryJSON; + const shouldReturnTripRooms = useShouldReturnTripRooms(type); const {isOffline} = useNetwork(); const {translate, localeCompare, formatPhoneNumber, dateFnsLocale} = useLocalize(); @@ -238,6 +240,7 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction convertToDisplayString, reportAttributesDerivedValue: reportAttributesForSections, optimisticTransactionID, + shouldReturnTripRooms, }); return { baseFilteredData: filtered as SearchListItem[], @@ -273,6 +276,7 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction reportAttributesForSections, optimisticTransactionID, dateFnsLocale, + shouldReturnTripRooms, ]); // Stage 2: for grouped views, fetch each group's sub-snapshot and enrich it with its transactions. @@ -347,18 +351,35 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction return EMPTY_DATA; } const sortInput = filteredData as Parameters[1]; - return getSortedSections(type, sortInput, localeCompare, translate, sortBy, sortOrder, validGroupBy, { + // Trip rows render through the same list as every other row, they're only kept out of SearchListItem because they can't be selected + const sortedData = getSortedSections(type, sortInput, localeCompare, translate, sortBy, sortOrder, validGroupBy, { policyCategories, policyTags, fallbackPolicyID: policyForMovingExpensesID, - }).map((item) => { + shouldReturnTripRooms, + }) as SearchListItem[]; + return sortedData.map((item) => { if (item.hash === hash) { return item; } return {...item, hash}; }); - }, [shouldComputeSections, type, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy, policyCategories, policyTags, policyForMovingExpensesID, hash]); + }, [ + shouldComputeSections, + type, + filteredData, + localeCompare, + translate, + sortBy, + sortOrder, + validGroupBy, + policyCategories, + policyTags, + policyForMovingExpensesID, + hash, + shouldReturnTripRooms, + ]); // Keep the optimistic row visible across a snapshot-replacement gap for up to // OPTIMISTIC_ROLLBACK_GRACE_MS until the new snapshot picks it up or the grace expires. diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 262d9cdacfca..57594fbf19d5 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -19,6 +19,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; import useSearchShouldCalculateTotals, {getSearchRequestOffsetForMissingAllMatchingCount} from '@hooks/useSearchShouldCalculateTotals'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -56,6 +57,7 @@ import { isSearchPending, isSearchResultsEmpty as isSearchResultsEmptyUtil, isTaskListItemType, + isTripListItemType, isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType, @@ -122,6 +124,7 @@ import {SearchScopeProvider} from './SearchScopeProvider'; import SearchTableHeader from './SearchTableHeader'; import SearchWriteActionsProvider from './SearchWriteActionsProvider'; import TaskSearchView from './TaskSearchView'; +import TripSearchView from './TripSearchView'; type SearchProps = { queryJSON: SearchQueryJSON; @@ -199,6 +202,7 @@ function Search({ const [nonPersonalAndWorkspaceCards] = useOnyx(ONYXKEYS.DERIVED.NON_PERSONAL_AND_WORKSPACE_CARD_LIST); const isExpenseReportType = type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const shouldReturnTripRooms = useShouldReturnTripRooms(type); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); // getSections only needs the boolean (it gates attendees on unreported transactions for the @@ -652,6 +656,15 @@ function Search({ return; } + if (isTripListItemType(item)) { + const tripRoute = ROUTES.SEARCH_REPORT.getRoute({reportID: item.reportID, backTo: Navigation.getActiveRoute()}); + if (openInternalRouteInNewTab(tripRoute, event)) { + return; + } + requestAnimationFrame(() => Navigation.navigate(tripRoute)); + return; + } + const isTransactionItem = isTransactionListItemType(item); const backTo = Navigation.getActiveRoute(); @@ -946,7 +959,7 @@ function Search({ const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; const isTask = type === CONST.SEARCH.DATA_TYPES.TASK; - const canSelectMultiple = !isChat && !isTask && (!isSmallScreenWidth || isMobileSelectionModeEnabled); + const canSelectMultiple = !isChat && !isTask && !shouldReturnTripRooms && (!isSmallScreenWidth || isMobileSelectionModeEnabled); useSaveSortedReportIDs(type, sortedData); @@ -1459,10 +1472,10 @@ function Search({ ); } - // Transaction lists (expense, invoice, trip) render through the flat or grouped view depending on groupBy; - // chat, expense-report and task each have their own dedicated view. Every view composes BaseSearchList - // directly, and the snapshot, lifecycle and selection providers stay here so the data layer runs once. - const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + // Transaction lists (expense, invoice, and trip without the TravelApproval beta) render through the flat or grouped view + // depending on groupBy; chat, expense-report, task and trip rooms each have their own dedicated view. Every view composes + // BaseSearchList directly, and the snapshot, lifecycle and selection providers stay here so the data layer runs once. + const isTransactionListView = type !== CONST.SEARCH.DATA_TYPES.CHAT && type !== CONST.SEARCH.DATA_TYPES.TASK && type !== CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && !shouldReturnTripRooms; let searchTablePaddingRightStyle; if (!isTask) { @@ -1488,7 +1501,7 @@ function Search({ isTaxAmountColumnWide={shouldShowTaxAmountInWideColumn} shouldShowSorting groupBy={validGroupBy} - isExpenseReportView={isExpenseReportType} + isExpenseReportView={isExpenseReportType || shouldReturnTripRooms} isActionColumnWide={isTask || hasDeletedTransaction} /> @@ -1564,6 +1577,8 @@ function Search({ searchListContent = ; } else if (isExpenseReportType) { searchListContent = ; + } else if (shouldReturnTripRooms) { + searchListContent = ; } else { // TASK is the only remaining data type. searchListContent = ; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 5ddf99a3576c..5f37822d83e6 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -184,6 +184,7 @@ type SyntaxFilterKey = ValueOf; type SearchCustomColumnIds = | ValueOf | ValueOf + | ValueOf | ValueOf | ValueOf | ValueOf diff --git a/src/hooks/useSearchAutoRefetch.ts b/src/hooks/useSearchAutoRefetch.ts index 7d702d11c331..80a56edabe33 100644 --- a/src/hooks/useSearchAutoRefetch.ts +++ b/src/hooks/useSearchAutoRefetch.ts @@ -17,6 +17,7 @@ import {useIsFocused} from '@react-navigation/native'; import {useEffect, useRef} from 'react'; import useNetwork from './useNetwork'; +import useShouldReturnTripRooms from './useShouldReturnTripRooms'; type UseSearchAutoRefetch = { /** Current Search snapshot */ @@ -67,6 +68,7 @@ function useSearchAutoRefetch({ const searchTriggeredRef = useRef(false); const hasPendingSearchRef = useRef(false); const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT; + const shouldReturnTripRooms = useShouldReturnTripRooms(queryJSON.type); const searchResultsData = searchResults?.data; @@ -83,6 +85,11 @@ function useSearchAutoRefetch({ // Trigger search when a new report action is added while on chat or when a new transaction is added for the other search types. useEffect(() => { + // Trip rows don't change when transactions or report actions do + if (shouldReturnTripRooms) { + return; + } + // Snapshot and focus updates re-run this effect with unchanged collections, which would diff a collection against itself. if (transactions === previousTransactions && reportActions === previousReportActions && !hasPendingSearchRef.current) { return; @@ -184,6 +191,7 @@ function useSearchAutoRefetch({ searchResultsData, isOffline, searchResults?.search?.isLoading, + shouldReturnTripRooms, ]); useEffect(() => { diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index 429e55e91c19..e4cec8f0dd34 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -15,6 +15,7 @@ import {useCallback, useEffect, useRef} from 'react'; import useNetwork from './useNetwork'; import usePrevious from './usePrevious'; import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; +import useShouldReturnTripRooms from './useShouldReturnTripRooms'; // Gates the save below to real hash changes so snapshot-loading re-fires don't wipe fields // (hasMoreResults, previousLengthOfResults) maintained by report-browsing callers. @@ -44,6 +45,7 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { // Without this, a plain page-level fetch during active select-all clears totals on arrival // (shouldClearTotals in getOnyxLoadingData), wiping a total the Search-internal effect already fetched. const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true, areAllMatchingItemsSelected); + const shouldReturnTripRooms = useShouldReturnTripRooms(queryJSON?.type); // 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(). @@ -105,8 +107,28 @@ function useSearchPageSetup(queryJSON: Readonly | undefined) { if (!isSnapshotDataLoaded) { markPageRequestedSearch(hash, shouldCalculateTotals); } - search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals, isLoading: false, skipWaitForWrites: shouldSkipWaitForWrites, shouldSaveRecentSearch: true}); - }, [hash, isOffline, shouldUseLiveData, queryJSON, isSnapshotDataLoaded, isSnapshotSearchLoading, isInitialSearchPending, currentSearchKey, shouldCalculateTotals]); + search({ + queryJSON, + searchKey: currentSearchKey, + offset: 0, + shouldCalculateTotals, + isLoading: false, + skipWaitForWrites: shouldSkipWaitForWrites, + shouldSaveRecentSearch: true, + shouldReturnTripRooms, + }); + }, [ + hash, + isOffline, + shouldUseLiveData, + queryJSON, + isSnapshotDataLoaded, + isSnapshotSearchLoading, + isInitialSearchPending, + currentSearchKey, + shouldCalculateTotals, + shouldReturnTripRooms, + ]); useFocusEffect(dropPageRequestOnBlur); diff --git a/src/hooks/useShouldReturnTripRooms.ts b/src/hooks/useShouldReturnTripRooms.ts new file mode 100644 index 000000000000..aaa3e94935d4 --- /dev/null +++ b/src/hooks/useShouldReturnTripRooms.ts @@ -0,0 +1,15 @@ +import CONST from '@src/CONST'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; + +import usePermissions from './usePermissions'; + +/** + * Whether a search of this type returns one row per trip room. Trip searches only do that with the TravelApproval beta, + * and stay expense searches without it. + */ +function useShouldReturnTripRooms(type: SearchDataTypes | undefined): boolean { + const {isBetaEnabled} = usePermissions(); + return type === CONST.SEARCH.DATA_TYPES.TRIP && isBetaEnabled(CONST.BETAS.TRAVEL_APPROVAL); +} + +export default useShouldReturnTripRooms; diff --git a/src/languages/de.ts b/src/languages/de.ts index 67650f3a7d20..767bdd86141d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -10454,6 +10454,15 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc pleaseSelectDatesForBothFromAndTo: 'Bitte wähle Daten für Von und Bis', }, spendOverTime: 'Ausgaben im Zeitverlauf', + trip: { + tripName: 'Reisename', + traveler: 'Reisende*r', + status: { + upcoming: 'Bevorstehend', + done: 'Abgeschlossen', + canceled: 'Storniert', + }, + }, tabs: { expenseReports: 'Spesenabrechnungen', reports: 'Berichte', diff --git a/src/languages/el.ts b/src/languages/el.ts index 27187504d004..3e7a4465d83d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10351,6 +10351,15 @@ ${reportName}`, screenShareRequest: 'Η Expensify σάς προσκαλεί σε κοινή χρήση οθόνης', }, search: { + trip: { + tripName: 'Όνομα ταξιδιού', + traveler: 'Ταξιδιώτης', + status: { + upcoming: 'Επερχόμενο', + done: 'Ολοκληρώθηκε', + canceled: 'Ακυρώθηκε', + }, + }, tabs: { expenseReports: 'Αναφορές εξόδων', reports: 'Αναφορές', diff --git a/src/languages/en.ts b/src/languages/en.ts index 5954259b901f..8979bb99db3b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10369,6 +10369,15 @@ const translations = { screenShareRequest: 'Expensify is inviting you to a screen share', }, search: { + trip: { + tripName: 'Trip name', + traveler: 'Traveler', + status: { + upcoming: 'Upcoming', + done: 'Done', + canceled: 'Canceled', + }, + }, tabs: { expenseReports: 'Expense reports', reports: 'Reports', diff --git a/src/languages/es.ts b/src/languages/es.ts index 765d13cf6612..fba48e1bca03 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9978,6 +9978,15 @@ ${reportName}`, screenShareRequest: 'Expensify te está invitando a compartir la pantalla', }, search: { + trip: { + tripName: 'Nombre del viaje', + traveler: 'Viajero', + status: { + upcoming: 'Próximo', + done: 'Finalizado', + canceled: 'Cancelado', + }, + }, tabs: { expenseReports: 'Informes de gastos', reports: 'Informes', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 33177d6fd898..4c4eec025f2c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10480,6 +10480,15 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e pleaseSelectDatesForBothFromAndTo: 'Veuillez sélectionner des dates pour De et À', }, spendOverTime: 'Dépenses dans le temps', + trip: { + tripName: 'Nom du voyage', + traveler: 'Voyageur', + status: { + upcoming: 'À venir', + done: 'Terminé', + canceled: 'Annulé', + }, + }, tabs: { expenseReports: 'Notes de frais', reports: 'Notes de frais', diff --git a/src/languages/it.ts b/src/languages/it.ts index 7e15e63a728c..209ac200bdb6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10402,6 +10402,15 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, pleaseSelectDatesForBothFromAndTo: 'Seleziona le date per Da e A', }, spendOverTime: 'Spesa nel tempo', + trip: { + tripName: 'Nome del viaggio', + traveler: 'Viaggiatore', + status: { + upcoming: 'In arrivo', + done: 'Completato', + canceled: 'Annullato', + }, + }, tabs: { expenseReports: 'Note spese', reports: 'Report', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 99d48835b98d..c05dc40028a8 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10249,6 +10249,15 @@ ${reportName}`, pleaseSelectDatesForBothFromAndTo: '開始日と終了日の両方を選択してください', }, spendOverTime: '時間経過による支出', + trip: { + tripName: '出張名', + traveler: '出張者', + status: { + upcoming: '予定', + done: '完了', + canceled: 'キャンセル済み', + }, + }, tabs: { expenseReports: '経費精算書', reports: 'レポート', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fe382b30c47f..4618f450ae18 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10372,6 +10372,15 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, [CONST.SEARCH.GROUP_BY.QUARTER]: 'Kwartalen', }, spendOverTime: 'Uitgaven in de tijd', + trip: { + tripName: 'Reisnaam', + traveler: 'Reiziger', + status: { + upcoming: 'Gepland', + done: 'Afgerond', + canceled: 'Geannuleerd', + }, + }, tabs: { expenseReports: 'Declaraties', reports: 'Rapporten', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index f1e4a58c3fe0..4c661a4734a2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -10388,6 +10388,15 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, pleaseSelectDatesForBothFromAndTo: 'Wybierz daty dla Od i Do', }, spendOverTime: 'Wydatki w czasie', + trip: { + tripName: 'Nazwa podróży', + traveler: 'Podróżny', + status: { + upcoming: 'Nadchodząca', + done: 'Zakończona', + canceled: 'Anulowana', + }, + }, tabs: { expenseReports: 'Raporty wydatków', reports: 'Raporty', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 31af9f7655c7..719215237070 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10369,6 +10369,15 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, pleaseSelectDatesForBothFromAndTo: 'Selecione datas para De e Até', }, spendOverTime: 'Gastos ao longo do tempo', + trip: { + tripName: 'Nome da viagem', + traveler: 'Viajante', + status: { + upcoming: 'Próxima', + done: 'Concluída', + canceled: 'Cancelada', + }, + }, tabs: { expenseReports: 'Relatórios de despesas', reports: 'Relatórios', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 51cc5ea8649e..2c7428aad464 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9974,6 +9974,15 @@ ${reportName}`, pleaseSelectDatesForBothFromAndTo: '请选择起始和结束日期', }, spendOverTime: '随时间支出', + trip: { + tripName: '行程名称', + traveler: '出行人', + status: { + upcoming: '即将开始', + done: '已完成', + canceled: '已取消', + }, + }, tabs: { expenseReports: '报销报告', reports: '报表', diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 82d37e136d75..7bdaaff3d286 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -27,6 +27,7 @@ import type { TransactionWeekGroupListItemType, TransactionWithdrawalIDGroupListItemType, TransactionYearGroupListItemType, + TripListItemType, } from '@components/Search/SearchList/ListItem/types'; import {GROUP_ITEM_TYPES} from '@components/Search/SearchList/ListItem/types'; import type { @@ -88,6 +89,7 @@ import type { SearchTagGroup, SearchTask, SearchTransactionAction, + SearchTrip, SearchWeekGroup, SearchWithdrawalIDGroup, SearchYearGroup, @@ -256,6 +258,8 @@ type PolicyCategoriesLookup = OnyxEntry | OnyxCollec type PolicyTagsLookup = OnyxEntry | OnyxCollection; type SortSectionsOptions = { + /** Whether a trip search returns trip rooms, which it only does with the TravelApproval beta */ + shouldReturnTripRooms?: boolean; policyCategories?: PolicyCategoriesLookup; policyTags?: PolicyTagsLookup; fallbackPolicyID?: string; @@ -269,6 +273,7 @@ type GroupBySection = { type TransactionSorting = ColumnSortMapping; type TaskSorting = ColumnSortMapping; type ExpenseReportSorting = ColumnSortMapping; +type TripSorting = ColumnSortMapping; // Transaction Group sorting type TransactionMemberGroupSorting = ColumnSortMapping; @@ -352,6 +357,16 @@ const taskColumnNamesToSortingProperty: TaskSorting = { [CONST.SEARCH.TABLE_COLUMNS.IN]: 'parentReportID' as const, }; +const tripColumnNamesToSortingProperty: TripSorting = { + [CONST.SEARCH.TABLE_COLUMNS.DATE]: 'startDate' as const, + [CONST.SEARCH.TABLE_COLUMNS.START_DATE]: 'startDate' as const, + [CONST.SEARCH.TABLE_COLUMNS.END_DATE]: 'endDate' as const, + [CONST.SEARCH.TABLE_COLUMNS.STATUS]: 'status' as const, + [CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME]: 'tripName' as const, + [CONST.SEARCH.TABLE_COLUMNS.TRAVELER]: 'formattedTraveler' as const, + [CONST.SEARCH.TABLE_COLUMNS.TOTAL]: 'total' as const, +}; + const expenseReportColumnNamesToSortingProperty: ExpenseReportSorting = { [CONST.SEARCH.TABLE_COLUMNS.AVATAR]: null, [CONST.SEARCH.TABLE_COLUMNS.DATE]: 'created' as const, @@ -465,6 +480,11 @@ const nonSortableColumns = new Set([ CONST.SEARCH.TABLE_COLUMNS.IN, CONST.SEARCH.TABLE_COLUMNS.AVATAR, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS, + // The trip search doesn't sort by these yet + CONST.SEARCH.TABLE_COLUMNS.START_DATE, + CONST.SEARCH.TABLE_COLUMNS.END_DATE, + CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME, + CONST.SEARCH.TABLE_COLUMNS.TRAVELER, ]); function isValidTaskStatus(status: unknown): status is ValueOf { @@ -605,6 +625,7 @@ type GetSectionsResult = [ | TransactionWeekGroupListItemType[] | TransactionYearGroupListItemType[] | TransactionQuarterGroupListItemType[] + | TripListItemType[] ), number, boolean, @@ -643,6 +664,8 @@ type GetSectionsParams = { * branch, remove that gating in the caller first, otherwise it will silently be `undefined`. */ reportAttributesDerivedValue: OnyxTypes.ReportAttributesDerivedValue['reports'] | undefined; + /** Whether a trip search returns trip rooms, which it only does with the TravelApproval beta */ + shouldReturnTripRooms?: boolean; }; /** @@ -996,6 +1019,13 @@ function isTaskListItemType(item: SearchListItem): item is TaskListItemType { return 'type' in item && item.type === CONST.REPORT.TYPE.TASK; } +/** + * Type guard that checks if something is a TripListItemType. Trip rows aren't part of SearchListItem since they can't be selected. + */ +function isTripListItemType(item: SearchListItem | TripListItemType): item is TripListItemType { + return 'chatType' in item && item.chatType === CONST.REPORT.CHAT_TYPE.TRIP_ROOM && 'travelerAccountID' in item; +} + /** * Type guard that checks if something is a ReportActionListItemType */ @@ -2370,6 +2400,31 @@ function getRowCapabilities(allActions: SearchTransactionAction[]): RowCapabilit * * Do not use directly, use only via `getSections()` facade. */ +/** + * @private + * Organizes trip rooms from a trip search into one row per trip + */ +function getTripSections(data: OnyxTypes.SearchResults['data'], formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocalizedTranslate): [TripListItemType[], number] { + const trips = Object.keys(data) + .filter(isReportEntry) + .map((key) => data[key] as SearchTrip) + .filter((trip) => trip?.chatType === CONST.REPORT.CHAT_TYPE.TRIP_ROOM) + .map((trip) => { + // Trip rooms have no owner, so the traveler comes from travelerAccountID + const traveler = data.personalDetailsList?.[trip.travelerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? emptyPersonalDetails; + + return { + ...trip, + tripName: StringUtils.lineBreaksToSpaces(Parser.htmlToText(trip.reportName ?? '')), + traveler, + formattedTraveler: temporaryGetDisplayNameOrDefault({passedPersonalDetails: traveler, translate, formatPhoneNumber}), + keyForList: trip.reportID, + }; + }); + + return [trips, trips.length]; +} + function getTaskSections( data: OnyxTypes.SearchResults['data'], formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], @@ -3751,6 +3806,7 @@ function getSections({ convertToDisplayString, optimisticTransactionID, reportAttributesDerivedValue, + shouldReturnTripRooms = false, }: GetSectionsParams): GetSectionsResult { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return [...getReportActionsSections(data, reportAttributesDerivedValue, visibleReportActionsData), false]; @@ -3758,6 +3814,9 @@ function getSections({ if (type === CONST.SEARCH.DATA_TYPES.TASK) { return [...getTaskSections(data, formatPhoneNumber, translate, conciergeReportID, reportNameValuePairs, reportAttributesDerivedValue, queryJSON), false]; } + if (type === CONST.SEARCH.DATA_TYPES.TRIP && shouldReturnTripRooms) { + return [...getTripSections(data, formatPhoneNumber, translate), false]; + } if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { return getReportSections({ @@ -3901,6 +3960,9 @@ function getSortedSections( if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { return getSortedReportData(data as TransactionReportGroupListItemType[], localeCompare, translate, sortBy, sortOrder); } + if (type === CONST.SEARCH.DATA_TYPES.TRIP && options?.shouldReturnTripRooms) { + return getSortedTripData(data as TripListItemType[], localeCompare, sortBy, sortOrder); + } if (groupBy) { const sortFunction = groupBySortFunction[groupBy]; @@ -4200,6 +4262,19 @@ function getSortedTaskData(data: TaskListItemType[], localeCompare: LocaleContex }); } +/** + * @private + * Sorts trip rows based on a specified column and sort order. + */ +function getSortedTripData(data: TripListItemType[], localeCompare: LocaleContextProps['localeCompare'], sortBy?: SearchSortBy, sortOrder?: SortOrder) { + const sortingProperty = sortBy ? tripColumnNamesToSortingProperty[sortBy as SearchColumnType] : undefined; + if (!sortingProperty || !sortOrder) { + return data; + } + + return data.sort((a, b) => compareValues(a[sortingProperty], b[sortingProperty], sortOrder, sortingProperty, localeCompare)); +} + /** * @private * Sorts report sections based on a specified column and sort order. @@ -4627,6 +4702,14 @@ function getSearchColumnTranslationKey(column: SearchSortBy, type?: SearchDataTy return 'common.categoryGLCode'; case CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE: return 'common.tagGLCode'; + case CONST.SEARCH.TABLE_COLUMNS.START_DATE: + return 'iou.startDate'; + case CONST.SEARCH.TABLE_COLUMNS.END_DATE: + return 'iou.endDate'; + case CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME: + return 'search.trip.tripName'; + case CONST.SEARCH.TABLE_COLUMNS.TRAVELER: + return 'search.trip.traveler'; default: // This should never happen, but TypeScript requires a default case return 'common.expenses' as TranslationPaths; @@ -6521,6 +6604,7 @@ function getColumnsToShow({ sortBy, shouldShowViolationsColumn = false, isVendorColumnAvailable = true, + shouldReturnTripRooms = false, }: { currentAccountID: number | undefined; data: OnyxTypes.SearchResults['data'] | OnyxTypes.Transaction[]; @@ -6540,6 +6624,7 @@ function getColumnsToShow({ sortBy?: SearchSortBy; shouldShowViolationsColumn?: boolean; isVendorColumnAvailable?: boolean; + shouldReturnTripRooms?: boolean; }): SearchColumnType[] { const reportCustomColumns = new Set([ CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_USER_ID, @@ -6620,6 +6705,17 @@ function getColumnsToShow({ }); } + if (type === CONST.SEARCH.DATA_TYPES.TRIP && shouldReturnTripRooms) { + const tripCustomColumns = new Set(Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.TRIP)); + const filteredVisibleColumns = visibleColumns.filter((column) => tripCustomColumns.has(column)); + if (!filteredVisibleColumns.length) { + return [...CONST.SEARCH.TYPE_DEFAULT_COLUMNS.TRIP]; + } + + // Like expense reports, the total always shows so the table keeps a flexible column + return filteredVisibleColumns.includes(CONST.SEARCH.TABLE_COLUMNS.TOTAL) ? filteredVisibleColumns : [CONST.SEARCH.TABLE_COLUMNS.TOTAL, ...filteredVisibleColumns]; + } + if (type === CONST.SEARCH.DATA_TYPES.TASK) { return [ CONST.SEARCH.TABLE_COLUMNS.DATE, @@ -7171,7 +7267,9 @@ function getTableMinWidth( column === CONST.SEARCH.TABLE_COLUMNS.APPROVED || column === CONST.SEARCH.TABLE_COLUMNS.POSTED || column === CONST.SEARCH.TABLE_COLUMNS.WITHDRAWN || - column === CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN + column === CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN || + column === CONST.SEARCH.TABLE_COLUMNS.START_DATE || + column === CONST.SEARCH.TABLE_COLUMNS.END_DATE ) { minWidth += 72; } else if (column === CONST.SEARCH.TABLE_COLUMNS.TYPE) { @@ -7489,6 +7587,7 @@ export { isCorrectSearchUserName, isReportActionEntry, isTaskListItemType, + isTripListItemType, getActions, getPrimaryAction, createTypeMenuSections, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 3573a2d02e64..1aec5c241405 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1299,6 +1299,7 @@ function search({ skipWaitForWrites = false, shouldSaveRecentSearch = false, shouldShowLoading = true, + shouldReturnTripRooms = false, }: { queryJSON: Readonly; searchKey: SearchKey | undefined; @@ -1324,6 +1325,8 @@ function search({ skipWaitForWrites?: boolean; /** When false, the request leaves `search.isLoading` unset, so the current results stay on screen without a loading state. */ shouldShowLoading?: boolean; + /** Makes a trip search return one row per trip room instead of travel expenses. Only sent with the TravelApproval beta. */ + shouldReturnTripRooms?: boolean; }): Promise | undefined { if (isLoading || shouldPreventSearchAPI) { return; @@ -1362,6 +1365,7 @@ function search({ // A re-fire that only adds the save flag returns the same results the finished request just showed, // so it must not show loading again (the footer would flash its skeleton over the totals). shouldShowLoading: !inFlightRequest.didSucceed || inFlightRequest.pendingShouldCalculateTotals !== inFlightRequest.shouldCalculateTotals, + shouldReturnTripRooms, }); } return needsBreakdownRefire ? inFlightRequest.promise : undefined; @@ -1380,6 +1384,7 @@ function search({ shouldSaveRecentSearch, // Backend expects 'maximumResults' instead of 'limit' ...(limit !== undefined && {maximumResults: limit}), + ...(shouldReturnTripRooms && queryJSON.type === CONST.SEARCH.DATA_TYPES.TRIP && {shouldReturnTripRooms: true}), }; const jsonQuery = serializeQueryJSONForBackend(query); diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index 01703b4be243..8535b7591921 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -7,6 +7,7 @@ import type {SearchCustomColumnIds} from '@components/Search/types'; import useIsVendorColumnAvailable from '@hooks/useIsVendorColumnAvailable'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import Navigation from '@libs/Navigation/Navigation'; import {buildQueryStringFromFilterFormValues, getCurrentSearchQueryJSON, hasValuesIncludeViolationFilter} from '@libs/SearchQueryUtils'; @@ -36,10 +37,13 @@ function SearchColumnsPage() { const isColumnAvailable = (column: SearchCustomColumnIds) => (shouldRequireViolationsColumn || column !== CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS) && (isVendorColumnAvailable || column !== CONST.SEARCH.TABLE_COLUMNS.VENDOR); - const allTypeCustomColumns = getCustomColumns(queryType).filter(isColumnAvailable); + // Without the TravelApproval beta a trip search is an expense search with no trip columns to pick + const shouldReturnTripRooms = useShouldReturnTripRooms(queryType); + const hasNoTypeCustomColumns = queryType === CONST.SEARCH.DATA_TYPES.TRIP && !shouldReturnTripRooms; + const allTypeCustomColumns = hasNoTypeCustomColumns ? [] : getCustomColumns(queryType).filter(isColumnAvailable); const allGroupCustomColumns = getCustomColumns(groupBy); const defaultGroupCustomColumns = getCustomColumnDefault(groupBy); - const defaultTypeCustomColumns = [...getCustomColumnDefault(queryType)]; + const defaultTypeCustomColumns = hasNoTypeCustomColumns ? [] : [...getCustomColumnDefault(queryType)]; const savedColumns = [...(searchAdvancedFiltersForm?.columns ?? [])].filter(isColumnAvailable); // We need at least one element with flex1 in the table to ensure the table looks good in the UI, so we don't allow removing the total columns diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 0b56b4ff15a8..941bda67d61a 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -10,6 +10,7 @@ import useHasTabBeenShown from '@hooks/useHasTabBeenShown'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import {PaymentContextProvider} from '@hooks/usePaymentContext'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useReleaseOptionListCaches from '@hooks/useReleaseOptionListCaches'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -151,13 +152,18 @@ function SearchPage({route}: SearchPageProps) { const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; - const handleSearchAction = useCallback((value: SearchParams | string) => { - if (typeof value === 'string') { - searchInServer(value); - } else { - search(value); - } - }, []); + const {isBetaEnabled} = usePermissions(); + const isTravelApprovalEnabled = isBetaEnabled(CONST.BETAS.TRAVEL_APPROVAL); + const handleSearchAction = useCallback( + (value: SearchParams | string) => { + if (typeof value === 'string') { + searchInServer(value); + } else { + search({...value, shouldReturnTripRooms: isTravelApprovalEnabled}); + } + }, + [isTravelApprovalEnabled], + ); const onSortPressedCallback = useCallback(() => { setIsSorting(true); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 94c01f0a2590..e696f7e49dee 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -24,6 +24,7 @@ import {useLoadingBarVisibility} from '@hooks/useInFlightRequests'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useSearchLoadingState from '@hooks/useSearchLoadingState'; @@ -173,13 +174,18 @@ function SearchPageNarrow({ const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES})); - const handleSearchAction = useCallback((value: SearchParams | string) => { - if (typeof value === 'string') { - searchInServer(value); - } else { - search(value); - } - }, []); + const {isBetaEnabled} = usePermissions(); + const isTravelApprovalEnabled = isBetaEnabled(CONST.BETAS.TRAVEL_APPROVAL); + const handleSearchAction = useCallback( + (value: SearchParams | string) => { + if (typeof value === 'string') { + searchInServer(value); + } else { + search({...value, shouldReturnTripRooms: isTravelApprovalEnabled}); + } + }, + [isTravelApprovalEnabled], + ); const navigation = useNavigation(); // When pre-inserted behind the RHP (not focused), always start in static rendering diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index c67a6f25b9ee..c423beaf4bb5 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2063,6 +2063,10 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ }; break; } + case CONST.SEARCH.TABLE_COLUMNS.START_DATE: + case CONST.SEARCH.TABLE_COLUMNS.END_DATE: + columnWidth = {...getWidthStyle(variables.w102)}; + break; case CONST.SEARCH.TABLE_COLUMNS.WITHDRAWN: case CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN: columnWidth = { diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index f92f71490bc6..95a9ac0c053d 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -4,6 +4,7 @@ import type { TransactionGroupListItemType, TransactionListItemType, TransactionReportGroupListItemType, + TripListItemType, } from '@components/Search/SearchList/ListItem/types'; import type {SearchFooterTotal, SearchGroupBy, SearchSortBy, SortOrder} from '@components/Search/types'; @@ -34,9 +35,11 @@ type ListItemDataType; +/** Model of a trip room returned by a trip search sent with shouldReturnTripRooms */ +type SearchTrip = Report & { + /** When the trip starts */ + startDate?: string; + + /** When the trip ends */ + endDate?: string; + + /** The trip's status, worked out by the backend */ + status?: ValueOf; + + /** The account ID of the traveler, who created the trip room */ + travelerAccountID?: number; +}; + /** Model of tasks search result */ type SearchTask = { /** The type of the response */ @@ -326,6 +344,7 @@ export default SearchResults; export type { ListItemDataType, SearchTask, + SearchTrip, SearchTransactionAction, SearchDataTypes, SearchResultsInfo, diff --git a/tests/ui/TripListItemRowTest.tsx b/tests/ui/TripListItemRowTest.tsx new file mode 100644 index 000000000000..e4c4cd617669 --- /dev/null +++ b/tests/ui/TripListItemRowTest.tsx @@ -0,0 +1,130 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import TripListItemRow from '@components/Search/SearchList/ListItem/TripListItemRow'; +import type {TripListItemType} from '@components/Search/SearchList/ListItem/types'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayout); + +const mockLayout = (isLargeScreenWidth: boolean) => + mockedUseResponsiveLayout.mockReturnValue({ + isLargeScreenWidth, + shouldUseNarrowLayout: !isLargeScreenWidth, + isSmallScreenWidth: !isLargeScreenWidth, + isMediumScreenWidth: false, + isExtraSmallScreenWidth: false, + isExtraSmallScreenHeight: false, + isExtraLargeScreenWidth: false, + isSmallScreen: !isLargeScreenWidth, + isInNarrowPaneModal: false, + onboardingIsMediumOrLargerScreenWidth: isLargeScreenWidth, + isInLandscapeMode: false, + }); + +// A trip room shaped like the trip search response +const tripItem: TripListItemType = { + reportID: '2816018005195895', + keyForList: '2816018005195895', + reportName: "Brandon's trip to Tokyo Sales Conference", + tripName: "Brandon's trip to Tokyo Sales Conference", + chatType: CONST.REPORT.CHAT_TYPE.TRIP_ROOM, + type: CONST.REPORT.TYPE.CHAT, + policyID: 'DD949BDE0E77A392', + startDate: '2026-10-28T00:00:00', + endDate: '2026-11-01T00:00:00', + status: CONST.SEARCH.TRIP_STATUS.UPCOMING, + total: 159732, + currency: 'USD', + travelerAccountID: 629, + traveler: {accountID: 629, displayName: 'Travel Employee', login: 'travel-employee@cplustest.com'}, + formattedTraveler: 'Travel Employee', +}; + +const renderRow = (onViewPress = jest.fn(), item: TripListItemType = tripItem) => + render( + + + , + ); + +describe('TripListItemRow', () => { + beforeAll(() => Onyx.init({keys: ONYXKEYS})); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + it('shows every trip column and opens the trip from View on wide screens', async () => { + // Given a wide layout, where the trip row is a table row + mockLayout(true); + const onViewPress = jest.fn(); + + // When the row renders a trip from the trip search + renderRow(onViewPress); + await waitForBatchedUpdatesWithAct(); + + // Then it shows the start and end dates, the status badge, the trip name, the traveler and the total + expect(screen.getByText('Oct 28')).toBeOnTheScreen(); + expect(screen.getByText('Nov 1')).toBeOnTheScreen(); + expect(screen.getByText('Upcoming')).toBeOnTheScreen(); + expect(screen.getByText("Brandon's trip to Tokyo Sales Conference")).toBeOnTheScreen(); + expect(screen.getByText('Travel Employee')).toBeOnTheScreen(); + expect(screen.getByText('$1,597.32')).toBeOnTheScreen(); + + // And View opens the trip, since View and tapping the row both open the trip room + fireEvent.press(screen.getByText('View')); + expect(onViewPress).toHaveBeenCalledTimes(1); + }); + + it('shows a canceled trip with its own badge', async () => { + // Given a canceled trip, since the badge follows the status the backend works out + mockLayout(true); + + // When the row renders it + renderRow(jest.fn(), {...tripItem, status: CONST.SEARCH.TRIP_STATUS.CANCELED}); + await waitForBatchedUpdatesWithAct(); + + // Then the badge reads Canceled + expect(screen.getByText('Canceled')).toBeOnTheScreen(); + }); + + it('shows the same trip data on narrow screens', async () => { + // Given a narrow layout, where the row is a card without columns or a View button + mockLayout(false); + + // When the row renders a trip + renderRow(); + await waitForBatchedUpdatesWithAct(); + + // Then the card still shows the dates, status, trip name, traveler and total + expect(screen.getByText('Oct 28')).toBeOnTheScreen(); + expect(screen.getByText('Nov 1')).toBeOnTheScreen(); + expect(screen.getByText('Upcoming')).toBeOnTheScreen(); + expect(screen.getByText("Brandon's trip to Tokyo Sales Conference")).toBeOnTheScreen(); + expect(screen.getByText('Travel Employee')).toBeOnTheScreen(); + expect(screen.getByText('$1,597.32')).toBeOnTheScreen(); + expect(screen.queryByText('View')).not.toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/Search/ColumnAvailabilityTest.ts b/tests/unit/Search/ColumnAvailabilityTest.ts index fa2b76501de2..120e0eec2d15 100644 --- a/tests/unit/Search/ColumnAvailabilityTest.ts +++ b/tests/unit/Search/ColumnAvailabilityTest.ts @@ -26,6 +26,11 @@ describe('Column availability single source of truth', () => { TABLE_COLUMNS.COMMENTS, // Task search view. TABLE_COLUMNS.ASSIGNEE, + // Trip search view. + TABLE_COLUMNS.START_DATE, + TABLE_COLUMNS.END_DATE, + TABLE_COLUMNS.TRIP_NAME, + TABLE_COLUMNS.TRAVELER, TABLE_COLUMNS.IN, // Expense-report (report list) view. TABLE_COLUMNS.REIMBURSABLE_TOTAL, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index cf4563632a3c..b486ac81267a 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -43,7 +43,7 @@ import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; import type * as OnyxTypes from '@src/types/onyx'; import type {CustomCardFeedData} from '@src/types/onyx/CardFeeds'; import type {Connections} from '@src/types/onyx/Policy'; -import type {SearchTask} from '@src/types/onyx/SearchResults'; +import type {SearchTask, SearchTrip} from '@src/types/onyx/SearchResults'; import type {WorkspaceTravelSettings} from '@src/types/onyx/TravelSettings'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -15627,3 +15627,122 @@ describe('getDatePresets', () => { expect(presets).not.toContain(CONST.SEARCH.DATE_PRESETS.NEVER); }); }); + +describe('Trip search', () => { + const buildTrip = (reportID: string, overrides: Partial = {}): SearchTrip => ({ + reportID, + reportName: `Trip ${reportID}`, + chatType: CONST.REPORT.CHAT_TYPE.TRIP_ROOM, + type: CONST.REPORT.TYPE.CHAT, + policyID: 'DD949BDE0E77A392', + ownerAccountID: 0, + startDate: '2026-10-28T00:00:00', + endDate: '2026-11-01T00:00:00', + status: CONST.SEARCH.TRIP_STATUS.UPCOMING, + total: 159732, + currency: 'USD', + travelerAccountID: 629, + ...overrides, + }); + + // Shaped like the trip search response: trip rooms only, with the traveler in personalDetailsList + const tripData = { + personalDetailsList: { + 628: {accountID: 628, displayName: 'Travel Admin', login: 'travel-admin@cplustest.com'}, + 629: {accountID: 629, displayName: 'Travel Employee', login: 'travel-employee@cplustest.com'}, + }, + report_1: buildTrip('1', {reportName: 'Tokyo Sales Conference', startDate: '2026-10-28T00:00:00'}), + report_2: buildTrip('2', {reportName: 'Sales Conf Vegas', startDate: '2026-08-12T00:00:00', status: CONST.SEARCH.TRIP_STATUS.DONE, travelerAccountID: 628}), + report_3: buildTrip('3', {reportName: 'Oslo React Meetup', startDate: '2026-11-04T00:00:00'}), + } as unknown as OnyxTypes.SearchResults['data']; + + const getTripSections = (shouldReturnTripRooms: boolean) => + SearchUIUtils.getSections({ + dateFnsLocale: undefined, + type: CONST.SEARCH.DATA_TYPES.TRIP, + data: tripData, + currentAccountID: 628, + currentUserEmail: 'travel-admin@cplustest.com', + translate: translateLocal, + formatPhoneNumber, + bankAccountList: {}, + rules: undefined, + conciergeReportID: undefined, + convertToDisplayString, + reportAttributesDerivedValue: undefined, + shouldReturnTripRooms, + }); + + it('builds one row per trip room with its traveler', () => { + // Given a trip search with the TravelApproval beta, which returns trip rooms + // When the search results are turned into rows + const [rows, count] = getSectionsByType(getTripSections(true), SearchUIUtils.isTripListItemType); + + // Then each trip room is a row keyed by its room, named after it, with the traveler from travelerAccountID since trip rooms have no owner + expect(count).toBe(3); + const tokyo = rows.find((row) => row.reportID === '1'); + expect(tokyo).toEqual( + expect.objectContaining({keyForList: '1', tripName: 'Tokyo Sales Conference', formattedTraveler: 'Travel Employee', status: CONST.SEARCH.TRIP_STATUS.UPCOMING}), + ); + expect(rows.find((row) => row.reportID === '2')?.formattedTraveler).toBe('Travel Admin'); + }); + + it('keeps the expense rows for a trip search without the beta', () => { + // Given the same results read without the TravelApproval beta, where type:trip is still an expense search + // When the search results are turned into rows + const [rows] = getTripSections(false); + + // Then no trip rows are built, since the expense search reads transactions and these results have none + expect(rows.some((row) => SearchUIUtils.isTripListItemType(row))).toBe(false); + }); + + it('sorts trips by start date for the default date sort', () => { + // Given trip rows and the canned trip search's date sort, newest first + const [rows] = getSectionsByType(getTripSections(true), SearchUIUtils.isTripListItemType); + + // When the rows are sorted + const sorted = SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.TRIP, + rows, + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.DATE, + CONST.SEARCH.SORT_ORDER.DESC, + undefined, + {shouldReturnTripRooms: true}, + ); + + // Then they follow the trip's start date, since trips have no created date to sort by + expect(getSortedSectionsByType(sorted, SearchUIUtils.isTripListItemType).map((row) => row.reportID)).toEqual(['3', '1', '2']); + }); + + it('shows the trip columns only for trip rooms', () => { + // Given a trip search + const getColumns = (shouldReturnTripRooms: boolean) => + SearchUIUtils.getColumnsToShow({currentAccountID: 628, data: tripData, type: CONST.SEARCH.DATA_TYPES.TRIP, shouldReturnTripRooms}); + + // When the columns are worked out with and without the beta + // Then trip rooms get the trip columns, with no Approver column until approval data exists, and the expense search keeps its own columns + expect(getColumns(true)).toEqual([ + CONST.SEARCH.TABLE_COLUMNS.START_DATE, + CONST.SEARCH.TABLE_COLUMNS.END_DATE, + CONST.SEARCH.TABLE_COLUMNS.STATUS, + CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME, + CONST.SEARCH.TABLE_COLUMNS.TRAVELER, + CONST.SEARCH.TABLE_COLUMNS.TOTAL, + CONST.SEARCH.TABLE_COLUMNS.ACTION, + ]); + expect(getColumns(false)).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME); + }); + + it('shows the trip columns the user picked and always keeps the total', () => { + // Given a user who picked only the trip name and traveler columns, plus an expense column left from another search + const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME, CONST.SEARCH.TABLE_COLUMNS.TRAVELER, CONST.SEARCH.TABLE_COLUMNS.MERCHANT]; + + // When the trip columns are worked out + const columns = SearchUIUtils.getColumnsToShow({currentAccountID: 628, data: tripData, type: CONST.SEARCH.DATA_TYPES.TRIP, visibleColumns, shouldReturnTripRooms: true}); + + // Then only the picked trip columns show, with the total added like on expense reports, and the expense column is ignored + expect(columns).toEqual([CONST.SEARCH.TABLE_COLUMNS.TOTAL, CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME, CONST.SEARCH.TABLE_COLUMNS.TRAVELER]); + }); +}); diff --git a/tests/unit/Search/searchReturnTripRoomsFlagTest.ts b/tests/unit/Search/searchReturnTripRoomsFlagTest.ts new file mode 100644 index 000000000000..40bcdcce77eb --- /dev/null +++ b/tests/unit/Search/searchReturnTripRoomsFlagTest.ts @@ -0,0 +1,68 @@ +import {search} from '@libs/actions/Search'; +import {makeRequestWithSideEffects, waitForWrites} from '@libs/API'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +jest.mock('@libs/API', () => ({ + makeRequestWithSideEffects: jest.fn(), + waitForWrites: jest.fn(), + write: jest.fn(), + read: jest.fn(), +})); + +const mockedMakeRequestWithSideEffects = jest.mocked(makeRequestWithSideEffects); +const mockedWaitForWrites = jest.mocked(waitForWrites); + +function getQueryJSON(query: string) { + const queryJSON = buildSearchQueryJSON(query); + if (!queryJSON) { + throw new Error('Query JSON should be defined for test setup'); + } + return queryJSON; +} + +function getLastRequestJsonQuery(): Record { + const requestParams = mockedMakeRequestWithSideEffects.mock.calls.at(-1)?.[1]; + if (!requestParams || !('jsonQuery' in requestParams) || typeof requestParams.jsonQuery !== 'string') { + throw new Error('Search request params with jsonQuery should be defined'); + } + return JSON.parse(requestParams.jsonQuery) as Record; +} + +// The backend only returns one row per trip room when a trip search sends shouldReturnTripRooms, and keeps +// type:trip an expense search otherwise, so these tests pin down when the flag is sent. +describe('search shouldReturnTripRooms flag', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedWaitForWrites.mockResolvedValue(undefined); + mockedMakeRequestWithSideEffects.mockResolvedValue(undefined); + }); + + it('sends the flag for a trip search with the TravelApproval beta', async () => { + // Given a trip search from a user with the TravelApproval beta + // When the search is fired + await search({queryJSON: getQueryJSON('type:trip'), searchKey: CONST.SEARCH.SEARCH_KEYS.TRIPS, offset: 0, isLoading: false, shouldReturnTripRooms: true}); + + // Then the backend is asked for trip rooms + expect(getLastRequestJsonQuery()).toEqual(expect.objectContaining({type: CONST.SEARCH.DATA_TYPES.TRIP, shouldReturnTripRooms: true})); + }); + + it('leaves the flag out for a trip search without the beta', async () => { + // Given a trip search from a user without the TravelApproval beta, which stays an expense search + // When the search is fired + await search({queryJSON: getQueryJSON('type:trip merchant:hotel'), searchKey: undefined, offset: 0, isLoading: false}); + + // Then the flag isn't sent + expect(getLastRequestJsonQuery()).not.toHaveProperty('shouldReturnTripRooms'); + }); + + it('leaves the flag out for other search types even with the beta', async () => { + // Given an expense search from a user with the TravelApproval beta, since the flag only means something for trips + // When the search is fired + await search({queryJSON: getQueryJSON('type:expense merchant:taxi'), searchKey: undefined, offset: 0, isLoading: false, shouldReturnTripRooms: true}); + + // Then the flag isn't sent + expect(getLastRequestJsonQuery()).not.toHaveProperty('shouldReturnTripRooms'); + }); +}); From 2ee46cb8f6d68eadf0cc357b57321427162ba0a8 Mon Sep 17 00:00:00 2001 From: Cong Pham Date: Wed, 21 Oct 2026 20:08:28 +0700 Subject: [PATCH 2/7] Keep trip rows out of Search selection and fix lint --- .../Search/SearchList/ListItem/types.ts | 2 +- .../Search/SearchWriteActionsProvider.tsx | 6 ++-- .../Search/hooks/useSearchSnapshot.ts | 6 ++-- src/components/Search/selectionBuilders.ts | 17 +++++++--- src/libs/SearchUIUtils.ts | 4 +-- src/pages/Search/SearchColumnsPage.tsx | 6 ++-- .../unit/Search/buildShiftRangeSourceTest.ts | 31 ++++++++++++++++++- .../Search/searchReturnTripRoomsFlagTest.ts | 5 +-- 8 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index b4b37fb4e872..0e3632159783 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -429,7 +429,7 @@ type ReportActionListItemType = ListItem & reportName: string; }; -type SearchListItem = TransactionListItemType | TransactionGroupListItemType | ReportActionListItemType | TaskListItemType | ExpenseReportListItemType; +type SearchListItem = TransactionListItemType | TransactionGroupListItemType | ReportActionListItemType | TaskListItemType | ExpenseReportListItemType | TripListItemType; type TransactionCardGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.GROUP_BY.CARD} & PersonalDetails & SearchCardGroup & { diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 7c282c7b0605..8309ce76daa7 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -15,6 +15,7 @@ import { isTransactionEntry, isTransactionGroupListItemType, isTransactionListItemType, + isTripListItemType, } from '@libs/SearchUIUtils'; import type {ShiftRangeBatch} from '@libs/shiftRangeSelection'; import {isTransactionPendingDelete} from '@libs/TransactionUtils'; @@ -43,6 +44,7 @@ import { buildShiftRangeSource, getSearchGroupCount, getSearchGroupCountByKey, + getSelectableRows, isGroupSelected, isRowChecked, mapEmptyReportToSelectedEntry, @@ -595,7 +597,7 @@ function SearchWriteActionsProvider({ // Expense-report rows are the selectable unit, so only group-by rows are headers whose children flatten in. const hasValidGroupBy = areItemsGrouped && !isExpenseReportType; - const {items: flattenedShiftRangeItems, childrenByGroupKey, groupKeyByChildKey} = buildShiftRangeSource(renderedData, openGroupKeys, hasValidGroupBy); + const {items: flattenedShiftRangeItems, childrenByGroupKey, groupKeyByChildKey} = buildShiftRangeSource(getSelectableRows(renderedData), openGroupKeys, hasValidGroupBy); useLayoutEffect(() => { groupKeyByChildKeyRef.current = groupKeyByChildKey; childrenByGroupKeyRef.current = childrenByGroupKey; @@ -689,7 +691,7 @@ function SearchWriteActionsProvider({ const seedGroup = (groupKey: string) => rangeApi.seedRangeFromSelection((childKey) => groupKeyByChildKeyRef.current.get(childKey) === groupKey); const toggle: SearchRowSelectionActionsValue['toggle'] = (item, itemTransactions, shiftKey) => { - if (isReportActionListItemType(item) || isTaskListItemType(item)) { + if (isReportActionListItemType(item) || isTaskListItemType(item) || isTripListItemType(item)) { return; } diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index e0e5f317c3c5..6a8a27acca2e 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -351,14 +351,12 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction return EMPTY_DATA; } const sortInput = filteredData as Parameters[1]; - // Trip rows render through the same list as every other row, they're only kept out of SearchListItem because they can't be selected - const sortedData = getSortedSections(type, sortInput, localeCompare, translate, sortBy, sortOrder, validGroupBy, { + return getSortedSections(type, sortInput, localeCompare, translate, sortBy, sortOrder, validGroupBy, { policyCategories, policyTags, fallbackPolicyID: policyForMovingExpensesID, shouldReturnTripRooms, - }) as SearchListItem[]; - return sortedData.map((item) => { + }).map((item) => { if (item.hash === hash) { return item; } diff --git a/src/components/Search/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts index 2438c60de9df..db3aad1d16a5 100644 --- a/src/components/Search/selectionBuilders.ts +++ b/src/components/Search/selectionBuilders.ts @@ -1,6 +1,6 @@ import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, getReimbursableTotal, isMoneyRequestReport, isOneTransactionReport} from '@libs/ReportUtils'; -import {isGroupedItemArray, isGroupEntry, isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; +import {isGroupedItemArray, isGroupEntry, isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType, isTripListItemType} from '@libs/SearchUIUtils'; import type {ShiftRangeBatch} from '@libs/shiftRangeSelection'; import {getOriginalTransactionWithSplitInfo, hasValidModifiedAmount, isExpenseUnreported, isOnHold, isTransactionPendingDelete} from '@libs/TransactionUtils'; @@ -489,7 +489,7 @@ function resolveGroupChildren(group: TransactionGroupListItemType, openGroupKeys type ShiftRangeSource = { /** Each group header followed by the rows it carries, in visual order */ - items: SearchListItem[]; + items: Array; childrenByGroupKey: Map; @@ -497,15 +497,23 @@ type ShiftRangeSource = { groupKeyByChildKey: Map; }; +const NO_SELECTABLE_ROWS: Array = []; + +/** The rows selection can act on. Trip rows can't be selected, so a trip list has none. */ +function getSelectableRows(data: SearchListItem[]): Array { + const isSelectable = (rows: SearchListItem[]): rows is Array => !rows.some(isTripListItemType); + return isSelectable(data) ? data : NO_SELECTABLE_ROWS; +} + /** One pass, so what a range spans and who owns each row cannot disagree. Flattens only in group-by views. */ -function buildShiftRangeSource(sortedData: SearchListItem[], openGroupKeys: OpenGroupKeys, groupsAreHeaders: boolean): ShiftRangeSource { +function buildShiftRangeSource(sortedData: Array, openGroupKeys: OpenGroupKeys, groupsAreHeaders: boolean): ShiftRangeSource { const childrenByGroupKey = new Map(); const groupKeyByChildKey = new Map(); if (!groupsAreHeaders || !isGroupedItemArray(sortedData)) { return {items: sortedData, childrenByGroupKey, groupKeyByChildKey}; } - const items: SearchListItem[] = []; + const items: Array = []; for (const group of sortedData) { items.push(group); if (!group.keyForList) { @@ -729,6 +737,7 @@ export { prepareTransactionsList, deriveSelectedReports, buildShiftRangeSource, + getSelectableRows, applyShiftRangeBatchToSelection, spellOutGroupSelection, isGroupSelected, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 7bdaaff3d286..6de4604ccc9a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1020,9 +1020,9 @@ function isTaskListItemType(item: SearchListItem): item is TaskListItemType { } /** - * Type guard that checks if something is a TripListItemType. Trip rows aren't part of SearchListItem since they can't be selected. + * Type guard that checks if something is a TripListItemType */ -function isTripListItemType(item: SearchListItem | TripListItemType): item is TripListItemType { +function isTripListItemType(item: SearchListItem): item is TripListItemType { return 'chatType' in item && item.chatType === CONST.REPORT.CHAT_TYPE.TRIP_ROOM && 'travelerAccountID' in item; } diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index 8535b7591921..9f98854b6fa0 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -39,11 +39,11 @@ function SearchColumnsPage() { // Without the TravelApproval beta a trip search is an expense search with no trip columns to pick const shouldReturnTripRooms = useShouldReturnTripRooms(queryType); - const hasNoTypeCustomColumns = queryType === CONST.SEARCH.DATA_TYPES.TRIP && !shouldReturnTripRooms; - const allTypeCustomColumns = hasNoTypeCustomColumns ? [] : getCustomColumns(queryType).filter(isColumnAvailable); + const canPickTypeColumns = queryType !== CONST.SEARCH.DATA_TYPES.TRIP || shouldReturnTripRooms; + const allTypeCustomColumns = canPickTypeColumns ? getCustomColumns(queryType).filter(isColumnAvailable) : []; const allGroupCustomColumns = getCustomColumns(groupBy); const defaultGroupCustomColumns = getCustomColumnDefault(groupBy); - const defaultTypeCustomColumns = hasNoTypeCustomColumns ? [] : [...getCustomColumnDefault(queryType)]; + const defaultTypeCustomColumns = canPickTypeColumns ? [...getCustomColumnDefault(queryType)] : []; const savedColumns = [...(searchAdvancedFiltersForm?.columns ?? [])].filter(isColumnAvailable); // We need at least one element with flex1 in the table to ensure the table looks good in the UI, so we don't allow removing the total columns diff --git a/tests/unit/Search/buildShiftRangeSourceTest.ts b/tests/unit/Search/buildShiftRangeSourceTest.ts index ebfb8804ab77..99af36bc96b0 100644 --- a/tests/unit/Search/buildShiftRangeSourceTest.ts +++ b/tests/unit/Search/buildShiftRangeSourceTest.ts @@ -1,4 +1,5 @@ -import {buildShiftRangeSource, getGroupCheckboxState, isGroupSelected, mapEmptyReportToSelectedEntry} from '@components/Search/selectionBuilders'; +import type {TripListItemType} from '@components/Search/SearchList/ListItem/types'; +import {buildShiftRangeSource, getGroupCheckboxState, getSelectableRows, isGroupSelected, mapEmptyReportToSelectedEntry} from '@components/Search/selectionBuilders'; import type {SearchData, SelectedTransactions} from '@components/Search/types'; import CONST from '@src/CONST'; @@ -206,3 +207,31 @@ describe('buildShiftRangeSource: who owns each row', () => { expect(groupKeyByChildKey.size).toBe(0); }); }); + +describe('getSelectableRows: the rows selection can act on', () => { + it('keeps the same array for rows that can be selected', () => { + // Given an expense list, where every row can be selected + const rows: SearchData = [makeChild(1, 't1'), makeChild(2, 't2')]; + + // When the selectable rows are read + // Then the same array comes back, so a list that can be selected costs no copy per render + expect(getSelectableRows(rows)).toBe(rows); + }); + + it('has no rows for a trip list', () => { + // Given a trip list, whose rows can't be selected + const trip = { + reportID: '1', + keyForList: '1', + chatType: CONST.REPORT.CHAT_TYPE.TRIP_ROOM, + travelerAccountID: 1, + tripName: 'Trip', + traveler: {accountID: 1}, + formattedTraveler: 'Traveler', + } as TripListItemType; + + // When the selectable rows are read + // Then there are none, so trip rows never join a shift-click range + expect(getSelectableRows([trip])).toHaveLength(0); + }); +}); diff --git a/tests/unit/Search/searchReturnTripRoomsFlagTest.ts b/tests/unit/Search/searchReturnTripRoomsFlagTest.ts index 40bcdcce77eb..21e7b46e33ae 100644 --- a/tests/unit/Search/searchReturnTripRoomsFlagTest.ts +++ b/tests/unit/Search/searchReturnTripRoomsFlagTest.ts @@ -22,12 +22,13 @@ function getQueryJSON(query: string) { return queryJSON; } -function getLastRequestJsonQuery(): Record { +function getLastRequestJsonQuery(): unknown { const requestParams = mockedMakeRequestWithSideEffects.mock.calls.at(-1)?.[1]; if (!requestParams || !('jsonQuery' in requestParams) || typeof requestParams.jsonQuery !== 'string') { throw new Error('Search request params with jsonQuery should be defined'); } - return JSON.parse(requestParams.jsonQuery) as Record; + const parsedJsonQuery: unknown = JSON.parse(requestParams.jsonQuery); + return parsedJsonQuery; } // The backend only returns one row per trip room when a trip search sends shouldReturnTripRooms, and keeps From d1d46c315a87577f933c0db8132903bd7683cb79 Mon Sep 17 00:00:00 2001 From: Cong Pham Date: Thu, 22 Oct 2026 10:54:57 +0700 Subject: [PATCH 3/7] Address review: trip sort keys, drop casts and manual memoization --- .../Search/FilterDropdowns/DisplayPopup.tsx | 4 +- .../SearchList/ListItem/TripListItem.tsx | 8 +-- .../SearchList/ListItem/TripListItemRow.tsx | 3 - .../SearchList/ListItem/TripStatusCell.tsx | 1 - .../Search/SearchList/ListItem/types.ts | 2 +- src/components/Search/SearchTableHeader.tsx | 11 ++-- src/components/Search/TripSearchView.tsx | 24 +++++--- src/components/Search/index.tsx | 1 + src/libs/SearchUIUtils.ts | 61 +++++++++++++------ src/pages/Search/SearchPage.tsx | 17 +++--- src/pages/Search/SearchPageNarrow/index.tsx | 17 +++--- src/pages/Search/SearchSavePage.tsx | 13 +++- src/types/onyx/SearchResults.ts | 3 +- tests/unit/Search/SearchUIUtilsTest.ts | 51 ++++++++++++++-- .../unit/Search/buildShiftRangeSourceTest.ts | 4 +- 15 files changed, 141 insertions(+), 79 deletions(-) diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx index 597aefe1541b..64e439b362cc 100644 --- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -9,6 +9,7 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useThemeStyles from '@hooks/useThemeStyles'; import {close} from '@libs/actions/Modal'; @@ -49,6 +50,7 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP const {isLargeScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); // eslint-disable-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const expensifyIcons = useMemoizedLazyExpensifyIcons(['Columns']); const {updateFilterQueryParams} = useUpdateFilterQuery(queryJSON); + const shouldReturnTripRooms = useShouldReturnTripRooms(queryJSON.type); const [searchAdvancedFilters = getEmptyObject()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const [selectedDisplayFilter, setSelectedDisplayFilter] = useState< | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT @@ -88,7 +90,7 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP name={translate('search.display.sortBy')} onPress={() => setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY)} sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_SORT_BY} - value={`${translate(getSearchColumnTranslationKey(sortByValue, queryJSON.type))} ${CONST.DOT_SEPARATOR} ${translate(`search.filters.sortOrder.${sortOrderValue}`)}`} + value={`${translate(getSearchColumnTranslationKey(sortByValue, queryJSON.type, shouldReturnTripRooms))} ${CONST.DOT_SEPARATOR} ${translate(`search.filters.sortOrder.${sortOrderValue}`)}`} /> {(isExpenseType || isTripType) && ( ({item, columns = [], isFocused, showTooltip, isDisabled, onSelectRow, onFocus, shouldSyncFocus, isLastItem}: TripListItemProps) { - const tripItem = item as unknown as TripListItemType; +function TripListItem({item, columns = [], isFocused, showTooltip, isDisabled, onSelectRow, onFocus, shouldSyncFocus, isLastItem}: TripListItemProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {pageGutterMargin} = useLayoutSpacing(); @@ -57,7 +55,7 @@ function TripListItem({item, columns = [], isFocused, sh > onSelectRow(item)} diff --git a/src/components/Search/SearchList/ListItem/TripListItemRow.tsx b/src/components/Search/SearchList/ListItem/TripListItemRow.tsx index b73cb613a077..18f0063b0200 100644 --- a/src/components/Search/SearchList/ListItem/TripListItemRow.tsx +++ b/src/components/Search/SearchList/ListItem/TripListItemRow.tsx @@ -27,16 +27,13 @@ import TripStatusCell from './TripStatusCell'; import UserInfoCell from './UserInfoCell'; type TripListItemRowProps = { - /** The trip to show */ item: TripListItemType; /** Columns to show on wide screens */ columns: SearchColumnType[]; - /** Whether to show tooltips */ showTooltip: boolean; - /** Opens the trip */ onViewPress: () => void; }; diff --git a/src/components/Search/SearchList/ListItem/TripStatusCell.tsx b/src/components/Search/SearchList/ListItem/TripStatusCell.tsx index 2d0aca212fcb..ea2e1d04b36f 100644 --- a/src/components/Search/SearchList/ListItem/TripStatusCell.tsx +++ b/src/components/Search/SearchList/ListItem/TripStatusCell.tsx @@ -11,7 +11,6 @@ import React from 'react'; import {View} from 'react-native'; type TripStatusCellProps = { - /** The trip's status */ status: SearchTrip['status']; }; diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 0e3632159783..57578448dbca 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -407,7 +407,7 @@ type TripListItemType = ListItem & keyForList: string; }; -type TripListItemProps = ListItemProps & { +type TripListItemProps = ListItemProps & { /** Columns to show */ columns?: SearchColumnType[]; }; diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index 22e4534b32ea..db2e0965bce2 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -255,7 +255,7 @@ const tripHeaders: SearchColumnConfig[] = [ { columnName: CONST.SEARCH.TABLE_COLUMNS.START_DATE, translationKey: 'iou.startDate', - isColumnSortable: false, + sortColumnName: CONST.SEARCH.TABLE_COLUMNS.DATE, canEdit: true, }, { @@ -271,12 +271,12 @@ const tripHeaders: SearchColumnConfig[] = [ { columnName: CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME, translationKey: 'search.trip.tripName', - isColumnSortable: false, + sortColumnName: CONST.SEARCH.TABLE_COLUMNS.TITLE, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TRAVELER, translationKey: 'search.trip.traveler', - isColumnSortable: false, + sortColumnName: CONST.SEARCH.TABLE_COLUMNS.FROM, }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TOTAL, @@ -646,10 +646,7 @@ function SearchTableHeader({ [columns], ); - const columnConfig = useMemo( - () => getSearchColumns(type, icons, groupBy, isExpenseReportView, shouldReturnTripRooms), - [type, groupBy, icons, isExpenseReportView, shouldReturnTripRooms], - ); + const columnConfig = getSearchColumns(type, icons, groupBy, isExpenseReportView, shouldReturnTripRooms); const orderedColumnConfig = useMemo(() => { if (!columnConfig) { diff --git a/src/components/Search/TripSearchView.tsx b/src/components/Search/TripSearchView.tsx index a23b8359ec50..81093600b237 100644 --- a/src/components/Search/TripSearchView.tsx +++ b/src/components/Search/TripSearchView.tsx @@ -1,5 +1,7 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; +import {isTripListItemType} from '@libs/SearchUIUtils'; + import CONST from '@src/CONST'; import type {NativeSyntheticEvent} from 'react-native'; @@ -67,16 +69,18 @@ function TripSearchView({ shouldApplyAnimation={false} hasItemsBeingRemoved={false} > - + {isTripListItemType(item) && ( + + )} ); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 57594fbf19d5..ba93b6398e5c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1501,6 +1501,7 @@ function Search({ isTaxAmountColumnWide={shouldShowTaxAmountInWideColumn} shouldShowSorting groupBy={validGroupBy} + // Trip rows keep a fixed-width total like expense report rows, so the header drops the total column's flex for them too isExpenseReportView={isExpenseReportType || shouldReturnTripRooms} isActionColumnWide={isTask || hasDeletedTransaction} /> diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 6de4604ccc9a..7285f85e959a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -89,7 +89,6 @@ import type { SearchTagGroup, SearchTask, SearchTransactionAction, - SearchTrip, SearchWeekGroup, SearchWithdrawalIDGroup, SearchYearGroup, @@ -359,11 +358,9 @@ const taskColumnNamesToSortingProperty: TaskSorting = { const tripColumnNamesToSortingProperty: TripSorting = { [CONST.SEARCH.TABLE_COLUMNS.DATE]: 'startDate' as const, - [CONST.SEARCH.TABLE_COLUMNS.START_DATE]: 'startDate' as const, - [CONST.SEARCH.TABLE_COLUMNS.END_DATE]: 'endDate' as const, + [CONST.SEARCH.TABLE_COLUMNS.TITLE]: 'tripName' as const, + [CONST.SEARCH.TABLE_COLUMNS.FROM]: 'formattedTraveler' as const, [CONST.SEARCH.TABLE_COLUMNS.STATUS]: 'status' as const, - [CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME]: 'tripName' as const, - [CONST.SEARCH.TABLE_COLUMNS.TRAVELER]: 'formattedTraveler' as const, [CONST.SEARCH.TABLE_COLUMNS.TOTAL]: 'total' as const, }; @@ -480,11 +477,8 @@ const nonSortableColumns = new Set([ CONST.SEARCH.TABLE_COLUMNS.IN, CONST.SEARCH.TABLE_COLUMNS.AVATAR, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS, - // The trip search doesn't sort by these yet - CONST.SEARCH.TABLE_COLUMNS.START_DATE, + // The trip search sorts by start date, trip name and traveler, but not by end date CONST.SEARCH.TABLE_COLUMNS.END_DATE, - CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME, - CONST.SEARCH.TABLE_COLUMNS.TRAVELER, ]); function isValidTaskStatus(status: unknown): status is ValueOf { @@ -2395,19 +2389,14 @@ function getRowCapabilities(allActions: SearchTransactionAction[]): RowCapabilit } /** - * @private - * Organizes data into List Sections for display, for the TaskListItemType of Search Results. + * Organizes trip rooms from a trip search into one row per trip. * * Do not use directly, use only via `getSections()` facade. */ -/** - * @private - * Organizes trip rooms from a trip search into one row per trip - */ function getTripSections(data: OnyxTypes.SearchResults['data'], formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocalizedTranslate): [TripListItemType[], number] { const trips = Object.keys(data) .filter(isReportEntry) - .map((key) => data[key] as SearchTrip) + .map((key) => data[key]) .filter((trip) => trip?.chatType === CONST.REPORT.CHAT_TYPE.TRIP_ROOM) .map((trip) => { // Trip rooms have no owner, so the traveler comes from travelerAccountID @@ -2425,6 +2414,12 @@ function getTripSections(data: OnyxTypes.SearchResults['data'], formatPhoneNumbe return [trips, trips.length]; } +/** + * @private + * Organizes data into List Sections for display, for the TaskListItemType of Search Results. + * + * Do not use directly, use only via `getSections()` facade. + */ function getTaskSections( data: OnyxTypes.SearchResults['data'], formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], @@ -4263,12 +4258,16 @@ function getSortedTaskData(data: TaskListItemType[], localeCompare: LocaleContex } /** - * @private * Sorts trip rows based on a specified column and sort order. */ function getSortedTripData(data: TripListItemType[], localeCompare: LocaleContextProps['localeCompare'], sortBy?: SearchSortBy, sortOrder?: SortOrder) { - const sortingProperty = sortBy ? tripColumnNamesToSortingProperty[sortBy as SearchColumnType] : undefined; - if (!sortingProperty || !sortOrder) { + if (!sortBy || !sortOrder) { + return data; + } + + const sortingProperty = + sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE || sortBy === CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE ? undefined : tripColumnNamesToSortingProperty[sortBy]; + if (!sortingProperty) { return data; } @@ -4551,7 +4550,19 @@ function isCreatedDateType(type?: SearchDataTypes): boolean { return type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || type === CONST.SEARCH.DATA_TYPES.TASK; } -function getSearchColumnTranslationKey(column: SearchSortBy, type?: SearchDataTypes): TranslationPaths { +function getSearchColumnTranslationKey(column: SearchSortBy, type?: SearchDataTypes, shouldReturnTripRooms = false): TranslationPaths { + // Trip rows sort by date, title and from, which show as their start date, trip name and traveler + if (type === CONST.SEARCH.DATA_TYPES.TRIP && shouldReturnTripRooms) { + if (column === CONST.SEARCH.TABLE_COLUMNS.DATE) { + return 'iou.startDate'; + } + if (column === CONST.SEARCH.TABLE_COLUMNS.TITLE) { + return 'search.trip.tripName'; + } + if (column === CONST.SEARCH.TABLE_COLUMNS.FROM) { + return 'search.trip.traveler'; + } + } switch (column) { case CONST.SEARCH.TABLE_COLUMNS.AVATAR: return 'common.avatar'; @@ -4721,6 +4732,16 @@ function isColumnSortable(column: SearchColumnType) { } function getSortByForColumn(column: SearchColumnType): SearchSortBy { + // The trip columns sort by the trip search's own sort keys + if (column === CONST.SEARCH.TABLE_COLUMNS.START_DATE) { + return CONST.SEARCH.TABLE_COLUMNS.DATE; + } + if (column === CONST.SEARCH.TABLE_COLUMNS.TRIP_NAME) { + return CONST.SEARCH.TABLE_COLUMNS.TITLE; + } + if (column === CONST.SEARCH.TABLE_COLUMNS.TRAVELER) { + return CONST.SEARCH.TABLE_COLUMNS.FROM; + } if (column === CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE) { return CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE; } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 941bda67d61a..161a016200f6 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -154,16 +154,13 @@ function SearchPage({route}: SearchPageProps) { const {isBetaEnabled} = usePermissions(); const isTravelApprovalEnabled = isBetaEnabled(CONST.BETAS.TRAVEL_APPROVAL); - const handleSearchAction = useCallback( - (value: SearchParams | string) => { - if (typeof value === 'string') { - searchInServer(value); - } else { - search({...value, shouldReturnTripRooms: isTravelApprovalEnabled}); - } - }, - [isTravelApprovalEnabled], - ); + const handleSearchAction = (value: SearchParams | string) => { + if (typeof value === 'string') { + searchInServer(value); + } else { + search({...value, shouldReturnTripRooms: isTravelApprovalEnabled}); + } + }; const onSortPressedCallback = useCallback(() => { setIsSorting(true); diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index e696f7e49dee..0ac3c95f5dbf 100644 --- a/src/pages/Search/SearchPageNarrow/index.tsx +++ b/src/pages/Search/SearchPageNarrow/index.tsx @@ -176,16 +176,13 @@ function SearchPageNarrow({ const {isBetaEnabled} = usePermissions(); const isTravelApprovalEnabled = isBetaEnabled(CONST.BETAS.TRAVEL_APPROVAL); - const handleSearchAction = useCallback( - (value: SearchParams | string) => { - if (typeof value === 'string') { - searchInServer(value); - } else { - search({...value, shouldReturnTripRooms: isTravelApprovalEnabled}); - } - }, - [isTravelApprovalEnabled], - ); + const handleSearchAction = (value: SearchParams | string) => { + if (typeof value === 'string') { + searchInServer(value); + } else { + search({...value, shouldReturnTripRooms: isTravelApprovalEnabled}); + } + }; const navigation = useNavigation(); // When pre-inserted behind the RHP (not focused), always start in static rendering diff --git a/src/pages/Search/SearchSavePage.tsx b/src/pages/Search/SearchSavePage.tsx index d8befb33b33e..e197d1b9163c 100644 --- a/src/pages/Search/SearchSavePage.tsx +++ b/src/pages/Search/SearchSavePage.tsx @@ -20,6 +20,7 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useThemeStyles from '@hooks/useThemeStyles'; import {saveSearch} from '@libs/actions/Search'; @@ -117,7 +118,12 @@ function FilterValue({filterKey, value}: FilterValueWithKeyProps) { return value; } -function getAppliedDisplays(searchAdvancedFiltersForm: Partial, queryJSON: SearchQueryJSON | undefined, translate: LocalizedTranslate) { +function getAppliedDisplays( + searchAdvancedFiltersForm: Partial, + queryJSON: SearchQueryJSON | undefined, + translate: LocalizedTranslate, + shouldReturnTripRooms: boolean, +) { const appliedDisplays = []; const groupBy = getValidGroupBy(searchAdvancedFiltersForm.groupBy); if (groupBy) { @@ -137,7 +143,7 @@ function getAppliedDisplays(searchAdvancedFiltersForm: Partial>()] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const {currentDefaultSearchQueryFilterKeys, currentSearchQueryJSON} = useSearchQueryContext(); + const shouldReturnTripRooms = useShouldReturnTripRooms(currentSearchQueryJSON?.type); const onSaveSearch = (values: FormOnyxValues) => { if (!currentSearchQueryJSON) { @@ -191,7 +198,7 @@ function SearchSavePage() { localeCompare, convertToDisplayStringWithoutCurrency, ); - const appliedDisplays = getAppliedDisplays(searchAdvancedFiltersForm, currentSearchQueryJSON, translate); + const appliedDisplays = getAppliedDisplays(searchAdvancedFiltersForm, currentSearchQueryJSON, translate, shouldReturnTripRooms); const {inputCallbackRef} = useAutoFocusInput(); diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 95a9ac0c053d..dfe747d08bc5 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -308,7 +308,8 @@ type SearchResultDataType = PrefixedRecord | undefined>> & PrefixedRecord> & Partial> & - PrefixedRecord & + // Trip rooms returned by a trip search carry the trip's fields on top of the report + PrefixedRecord & PrefixedRecord & PrefixedRecord & PrefixedRecord & diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index b486ac81267a..beaa3ac1dca2 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -15629,9 +15629,9 @@ describe('getDatePresets', () => { }); describe('Trip search', () => { - const buildTrip = (reportID: string, overrides: Partial = {}): SearchTrip => ({ - reportID, - reportName: `Trip ${reportID}`, + const buildTrip = (tripReportID: string, overrides: Partial = {}): SearchTrip => ({ + reportID: tripReportID, + reportName: `Trip ${tripReportID}`, chatType: CONST.REPORT.CHAT_TYPE.TRIP_ROOM, type: CONST.REPORT.TYPE.CHAT, policyID: 'DD949BDE0E77A392', @@ -15646,7 +15646,7 @@ describe('Trip search', () => { }); // Shaped like the trip search response: trip rooms only, with the traveler in personalDetailsList - const tripData = { + const tripData: OnyxTypes.SearchResults['data'] = { personalDetailsList: { 628: {accountID: 628, displayName: 'Travel Admin', login: 'travel-admin@cplustest.com'}, 629: {accountID: 629, displayName: 'Travel Employee', login: 'travel-employee@cplustest.com'}, @@ -15654,7 +15654,7 @@ describe('Trip search', () => { report_1: buildTrip('1', {reportName: 'Tokyo Sales Conference', startDate: '2026-10-28T00:00:00'}), report_2: buildTrip('2', {reportName: 'Sales Conf Vegas', startDate: '2026-08-12T00:00:00', status: CONST.SEARCH.TRIP_STATUS.DONE, travelerAccountID: 628}), report_3: buildTrip('3', {reportName: 'Oslo React Meetup', startDate: '2026-11-04T00:00:00'}), - } as unknown as OnyxTypes.SearchResults['data']; + }; const getTripSections = (shouldReturnTripRooms: boolean) => SearchUIUtils.getSections({ @@ -15716,6 +15716,47 @@ describe('Trip search', () => { expect(getSortedSectionsByType(sorted, SearchUIUtils.isTripListItemType).map((row) => row.reportID)).toEqual(['3', '1', '2']); }); + it('sorts trips by trip name and traveler through the title and from sort keys', () => { + // Given trip rows, since the trip search sorts trip names by title and travelers by from + const [rows] = getSectionsByType(getTripSections(true), SearchUIUtils.isTripListItemType); + const sortBy = (column: typeof CONST.SEARCH.TABLE_COLUMNS.TITLE | typeof CONST.SEARCH.TABLE_COLUMNS.FROM) => + getSortedSectionsByType( + SearchUIUtils.getSortedSections(CONST.SEARCH.DATA_TYPES.TRIP, [...rows], localeCompare, translateLocal, column, CONST.SEARCH.SORT_ORDER.ASC, undefined, { + shouldReturnTripRooms: true, + }), + SearchUIUtils.isTripListItemType, + ).map((row) => row.reportID); + + // When the rows are sorted by those keys + // Then title follows the trip name and from follows the traveler's name + expect(sortBy(CONST.SEARCH.TABLE_COLUMNS.TITLE)).toEqual(['3', '2', '1']); + expect(sortBy(CONST.SEARCH.TABLE_COLUMNS.FROM)[0]).toBe('2'); + }); + + it('offers the trip columns in Sort by with the trip search sort keys', () => { + // Given the trip columns, which the backend sorts by date, title and from + // When the Sort by options are built for a trip search + const options = SearchUIUtils.getSortByOptions([...CONST.SEARCH.TYPE_DEFAULT_COLUMNS.TRIP], translateLocal, CONST.SEARCH.DATA_TYPES.TRIP); + + // Then start date, trip name and traveler sort by those keys, so the default date sort shows as Start date, and end date isn't offered + expect(options).toEqual( + expect.arrayContaining([ + {text: translateLocal('iou.startDate'), value: CONST.SEARCH.TABLE_COLUMNS.DATE}, + {text: translateLocal('search.trip.tripName'), value: CONST.SEARCH.TABLE_COLUMNS.TITLE}, + {text: translateLocal('search.trip.traveler'), value: CONST.SEARCH.TABLE_COLUMNS.FROM}, + ]), + ); + expect(options.map((option) => option.text)).not.toContain(translateLocal('iou.endDate')); + }); + + it('labels the date sort as Start date only for trip rooms', () => { + // Given the canned trip search's date sort + // When its label is read with and without the TravelApproval beta + // Then trip rooms show Start date, matching the Sort by option, and the expense search keeps Date + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.DATA_TYPES.TRIP, true)).toBe('iou.startDate'); + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.DATA_TYPES.TRIP, false)).toBe('common.date'); + }); + it('shows the trip columns only for trip rooms', () => { // Given a trip search const getColumns = (shouldReturnTripRooms: boolean) => diff --git a/tests/unit/Search/buildShiftRangeSourceTest.ts b/tests/unit/Search/buildShiftRangeSourceTest.ts index 99af36bc96b0..354663b5c381 100644 --- a/tests/unit/Search/buildShiftRangeSourceTest.ts +++ b/tests/unit/Search/buildShiftRangeSourceTest.ts @@ -220,7 +220,7 @@ describe('getSelectableRows: the rows selection can act on', () => { it('has no rows for a trip list', () => { // Given a trip list, whose rows can't be selected - const trip = { + const trip: TripListItemType = { reportID: '1', keyForList: '1', chatType: CONST.REPORT.CHAT_TYPE.TRIP_ROOM, @@ -228,7 +228,7 @@ describe('getSelectableRows: the rows selection can act on', () => { tripName: 'Trip', traveler: {accountID: 1}, formattedTraveler: 'Traveler', - } as TripListItemType; + }; // When the selectable rows are read // Then there are none, so trip rows never join a shift-click range From d6e441a86beb4792141ae3ca719b7da04f2c309e Mon Sep 17 00:00:00 2001 From: Cong Pham Date: Thu, 22 Oct 2026 12:57:29 +0700 Subject: [PATCH 4/7] fix typecheck --- src/libs/SearchUIUtils.ts | 11 ++++++----- src/libs/getTransactionFromTransactionListItem.ts | 1 - tests/unit/Search/SearchUIUtilsTest.ts | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 8cbc220400a6..c9404c8151d0 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3960,7 +3960,7 @@ function getSortedSections( return getSortedReportData(data as TransactionReportGroupListItemType[], localeCompare, translate, sortBy, sortOrder); } if (type === CONST.SEARCH.DATA_TYPES.TRIP && options?.shouldReturnTripRooms) { - return getSortedTripData(data as TripListItemType[], localeCompare, sortBy, sortOrder); + return getSortedTripData(data, localeCompare, sortBy, sortOrder); } if (groupBy) { @@ -4271,18 +4271,19 @@ function getSortedTaskData(data: TaskListItemType[], localeCompare: LocaleContex /** * Sorts trip rows based on a specified column and sort order. */ -function getSortedTripData(data: TripListItemType[], localeCompare: LocaleContextProps['localeCompare'], sortBy?: SearchSortBy, sortOrder?: SortOrder) { +function getSortedTripData(data: SearchListItem[], localeCompare: LocaleContextProps['localeCompare'], sortBy?: SearchSortBy, sortOrder?: SortOrder) { + const trips = data.filter(isTripListItemType); if (!sortBy || !sortOrder) { - return data; + return trips; } const sortingProperty = sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE || sortBy === CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE ? undefined : tripColumnNamesToSortingProperty[sortBy]; if (!sortingProperty) { - return data; + return trips; } - return data.sort((a, b) => compareValues(a[sortingProperty], b[sortingProperty], sortOrder, sortingProperty, localeCompare)); + return trips.sort((a, b) => compareValues(a[sortingProperty], b[sortingProperty], sortOrder, sortingProperty, localeCompare)); } /** diff --git a/src/libs/getTransactionFromTransactionListItem.ts b/src/libs/getTransactionFromTransactionListItem.ts index 22b4b1da7aaa..7be736d4dfa2 100644 --- a/src/libs/getTransactionFromTransactionListItem.ts +++ b/src/libs/getTransactionFromTransactionListItem.ts @@ -63,7 +63,6 @@ const SEARCH_TRANSACTION_ROW_ONLY_FIELDS: Record { // When the rows are sorted by those keys // Then title follows the trip name and from follows the traveler's name expect(sortBy(CONST.SEARCH.TABLE_COLUMNS.TITLE)).toEqual(['3', '2', '1']); - expect(sortBy(CONST.SEARCH.TABLE_COLUMNS.FROM)[0]).toBe('2'); + expect(sortBy(CONST.SEARCH.TABLE_COLUMNS.FROM).at(0)).toBe('2'); }); it('offers the trip columns in Sort by with the trip search sort keys', () => { From 055bb2b8a8bfee563e49924967679e36dc020462 Mon Sep 17 00:00:00 2001 From: Cong Pham Date: Fri, 23 Oct 2026 12:59:45 +0700 Subject: [PATCH 5/7] Explain why trip rows use the expense report header layout --- src/components/Search/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 83d734b0348c..00111429ef18 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1540,6 +1540,7 @@ function Search({ isTaxAmountColumnWide={shouldShowTaxAmountInWideColumn} shouldShowSorting groupBy={validGroupBy} + // Trip rows share the expense report layout (Total without flex), so the header aligns the same way isExpenseReportView={isExpenseReportType || shouldReturnTripRooms} isActionColumnWide={isActionColumnWide} /> From 9a984ba2ec6a859c7978e1f47cac1a91b5cafc0f Mon Sep 17 00:00:00 2001 From: Cong Pham Date: Fri, 23 Oct 2026 14:47:06 +0700 Subject: [PATCH 6/7] Keep trip rooms on Search refreshes and in-flight re-fires, and allow editing trip columns --- .../MoneyReportHeaderSecondaryActions.tsx | 3 + .../PayPrimaryAction.tsx | 3 + .../SubmitPrimaryAction.tsx | 3 + .../MoneyRequestReportNavigation.tsx | 3 + .../Search/FilterDropdowns/DisplayPopup.tsx | 3 +- src/hooks/useLifecycleActions.tsx | 3 + src/hooks/useSearchBulkActions.ts | 5 ++ src/hooks/useSelectionModePayment.ts | 3 + src/libs/SearchRefreshUtils.ts | 5 +- src/libs/actions/Search.ts | 11 +++- .../DynamicReportChangeWorkspacePage.tsx | 3 + .../Search/searchReturnTripRoomsFlagTest.ts | 31 ++++++++++ tests/unit/SearchRefreshUtilsTest.ts | 59 +++++++++++++++++++ 13 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 tests/unit/SearchRefreshUtilsTest.ts diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 232c7ee9d195..765b95da0f4f 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -36,6 +36,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useShareActions from '@hooks/useShareActions'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -170,6 +171,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); + const shouldReturnTripRooms = useShouldReturnTripRooms(currentSearchQueryJSON?.type); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); @@ -266,6 +268,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo offset: 0, queryJSON: currentSearchQueryJSON, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); } } diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 1bb26e04ee1e..6c11f10591b0 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -18,6 +18,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; @@ -120,6 +121,7 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction const isAnyTransactionOnHold = hasHeldExpensesReportUtils(transactions); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); + const shouldReturnTripRooms = useShouldReturnTripRooms(currentSearchQueryJSON?.type); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); @@ -200,6 +202,7 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction offset: 0, queryJSON: currentSearchQueryJSON, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); } } diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index 58dde9b9352d..87a623390b44 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -19,6 +19,7 @@ import usePermissions from '@hooks/usePermissions'; import {useGetPersonalDetailsByLogin} from '@hooks/usePersonalDetailByLogin'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useStrictPolicyRules from '@hooks/useStrictPolicyRules'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -134,6 +135,7 @@ function SubmitPrimaryActionContent({reportID, isDisabled}: SubmitPrimaryActionP const canSubmitViaPDF = !!moneyRequestReport && isSubmitViaPDFAction(moneyRequestReport, accountID, policy); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); + const shouldReturnTripRooms = useShouldReturnTripRooms(currentSearchQueryJSON?.type); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); @@ -222,6 +224,7 @@ function SubmitPrimaryActionContent({reportID, isDisabled}: SubmitPrimaryActionP offset: 0, queryJSON: currentSearchQueryJSON, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); } }); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx index bc7c4220eaec..0bc9dd91d3a8 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportNavigation.tsx @@ -6,6 +6,7 @@ import useFilterPendingDeleteReports from '@hooks/useFilterPendingDeleteReports' import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchSections from '@hooks/useSearchSections'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useThemeStyles from '@hooks/useThemeStyles'; import {startSpan} from '@libs/telemetry/activeSpans'; @@ -129,6 +130,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi // Lightweight subscriptions only: the current search query and its loading flag. These never mount // the heavy useSearchSections subscription set, so the fast context path stays cheap. const [lastSearchQuery] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const shouldReturnTripRooms = useShouldReturnTripRooms(lastSearchQuery?.queryJSON?.type); const [isSearchLoading = false] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${lastSearchQuery?.queryJSON?.hash}`, {selector: searchLoadingSelector}); // Fast path: use the pre-computed IDs from the search context when they are usable and no page is in @@ -230,6 +232,7 @@ function MoneyRequestReportNavigationContent({reportID, shouldDisplayNarrowVersi searchKey: lastSearchQuery.searchKey, isLoading: isSearchLoading, shouldUpdateLastSearchParams: true, + shouldReturnTripRooms, }); }); } diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx index 64e439b362cc..74679b1bcf6f 100644 --- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx +++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx @@ -66,7 +66,8 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP const groupBy = groupBySections.flatMap((section) => section.options).find((option) => option.value === queryJSON.groupBy) ?? null; const viewOptions = getViewOptions(translate); const view = viewOptions.find((option) => option.value === queryJSON.view) ?? viewOptions.at(0); - const shouldShowColumnsButton = isLargeScreenWidth && (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE || queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); + const shouldShowColumnsButton = + isLargeScreenWidth && (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE || queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT || shouldReturnTripRooms); const limitValue = searchAdvancedFilters[CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT]; diff --git a/src/hooks/useLifecycleActions.tsx b/src/hooks/useLifecycleActions.tsx index 0612c1576a7d..2dc793e06ca8 100644 --- a/src/hooks/useLifecycleActions.tsx +++ b/src/hooks/useLifecycleActions.tsx @@ -53,6 +53,7 @@ import {useGetPersonalDetailsByLogin} from './usePersonalDetailByLogin'; import {usePersonalDetail} from './usePersonalDetails'; import useReportIsArchived from './useReportIsArchived'; import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; +import useShouldReturnTripRooms from './useShouldReturnTripRooms'; import useStrictPolicyRules from './useStrictPolicyRules'; import useThemeStyles from './useThemeStyles'; import useTransactionsAndViolationsForReport from './useTransactionsAndViolationsForReport'; @@ -140,6 +141,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); + const shouldReturnTripRooms = useShouldReturnTripRooms(currentSearchQueryJSON?.type); const {currentSearchResults} = useSearchResultsContext(); const {selectedTransactionIDs} = useSearchSelectionContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); @@ -333,6 +335,7 @@ function useLifecycleActions({reportID, startApprovedAnimation, startAnimation, shouldCalculateTotals, isOffline, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); if (skipAnimation) { clearSelectedTransactions(true); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index a145090197bf..c687d124aa41 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -172,6 +172,7 @@ import useReportPDFDownloadModal from './useReportPDFDownloadModal'; import useRestrictedActionPolicyID from './useRestrictedActionPolicyID'; import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; import useSelfDMReport from './useSelfDMReport'; +import useShouldReturnTripRooms from './useShouldReturnTripRooms'; import useSplitEffectivePolicy from './useSplitEffectivePolicy'; import useTheme from './useTheme'; import useThemeStyles from './useThemeStyles'; @@ -632,6 +633,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {selectedTransactions, excludedTransactions = getEmptyObject(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); + const shouldReturnTripRooms = useShouldReturnTripRooms(currentSearchQueryJSON?.type); const {clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {accountID, email, login: currentUserLogin, localCurrencyCode} = currentUserPersonalDetails; @@ -2971,6 +2973,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCalculateTotals: shouldCalculateTotalsOnRefresh, isOffline, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); clearSelectedTransactions(); }, @@ -3052,6 +3055,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCalculateTotals: shouldCalculateTotalsOnRefresh, isOffline, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); clearSelectedTransactions(); }; @@ -3468,6 +3472,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { hasUnloadedMatchingExpenses, isUnreportedOnlyQuery, currentSearchQueryJSON, + shouldReturnTripRooms, currentSearchResults?.search?.isLoading, shouldCalculateTotalsOnRefresh, rules, diff --git a/src/hooks/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index 8603b9c17ee5..2335b6a9360f 100644 --- a/src/hooks/useSelectionModePayment.ts +++ b/src/hooks/useSelectionModePayment.ts @@ -45,6 +45,7 @@ import usePermissions from './usePermissions'; import {usePersonalDetail} from './usePersonalDetails'; import usePolicy from './usePolicy'; import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; +import useShouldReturnTripRooms from './useShouldReturnTripRooms'; import useVerifyAccountAndResume from './useVerifyAccountAndResume'; type HoldMenuOpenParams = { @@ -89,6 +90,7 @@ function useSelectionModePayment({ const lastWorkspaceNumber = useLastWorkspaceNumber(); const {currentSearchQueryJSON, currentSearchKey} = useSearchQueryContext(); + const shouldReturnTripRooms = useShouldReturnTripRooms(currentSearchQueryJSON?.type); const {currentSearchResults} = useSearchResultsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, true); @@ -246,6 +248,7 @@ function useSelectionModePayment({ shouldCalculateTotals, isOffline, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); } diff --git a/src/libs/SearchRefreshUtils.ts b/src/libs/SearchRefreshUtils.ts index 4ffd2e962f49..bed5f65b35ef 100644 --- a/src/libs/SearchRefreshUtils.ts +++ b/src/libs/SearchRefreshUtils.ts @@ -10,9 +10,11 @@ type RefreshSearchParams = { shouldCalculateTotals: boolean; isOffline: boolean; isLoading: boolean; + /** Keeps a refresh of the Trips search asking for trip rooms, or it overwrites them with travel expenses */ + shouldReturnTripRooms: boolean; }; -function refreshSearchAfterReportAction({currentSearchQueryJSON, currentSearchKey, shouldCalculateTotals, isOffline, isLoading}: RefreshSearchParams) { +function refreshSearchAfterReportAction({currentSearchQueryJSON, currentSearchKey, shouldCalculateTotals, isOffline, isLoading, shouldReturnTripRooms}: RefreshSearchParams) { if (!currentSearchQueryJSON || isOffline) { return; } @@ -22,6 +24,7 @@ function refreshSearchAfterReportAction({currentSearchQueryJSON, currentSearchKe offset: 0, queryJSON: currentSearchQueryJSON, isLoading, + shouldReturnTripRooms, }); } diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 0422c46f7352..5e7c2510641c 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1234,6 +1234,8 @@ type InFlightSearchRequest = { shouldSaveRecentSearch: boolean; /** The footer breakdown it asks the total for. Not part of the hash, so it is told apart here. */ footerTotal?: SearchFooterTotal; + /** Whether it asks for trip rooms. Not part of the hash either, and it changes the shape of the results. */ + shouldReturnTripRooms: boolean; /** The request, including any re-fire chained onto it. */ promise?: Promise; pendingShouldCalculateTotals?: boolean; @@ -1358,6 +1360,7 @@ function search({ } const dedupeKey = `${queryJSON.hash}_${offset ?? 0}`; + const isTripRoomsSearch = shouldReturnTripRooms && queryJSON.type === CONST.SEARCH.DATA_TYPES.TRIP; const {footerTotal} = getFooterSelectionFromQuery(queryJSON); const inFlightRequest = inFlightSearchRequests.get(dedupeKey); if (inFlightRequest) { @@ -1371,7 +1374,9 @@ function search({ // Another footer breakdown shares the hash but not the total, so it re-fires after the in-flight one, and the // latest breakdown wins without racing it. Its caller gets the chain, so it waits until that total lands. const needsBreakdownRefire = footerTotal !== inFlightRequest.footerTotal; - if (needsTotalsUpgrade || needsSaveRecentSearchUpgrade || needsBreakdownRefire) { + // Trip rooms and travel expenses share the hash, so a request for the other one re-fires after the in-flight one + const needsTripRoomsRefire = isTripRoomsSearch !== inFlightRequest.shouldReturnTripRooms; + if (needsTotalsUpgrade || needsSaveRecentSearchUpgrade || needsBreakdownRefire || needsTripRoomsRefire) { // Accumulate desired flags so a later upgrade for one dimension can't drop an earlier // upgrade for the other. Only a single pending re-fire is kept. inFlightRequest.pendingShouldCalculateTotals = (inFlightRequest.pendingShouldCalculateTotals ?? inFlightRequest.shouldCalculateTotals) || shouldCalculateTotals; @@ -1395,7 +1400,7 @@ function search({ } return needsBreakdownRefire ? inFlightRequest.promise : undefined; } - const inFlightRequestState: InFlightSearchRequest = {shouldCalculateTotals, shouldSaveRecentSearch, footerTotal}; + const inFlightRequestState: InFlightSearchRequest = {shouldCalculateTotals, shouldSaveRecentSearch, footerTotal, shouldReturnTripRooms: isTripRoomsSearch}; inFlightSearchRequests.set(dedupeKey, inFlightRequestState); const onyxLoadingData = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, true, shouldCalculateTotals, shouldShowLoading); @@ -1409,7 +1414,7 @@ function search({ shouldSaveRecentSearch, // Backend expects 'maximumResults' instead of 'limit' ...(limit !== undefined && {maximumResults: limit}), - ...(shouldReturnTripRooms && queryJSON.type === CONST.SEARCH.DATA_TYPES.TRIP && {shouldReturnTripRooms: true}), + ...(isTripRoomsSearch && {shouldReturnTripRooms: true}), }; const jsonQuery = serializeQueryJSONForBackend(query); diff --git a/src/pages/DynamicReportChangeWorkspacePage.tsx b/src/pages/DynamicReportChangeWorkspacePage.tsx index 44561e603d14..c44687ee252c 100644 --- a/src/pages/DynamicReportChangeWorkspacePage.tsx +++ b/src/pages/DynamicReportChangeWorkspacePage.tsx @@ -27,6 +27,7 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportTransactions from '@hooks/useReportTransactions'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; +import useShouldReturnTripRooms from '@hooks/useShouldReturnTripRooms'; import useShouldSuppressPromotionalUI from '@hooks/useShouldSuppressPromotionalUI'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceList from '@hooks/useWorkspaceList'; @@ -134,6 +135,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace const initialSelection = useInitialSelection(currentSelection, {resetOnFocus: true}); const shouldFooterBeInsideList = useShouldFooterBeInsideList(); + const shouldReturnTripRooms = useShouldReturnTripRooms(currentSearchQueryJSON?.type); // The snapshot keeps the report row after a workspace change, and only the server can tell whether it still matches the query. const refreshSearch = () => { @@ -143,6 +145,7 @@ function DynamicReportChangeWorkspacePage({report}: DynamicReportChangeWorkspace shouldCalculateTotals, isOffline, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); }; diff --git a/tests/unit/Search/searchReturnTripRoomsFlagTest.ts b/tests/unit/Search/searchReturnTripRoomsFlagTest.ts index 21e7b46e33ae..fea569539623 100644 --- a/tests/unit/Search/searchReturnTripRoomsFlagTest.ts +++ b/tests/unit/Search/searchReturnTripRoomsFlagTest.ts @@ -31,6 +31,16 @@ function getLastRequestJsonQuery(): unknown { return parsedJsonQuery; } +/** Holds the next request open until the test resolves it. */ +function holdNextRequest() { + let resolveRequest: () => void = () => {}; + const requestPromise = new Promise<{jsonCode: number}>((resolve) => { + resolveRequest = () => resolve({jsonCode: CONST.JSON_CODE.SUCCESS}); + }); + mockedMakeRequestWithSideEffects.mockImplementationOnce(() => requestPromise); + return resolveRequest; +} + // The backend only returns one row per trip room when a trip search sends shouldReturnTripRooms, and keeps // type:trip an expense search otherwise, so these tests pin down when the flag is sent. describe('search shouldReturnTripRooms flag', () => { @@ -66,4 +76,25 @@ describe('search shouldReturnTripRooms flag', () => { // Then the flag isn't sent expect(getLastRequestJsonQuery()).not.toHaveProperty('shouldReturnTripRooms'); }); + it('re-fires a trip rooms search that collides with an in-flight travel expense search', async () => { + // Given a trip search without the flag still waiting for its response, e.g. fired before the beta loaded + const queryJSON = getQueryJSON('type:trip'); + const resolveExpenseRequest = holdNextRequest(); + const expenseSearch = search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.TRIPS, offset: 0, isLoading: false}); + + // When the same trip search is fired for trip rooms, which shares the hash and the offset + search({queryJSON, searchKey: CONST.SEARCH.SEARCH_KEYS.TRIPS, offset: 0, isLoading: false, shouldReturnTripRooms: true}); + + // Then it waits behind the in-flight request instead of being dropped + await Promise.resolve(); + expect(mockedMakeRequestWithSideEffects.mock.calls).toHaveLength(1); + + // When the travel expense request finishes + resolveExpenseRequest(); + await expenseSearch; + + // Then trip rooms are asked for afterward, so they are what lands on the snapshot + expect(mockedMakeRequestWithSideEffects.mock.calls).toHaveLength(2); + expect(getLastRequestJsonQuery()).toEqual(expect.objectContaining({shouldReturnTripRooms: true})); + }); }); diff --git a/tests/unit/SearchRefreshUtilsTest.ts b/tests/unit/SearchRefreshUtilsTest.ts new file mode 100644 index 000000000000..ad02f3358269 --- /dev/null +++ b/tests/unit/SearchRefreshUtilsTest.ts @@ -0,0 +1,59 @@ +import {search} from '@libs/actions/Search'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; +import refreshSearchAfterReportAction from '@libs/SearchRefreshUtils'; + +import CONST from '@src/CONST'; + +jest.mock('@libs/actions/Search', () => ({ + search: jest.fn(), +})); + +const mockedSearch = jest.mocked(search); + +function getQueryJSON(query: string) { + const queryJSON = buildSearchQueryJSON(query); + if (!queryJSON) { + throw new Error('Query JSON should be defined for test setup'); + } + return queryJSON; +} + +describe('refreshSearchAfterReportAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps asking for trip rooms when the search underneath is Trips', () => { + // Given an action on a report while the Trips search, with the TravelApproval beta, is the search underneath + const queryJSON = getQueryJSON('type:trip'); + + // When the search is refreshed after the action + refreshSearchAfterReportAction({ + currentSearchQueryJSON: queryJSON, + currentSearchKey: CONST.SEARCH.SEARCH_KEYS.TRIPS, + shouldCalculateTotals: false, + isOffline: false, + isLoading: false, + shouldReturnTripRooms: true, + }); + + // Then the refresh still asks for trip rooms, so it doesn't overwrite them with travel expenses + expect(mockedSearch).toHaveBeenCalledWith(expect.objectContaining({queryJSON, shouldReturnTripRooms: true})); + }); + + it('skips the refresh while offline', () => { + // Given an action on a report while offline + // When the search would be refreshed + refreshSearchAfterReportAction({ + currentSearchQueryJSON: getQueryJSON('type:trip'), + currentSearchKey: CONST.SEARCH.SEARCH_KEYS.TRIPS, + shouldCalculateTotals: false, + isOffline: true, + isLoading: false, + shouldReturnTripRooms: true, + }); + + // Then nothing is sent, since the request would only queue a stale refresh + expect(mockedSearch).not.toHaveBeenCalled(); + }); +}); From 8869a4d887d2e21a20bbba06a7a3958579d901bb Mon Sep 17 00:00:00 2001 From: Cong Pham Date: Tue, 27 Oct 2026 15:19:53 +0700 Subject: [PATCH 7/7] fix typecheck --- src/components/Search/SearchList/ListItem/TripListItem.tsx | 4 +++- src/components/Search/TripSearchView.tsx | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/TripListItem.tsx b/src/components/Search/SearchList/ListItem/TripListItem.tsx index ea25b9b30563..f88d2dbcbaef 100644 --- a/src/components/Search/SearchList/ListItem/TripListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TripListItem.tsx @@ -1,3 +1,4 @@ +import useIsSearchRowFocused from '@components/Search/hooks/useIsSearchRowFocused'; import ListItemComposed from '@components/SelectionList/ListItemComposed'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; @@ -17,8 +18,9 @@ import TripListItemRow from './TripListItemRow'; /** * A trip row in search results. Tapping the row or its View action opens the trip room. */ -function TripListItem({item, columns = [], isFocused, showTooltip, isDisabled, onSelectRow, onFocus, shouldSyncFocus, isLastItem}: TripListItemProps) { +function TripListItem({item, columns = [], showTooltip, isDisabled, onSelectRow, onFocus, shouldSyncFocus, isLastItem}: TripListItemProps) { const styles = useThemeStyles(); + const isFocused = useIsSearchRowFocused(item.keyForList); const StyleUtils = useStyleUtils(); const {pageGutterMargin} = useLayoutSpacing(); const {isLargeScreenWidth} = useResponsiveLayout(); diff --git a/src/components/Search/TripSearchView.tsx b/src/components/Search/TripSearchView.tsx index 81093600b237..5a49d3c2dd36 100644 --- a/src/components/Search/TripSearchView.tsx +++ b/src/components/Search/TripSearchView.tsx @@ -63,7 +63,7 @@ function TripSearchView({ const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0); const totalItems = data.filter((item) => !isRowDeleted(item)).length; - const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => ( + const renderItem = (item: SearchListItem, index: number, onFocus?: (event: NativeSyntheticEvent) => void) => ( // Trip rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.