diff --git a/src/CONST/index.ts b/src/CONST/index.ts index bf1757295b33..946fee74f128 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8458,7 +8458,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: {}, }; }, @@ -8562,7 +8570,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: [], }; }, @@ -8631,6 +8647,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', @@ -8711,6 +8733,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/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 87286315af6f..4554f22c0855 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 c994ba9cfbd0..c0470de23ef7 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 6e98feaed827..a7198633f315 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -20,6 +20,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'; @@ -135,6 +136,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); @@ -214,6 +216,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 597aefe1541b..74679b1bcf6f 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 @@ -64,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]; @@ -88,7 +91,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) && ( + + 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..18f0063b0200 --- /dev/null +++ b/src/components/Search/SearchList/ListItem/TripListItemRow.tsx @@ -0,0 +1,154 @@ +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 = { + item: TripListItemType; + + /** Columns to show on wide screens */ + columns: SearchColumnType[]; + + showTooltip: boolean; + + 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..ea2e1d04b36f --- /dev/null +++ b/src/components/Search/SearchList/ListItem/TripStatusCell.tsx @@ -0,0 +1,43 @@ +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 = { + 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 716468ec9f6d..aa76674e3ecc 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -35,6 +35,7 @@ import type { SearchTask, SearchTransaction, SearchTransactionAction, + SearchTrip, SearchWeekGroup, SearchWithdrawalIDGroup, SearchYearGroup, @@ -406,6 +407,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 */ @@ -423,7 +444,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 & { @@ -626,6 +647,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 fedeeef53f1d..5d68f5291286 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'; @@ -164,6 +165,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}); @@ -656,6 +658,7 @@ function SearchSelectionFooter({searchResults, style}: SearchSelectionFooterProp shouldCalculateTotals: true, isLoading: false, shouldShowLoading: false, + shouldReturnTripRooms, }), ).finally(() => setIsAwaitingBreakdownTotal(false)); } @@ -671,8 +674,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 a7a01e0dfb49..7731c84c19be 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'; @@ -261,6 +262,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', + sortColumnName: CONST.SEARCH.TABLE_COLUMNS.DATE, + 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', + sortColumnName: CONST.SEARCH.TABLE_COLUMNS.TITLE, + }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.TRAVELER, + translationKey: 'search.trip.traveler', + sortColumnName: CONST.SEARCH.TABLE_COLUMNS.FROM, + }, + { + 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, @@ -542,7 +582,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) { @@ -552,7 +598,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: @@ -614,6 +660,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; @@ -622,7 +669,7 @@ function SearchTableHeader({ return columns.includes(columnName); }; - const columnConfig = getSearchColumns(type, icons, groupBy, isExpenseReportView); + const columnConfig = getSearchColumns(type, icons, groupBy, isExpenseReportView, shouldReturnTripRooms); let orderedColumnConfig; if (!columnConfig) { diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 4836bd61d8cb..cf9ad99b6b86 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/TripSearchView.tsx b/src/components/Search/TripSearchView.tsx new file mode 100644 index 000000000000..5a49d3c2dd36 --- /dev/null +++ b/src/components/Search/TripSearchView.tsx @@ -0,0 +1,139 @@ +import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types'; + +import {isTripListItemType} from '@libs/SearchUIUtils'; + +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, onFocus?: (event: NativeSyntheticEvent) => void) => ( + // Trip rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip. + + {isTripListItemType(item) && ( + + )} + + ); + + 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 09797da47273..f29acd32ae8f 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'; @@ -151,6 +152,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(); @@ -250,6 +252,7 @@ function useSearchSnapshot({queryJSON, searchResults, transactions, reportAction convertToDisplayString, reportAttributesDerivedValue: reportAttributesForSections, optimisticTransactionID, + shouldReturnTripRooms, } : undefined, ); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index bf49694773d5..409e8c7c73e8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -20,6 +20,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'; @@ -57,6 +58,7 @@ import { isSearchPending, isSearchResultsEmpty as isSearchResultsEmptyUtil, isTaskListItemType, + isTripListItemType, isTransactionGroupListItemType, isTransactionListItemType, isTransactionReportGroupListItemType, @@ -124,6 +126,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; @@ -202,6 +205,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 @@ -678,6 +682,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(); @@ -975,7 +988,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); @@ -1531,10 +1544,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) { @@ -1561,7 +1574,8 @@ function Search({ isTaxAmountColumnWide={shouldShowTaxAmountInWideColumn} shouldShowSorting groupBy={validGroupBy} - isExpenseReportView={isExpenseReportType} + // Trip rows share the expense report layout (Total without flex), so the header aligns the same way + isExpenseReportView={isExpenseReportType || shouldReturnTripRooms} isActionColumnWide={isActionColumnWide} /> @@ -1623,6 +1637,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/selectionBuilders.ts b/src/components/Search/selectionBuilders.ts index 16522b0cb84a..e5690c1e36c7 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/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/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/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/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 785aca96d34f..d68167f59584 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -173,6 +173,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'; @@ -633,6 +634,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; @@ -3000,6 +3002,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCalculateTotals: shouldCalculateTotalsOnRefresh, isOffline, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); clearSelectedTransactions(); }, @@ -3081,6 +3084,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { shouldCalculateTotals: shouldCalculateTotalsOnRefresh, isOffline, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); clearSelectedTransactions(); }; @@ -3497,6 +3501,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { hasUnloadedMatchingExpenses, isUnreportedOnlyQuery, currentSearchQueryJSON, + shouldReturnTripRooms, currentSearchResults?.search?.isLoading, shouldCalculateTotalsOnRefresh, rules, 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/useSelectionModePayment.ts b/src/hooks/useSelectionModePayment.ts index f65005a67721..03f280660bb6 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); @@ -247,6 +249,7 @@ function useSelectionModePayment({ shouldCalculateTotals, isOffline, isLoading: !!currentSearchResults?.search?.isLoading, + shouldReturnTripRooms, }); } 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 f81370936d01..8b5baad4bf5f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -10786,6 +10786,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 576923fa7a55..6a37d4546cfd 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10663,6 +10663,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 c8bdaa542c82..482efce6ea56 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10689,6 +10689,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 43a401c5de6c..2f934aa87574 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10298,6 +10298,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 16025f958d57..a346cd6df266 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10812,6 +10812,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 c69b696bf807..a170cf2c5349 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10740,6 +10740,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 be5282d2266d..9050f03d951c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10578,6 +10578,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 60520425de68..7feb954ba623 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10709,6 +10709,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 3ecfb0add1a4..ca1d7800fe14 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -10731,6 +10731,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 fb2be3403595..682395aa2bda 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10702,6 +10702,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 40a0d421f177..8678efd4b6a5 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -10314,6 +10314,15 @@ ${reportName}`, pleaseSelectDatesForBothFromAndTo: '请选择起始和结束日期', }, spendOverTime: '随时间支出', + trip: { + tripName: '行程名称', + traveler: '出行人', + status: { + upcoming: '即将开始', + done: '已完成', + canceled: '已取消', + }, + }, tabs: { expenseReports: '报销报告', reports: '报表', 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/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index ef88143a5a3e..36c670af1996 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 { @@ -260,6 +261,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; @@ -273,6 +276,7 @@ type GroupBySection = { type TransactionSorting = ColumnSortMapping; type TaskSorting = ColumnSortMapping; type ExpenseReportSorting = ColumnSortMapping; +type TripSorting = ColumnSortMapping; // Transaction Group sorting type TransactionMemberGroupSorting = ColumnSortMapping; @@ -361,6 +365,14 @@ 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.TITLE]: 'tripName' as const, + [CONST.SEARCH.TABLE_COLUMNS.FROM]: 'formattedTraveler' as const, + [CONST.SEARCH.TABLE_COLUMNS.STATUS]: 'status' 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, @@ -477,6 +489,8 @@ const nonSortableColumns = new Set([ CONST.SEARCH.TABLE_COLUMNS.IN, CONST.SEARCH.TABLE_COLUMNS.AVATAR, CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS, + // The trip search sorts by start date, trip name and traveler, but not by end date + CONST.SEARCH.TABLE_COLUMNS.END_DATE, ]); function isValidTaskStatus(status: unknown): status is ValueOf { @@ -617,6 +631,7 @@ type GetSectionsResult = [ | TransactionWeekGroupListItemType[] | TransactionYearGroupListItemType[] | TransactionQuarterGroupListItemType[] + | TripListItemType[] ), number, boolean, @@ -655,6 +670,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; }; /** @@ -1009,6 +1026,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 + */ +function isTripListItemType(item: SearchListItem): 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 */ @@ -2408,6 +2432,32 @@ function getRowCapabilities(allActions: SearchTransactionAction[]): RowCapabilit }; } +/** + * Organizes trip rooms from a trip search into one row per trip. + * + * Do not use directly, use only via `getSections()` facade. + */ +function getTripSections(data: OnyxTypes.SearchResults['data'], formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], translate: LocalizedTranslate): [TripListItemType[], number] { + const trips = Object.keys(data) + .filter(isReportEntry) + .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 + 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]; +} + /** * @private * Organizes data into List Sections for display, for the TaskListItemType of Search Results. @@ -3854,6 +3904,7 @@ function getSections({ convertToDisplayString, optimisticTransactionID, reportAttributesDerivedValue, + shouldReturnTripRooms = false, }: GetSectionsParams): GetSectionsResult { if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return [...getReportActionsSections(data, reportAttributesDerivedValue, visibleReportActionsData), false]; @@ -3861,6 +3912,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({ @@ -4006,6 +4060,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, localeCompare, sortBy, sortOrder); + } if (groupBy) { const sortFunction = groupBySortFunction[groupBy]; @@ -4311,6 +4368,24 @@ function getSortedTaskData(data: TaskListItemType[], localeCompare: LocaleContex }); } +/** + * Sorts trip rows based on a specified column and sort order. + */ +function getSortedTripData(data: SearchListItem[], localeCompare: LocaleContextProps['localeCompare'], sortBy?: SearchSortBy, sortOrder?: SortOrder) { + const trips = data.filter(isTripListItemType); + if (!sortBy || !sortOrder) { + 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 trips; + } + + return trips.sort((a, b) => compareValues(a[sortingProperty], b[sortingProperty], sortOrder, sortingProperty, localeCompare)); +} + /** * @private * Sorts report sections based on a specified column and sort order. @@ -4596,7 +4671,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'; @@ -4753,6 +4840,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; @@ -4764,6 +4859,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; } @@ -6712,6 +6817,7 @@ function getColumnsToShow({ sortBy, shouldShowViolationsColumn = false, isVendorColumnAvailable = true, + shouldReturnTripRooms = false, }: { currentAccountID: number | undefined; data: OnyxTypes.SearchResults['data'] | OnyxTypes.Transaction[]; @@ -6731,6 +6837,7 @@ function getColumnsToShow({ sortBy?: SearchSortBy; shouldShowViolationsColumn?: boolean; isVendorColumnAvailable?: boolean; + shouldReturnTripRooms?: boolean; }): SearchColumnType[] { const reportCustomColumns = new Set([ CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_USER_ID, @@ -6811,6 +6918,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, @@ -7333,7 +7451,9 @@ function getTableMinWidth( column === CONST.SEARCH.TABLE_COLUMNS.PAID || 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) { @@ -7674,6 +7794,7 @@ export { isCorrectSearchUserName, isReportActionEntry, isTaskListItemType, + isTripListItemType, getActions, getPrimaryAction, createTypeMenuSections, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index e1a885af9868..1407aae86c47 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1241,6 +1241,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; @@ -1331,6 +1333,7 @@ function search({ skipWaitForWrites = false, shouldSaveRecentSearch = false, shouldShowLoading = true, + shouldReturnTripRooms = false, }: { queryJSON: Readonly; searchKey: SearchKey | undefined; @@ -1356,12 +1359,15 @@ 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; } 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) { @@ -1375,7 +1381,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; @@ -1394,11 +1402,12 @@ 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; } - 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); @@ -1412,6 +1421,7 @@ function search({ shouldSaveRecentSearch, // Backend expects 'maximumResults' instead of 'limit' ...(limit !== undefined && {maximumResults: limit}), + ...(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/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index 01703b4be243..9f98854b6fa0 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 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 = [...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/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 167ab5cb67ed..5d55790cf5f8 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'; @@ -161,11 +162,13 @@ function SearchPage({route}: SearchPageProps) { const contentQueryJSON = shouldHoldLastResolvedSearch ? lastResolvedSearch.queryJSON : currentSearchQueryJSON; const contentSearchResults = shouldHoldLastResolvedSearch ? lastResolvedSearch.searchResults : searchResults; + const {isBetaEnabled} = usePermissions(); + const isTravelApprovalEnabled = isBetaEnabled(CONST.BETAS.TRAVEL_APPROVAL); const handleSearchAction = (value: SearchParams | string) => { if (typeof value === 'string') { searchInServer(value); } else { - search(value); + search({...value, shouldReturnTripRooms: isTravelApprovalEnabled}); } }; diff --git a/src/pages/Search/SearchPageNarrow/index.tsx b/src/pages/Search/SearchPageNarrow/index.tsx index 1cb42a2e5b91..d7ad063b5f38 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,11 +174,13 @@ function SearchPageNarrow({ const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery(), searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES})); + const {isBetaEnabled} = usePermissions(); + const isTravelApprovalEnabled = isBetaEnabled(CONST.BETAS.TRAVEL_APPROVAL); const handleSearchAction = (value: SearchParams | string) => { if (typeof value === 'string') { searchInServer(value); } else { - search(value); + search({...value, shouldReturnTripRooms: isTravelApprovalEnabled}); } }; diff --git a/src/pages/Search/SearchSavePage.tsx b/src/pages/Search/SearchSavePage.tsx index afa8cd4e390f..7237b4cf5f57 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) { @@ -153,7 +159,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) { @@ -207,7 +214,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/styles/utils/index.ts b/src/styles/utils/index.ts index 042bb70b6135..4997382fce2d 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2067,6 +2067,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 4e186aac9a4e..0e94821d27bd 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 */ @@ -302,6 +320,8 @@ type SearchResultDataType = PrefixedRecord | undefined>> & PrefixedRecord> & Partial> & + // Trip rooms returned by a trip search carry the trip's fields on top of the report + PrefixedRecord & PrefixedRecord & PrefixedRecord & PrefixedRecord & @@ -338,6 +358,7 @@ export default SearchResults; export type { ListItemDataType, SearchTask, + SearchTrip, SearchTransaction, SearchReport, SearchTransactionAction, 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 b359d8632fbc..6e8d320f463b 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 f6946e96392d..60d3a651bd4f 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -44,7 +44,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 */ @@ -16539,6 +16539,166 @@ describe('getDatePresets', () => { }); }); +describe('Trip search', () => { + 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', + 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: 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'}, + }, + 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'}), + }; + + 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('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).at(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) => + 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]); + }); +}); + describe('isFilterMatchingSearchValue', () => { beforeAll(async () => { await IntlStore.load('en'); diff --git a/tests/unit/Search/buildShiftRangeSourceTest.ts b/tests/unit/Search/buildShiftRangeSourceTest.ts index ebfb8804ab77..354663b5c381 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: TripListItemType = { + reportID: '1', + keyForList: '1', + chatType: CONST.REPORT.CHAT_TYPE.TRIP_ROOM, + travelerAccountID: 1, + tripName: 'Trip', + traveler: {accountID: 1}, + formattedTraveler: 'Traveler', + }; + + // 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 new file mode 100644 index 000000000000..fea569539623 --- /dev/null +++ b/tests/unit/Search/searchReturnTripRoomsFlagTest.ts @@ -0,0 +1,100 @@ +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(): 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'); + } + const parsedJsonQuery: unknown = JSON.parse(requestParams.jsonQuery); + 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', () => { + 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'); + }); + 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(); + }); +});