diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 821f33fcb6b4..bb3d0544b7a4 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8254,6 +8254,11 @@ const CONST = { search: true, reportView: true, }, + DECLINED: { + column: this.TABLE_COLUMNS.DECLINED, + search: true, + reportView: false, + }, MCC: {column: this.TABLE_COLUMNS.MCC, search: true, reportView: true}, TAX_CODE: { column: this.TABLE_COLUMNS.TAX_CODE, @@ -8336,6 +8341,7 @@ const CONST = { EXPORTED: this.TABLE_COLUMNS.EXPORTED, STATUS: this.TABLE_COLUMNS.STATUS, PAID_STATUS: this.TABLE_COLUMNS.PAID_STATUS, + DECLINED: this.TABLE_COLUMNS.DECLINED, TITLE: this.TABLE_COLUMNS.TITLE, FROM: this.TABLE_COLUMNS.FROM, TO: this.TABLE_COLUMNS.TO, @@ -8550,6 +8556,7 @@ const CONST = { REIMBURSABLE: 'reimbursable', BILLABLE: 'billable', HELD: 'held', + DECLINED: 'declined', TAX_RATE: 'taxrate', TOTAL_AMOUNT: 'amount', ATTENDEES: 'attendees', @@ -8835,6 +8842,7 @@ const CONST = { [this.TABLE_COLUMNS.REIMBURSABLE]: 'reimbursable', [this.TABLE_COLUMNS.BILLABLE]: 'billable', [this.TABLE_COLUMNS.HELD]: 'held', + [this.TABLE_COLUMNS.DECLINED]: 'declined', [this.TABLE_COLUMNS.TAX_RATE]: 'tax-rate', [this.TABLE_COLUMNS.TOTAL_AMOUNT]: 'amount', [this.TABLE_COLUMNS.TOTAL]: 'total', diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx index b516f8fc4269..648436352fbf 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx @@ -10,6 +10,7 @@ import TextCell from '@components/Search/SearchList/ListItem/TextCell'; import TotalCell from '@components/Search/SearchList/ListItem/TotalCell'; import UserInfoCell from '@components/Search/SearchList/ListItem/UserInfoCell'; import WorkspaceCell from '@components/Search/SearchList/ListItem/WorkspaceCell'; +import YesNoCell from '@components/Search/SearchList/ListItem/YesNoCell'; import {useRowSelection} from '@components/Search/SearchSelectionProvider'; import {useListItemContext, useListItemHovered} from '@components/SelectionList/ListItemContext'; @@ -158,6 +159,11 @@ function ExpenseReportListItemRowWide({ /> ), + [CONST.SEARCH.TABLE_COLUMNS.DECLINED]: ( + + + + ), [CONST.SEARCH.TABLE_COLUMNS.TITLE]: ( + {value ? translate('common.yes') : translate('common.no')} + + ); +} + +export default YesNoCell; diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 25a0db59e6f8..8d81f6e2a859 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -30,8 +30,10 @@ import type { SearchMerchantGroup, SearchMonthGroup, SearchQuarterGroup, + SearchReport, SearchTagGroup, SearchTask, + SearchTransaction, SearchTransactionAction, SearchWeekGroup, SearchWithdrawalIDGroup, @@ -71,7 +73,7 @@ type ExpenseReportListItemProps = ListItemProps & }; type TransactionListItemType = ListItem & - Transaction & { + SearchTransaction & { /** Report to which the transaction belongs */ report: Report | undefined; @@ -227,7 +229,7 @@ type TransactionGroupListItemType = ListItem & { type ExpenseReportListItemType = TransactionReportGroupListItemType; -type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} & Report & { +type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} & SearchReport & { /** The personal details of the user requesting money */ from: PersonalDetails; diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index 82029790529d..55a737cf1e52 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -140,6 +140,10 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [ columnName: CONST.SEARCH.TABLE_COLUMNS.HELD, translationKey: 'search.filters.held', }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.DECLINED, + translationKey: 'search.filters.rejected', + }, { columnName: CONST.SEARCH.TABLE_COLUMNS.MCC, translationKey: 'common.mcc', @@ -296,6 +300,10 @@ const getExpenseReportHeaders = (profileIcon?: IconAsset): SearchColumnConfig[] columnName: CONST.SEARCH.TABLE_COLUMNS.PAID_STATUS, translationKey: 'common.paidStatus', }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.DECLINED, + translationKey: 'search.filters.rejected', + }, { columnName: CONST.SEARCH.TABLE_COLUMNS.TITLE, translationKey: 'common.title', diff --git a/src/components/Search/SortableTableHeader.tsx b/src/components/Search/SortableTableHeader.tsx index 868623144a45..46e24c729fa7 100644 --- a/src/components/Search/SortableTableHeader.tsx +++ b/src/components/Search/SortableTableHeader.tsx @@ -84,7 +84,10 @@ function SortableTableHeader({ const sortByColumnName = sortColumnName ?? columnName; const isActive = sortBy === sortByColumnName; const isYesNoColumn = - columnName === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE || columnName === CONST.SEARCH.TABLE_COLUMNS.BILLABLE || columnName === CONST.SEARCH.TABLE_COLUMNS.HELD; + columnName === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE || + columnName === CONST.SEARCH.TABLE_COLUMNS.BILLABLE || + columnName === CONST.SEARCH.TABLE_COLUMNS.HELD || + columnName === CONST.SEARCH.TABLE_COLUMNS.DECLINED; const isConversionAmountColumn = columnName === CONST.SEARCH.TABLE_COLUMNS.GROUP_AMOUNT_DEBITED || columnName === CONST.SEARCH.TABLE_COLUMNS.GROUP_AMOUNT_REIMBURSED || diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 629dfcefb562..2d8188417b67 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -12,8 +12,8 @@ import TextCell from '@components/Search/SearchList/ListItem/TextCell'; import AmountCell from '@components/Search/SearchList/ListItem/TotalCell'; import UserInfoCell from '@components/Search/SearchList/ListItem/UserInfoCell'; import WorkspaceCell from '@components/Search/SearchList/ListItem/WorkspaceCell'; +import YesNoCell from '@components/Search/SearchList/ListItem/YesNoCell'; import type {SearchColumnType} from '@components/Search/types'; -import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -185,12 +185,7 @@ function TransactionItemRowWide({ key={columnName} style={[getSearchColumnStyles(columnName)]} > - - {value ? translate('common.yes') : translate('common.no')} - + ); @@ -374,6 +369,8 @@ function TransactionItemRowWide({ return renderYesNoCell(column, !!transactionItem.billable); case CONST.SEARCH.TABLE_COLUMNS.HELD: return renderYesNoCell(column, isOnHold(transactionItem)); + case CONST.SEARCH.TABLE_COLUMNS.DECLINED: + return renderYesNoCell(column, !!transactionItem.declined); case CONST.SEARCH.TABLE_COLUMNS.ACTION: return ( { + // Ranked as 'yes'/'no' to match the backend, with a report that never got the field ranked as not rejected. + const aValue = a.declined ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; + const bValue = b.declined ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; + return compareValues(aValue, bValue, sortOrder, sortBy, localeCompare); + }); + } + const sortingProperty = sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE || sortBy === CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE ? undefined : expenseReportColumnNamesToSortingProperty[sortBy]; @@ -4551,6 +4560,8 @@ function getSearchColumnTranslationKey(column: SearchSortBy, type?: SearchDataTy return 'common.billable'; case CONST.SEARCH.TABLE_COLUMNS.HELD: return 'search.filters.held'; + case CONST.SEARCH.TABLE_COLUMNS.DECLINED: + return 'search.filters.rejected'; case CONST.SEARCH.TABLE_COLUMNS.MCC: return 'common.mcc'; case CONST.SEARCH.TABLE_COLUMNS.TAX_CODE: @@ -6776,6 +6787,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE]: false, [CONST.SEARCH.TABLE_COLUMNS.BILLABLE]: false, [CONST.SEARCH.TABLE_COLUMNS.HELD]: false, + [CONST.SEARCH.TABLE_COLUMNS.DECLINED]: false, [CONST.SEARCH.TABLE_COLUMNS.MCC]: false, [CONST.SEARCH.TABLE_COLUMNS.TAX_CODE]: false, [CONST.SEARCH.TABLE_COLUMNS.TAX_RATE]: false, @@ -7183,6 +7195,7 @@ function getTableMinWidth( column === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE || column === CONST.SEARCH.TABLE_COLUMNS.BILLABLE || column === CONST.SEARCH.TABLE_COLUMNS.HELD || + column === CONST.SEARCH.TABLE_COLUMNS.DECLINED || column === CONST.SEARCH.TABLE_COLUMNS.MCC || column === CONST.SEARCH.TABLE_COLUMNS.TAX_CODE ) { diff --git a/src/libs/getTransactionFromTransactionListItem.ts b/src/libs/getTransactionFromTransactionListItem.ts index 7be736d4dfa2..aaa7256232cb 100644 --- a/src/libs/getTransactionFromTransactionListItem.ts +++ b/src/libs/getTransactionFromTransactionListItem.ts @@ -42,6 +42,7 @@ const SEARCH_TRANSACTION_ROW_ONLY_FIELDS: Record ({ case CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE: case CONST.SEARCH.TABLE_COLUMNS.BILLABLE: case CONST.SEARCH.TABLE_COLUMNS.HELD: + case CONST.SEARCH.TABLE_COLUMNS.DECLINED: columnWidth = {...getWidthStyle(variables.w80)}; break; case CONST.SEARCH.TABLE_COLUMNS.MCC: diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index f92f71490bc6..4e186aac9a4e 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -285,12 +285,24 @@ type SearchQuarterGroup = SearchGroupBase & { quarter: number; }; +/** Model of an expense in the search results */ +type SearchTransaction = Transaction & { + /** Whether the expense was ever rejected, only returned when the Rejected column is shown or sorted by */ + declined?: boolean; +}; + +/** Model of a report in the search results */ +type SearchReport = Report & { + /** Whether the expense report was ever rejected, only returned when the Rejected column is shown or sorted by */ + declined?: boolean; +}; + /** SearchResultDataType */ -type SearchResultDataType = PrefixedRecord & +type SearchResultDataType = PrefixedRecord & Partial | undefined>> & PrefixedRecord> & Partial> & - PrefixedRecord & + PrefixedRecord & PrefixedRecord & PrefixedRecord & PrefixedRecord & @@ -326,6 +338,8 @@ export default SearchResults; export type { ListItemDataType, SearchTask, + SearchTransaction, + SearchReport, SearchTransactionAction, SearchDataTypes, SearchResultsInfo, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 938e5656a8b9..0e1f73b0c4dd 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -8636,6 +8636,63 @@ describe('SearchUIUtils', () => { ]); }); + it('should sort expenses by the Rejected column, ranking expenses without the field as not rejected', () => { + // Given a rejected expense, a never rejected one, and one returned without the field, with `inserted` set so + // the tie breaker between the equal rows is predictable + const baseTransaction = transactionsListItems.at(0); + if (!baseTransaction) { + throw new Error('Missing base transaction fixture'); + } + const transactions: TransactionListItemType[] = [ + {...baseTransaction, transactionID: 'rejected', keyForList: 'rejected', inserted: '2024-01-01', declined: true}, + {...baseTransaction, transactionID: 'not-rejected', keyForList: 'not-rejected', inserted: '2024-02-02', declined: false}, + {...baseTransaction, transactionID: 'no-field', keyForList: 'no-field', inserted: '2024-03-03'}, + ]; + + // When the expenses are sorted by Rejected in each direction + const sortTransactions = (sortOrder: SortOrder) => + SearchUIUtils.getSortedSections( + CONST.SEARCH.DATA_TYPES.EXPENSE, + [...transactions], + localeCompare, + translateLocal, + CONST.SEARCH.TABLE_COLUMNS.DECLINED, + sortOrder, + undefined, + ).map((item) => ('transactionID' in item ? item.transactionID : undefined)); + + // Then the expense without the field ranks with the not rejected one, the way its cell shows "No", so the local + // sort agrees with the backend's 'yes'/'no' ranking + expect(sortTransactions(CONST.SEARCH.SORT_ORDER.ASC)).toEqual(['not-rejected', 'no-field', 'rejected']); + expect(sortTransactions(CONST.SEARCH.SORT_ORDER.DESC)).toEqual(['rejected', 'no-field', 'not-rejected']); + }); + + it('should sort expense reports by the Rejected column, ranking reports without the field as not rejected', () => { + // Given a rejected expense report, a never rejected one, and one returned without the field + const createReport = (keyForList: string, declined?: boolean) => + createMock({ + groupedBy: 'expense-report', + keyForList, + reportID: keyForList, + created: '2025-08-12 17:11:22', + currency: 'USD', + transactions: [], + ...(declined === undefined ? {} : {declined}), + }); + const reports = [createReport('report_rejected', true), createReport('report_not_rejected', false), createReport('report_no_field')]; + + // When the reports are sorted by Rejected in each direction + const sortReports = (sortOrder: SortOrder) => + SearchUIUtils.getSortedSections(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, [...reports], localeCompare, translateLocal, CONST.SEARCH.TABLE_COLUMNS.DECLINED, sortOrder).map( + (report) => report.keyForList, + ); + + // Then the report without the field ranks with the not rejected one, the way its cell shows "No", and equal + // reports keep the order the backend returned them in + expect(sortReports(CONST.SEARCH.SORT_ORDER.ASC)).toStrictEqual(['report_not_rejected', 'report_no_field', 'report_rejected']); + expect(sortReports(CONST.SEARCH.SORT_ORDER.DESC)).toStrictEqual(['report_rejected', 'report_not_rejected', 'report_no_field']); + }); + it('should return getSortedReportData result when type is expense-report', () => { expect(SearchUIUtils.getSortedSections(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, transactionReportGroupListItems, localeCompare, translateLocal, 'date', 'asc')).toStrictEqual( transactionReportGroupListItems, @@ -12088,6 +12145,40 @@ describe('SearchUIUtils', () => { expect(result).toContain(CONST.SEARCH.TABLE_COLUMNS.TOTAL); }); + test('Should keep the Rejected column hidden on expense searches until the user picks it', () => { + // Given an expense the backend marked as rejected + const rejectedTransaction = {...createRandomTransaction(4), declined: true}; + const pickedColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.DECLINED, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; + + // When the columns are resolved with and without the user picking Rejected + const defaultColumns = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [rejectedTransaction], visibleColumns: [], type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + const pickedResult = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [rejectedTransaction], visibleColumns: pickedColumns, type: CONST.SEARCH.DATA_TYPES.EXPENSE}); + + // Then Rejected is offered in the Search column picker but only shown once picked, and it is not offered in the + // single-report view because the report view's transactions never carry the field + expect(SearchUIUtils.getCustomColumns(CONST.SEARCH.DATA_TYPES.EXPENSE)).toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED); + expect(SearchUIUtils.getCustomColumnDefault(CONST.SEARCH.DATA_TYPES.EXPENSE)).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED); + expect(Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS)).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED); + expect(defaultColumns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED); + expect(pickedResult).toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED); + }); + + test('Should keep the Rejected column hidden on expense report searches until the user picks it', () => { + // Given the column lists for expense report searches + const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.DECLINED, CONST.SEARCH.TABLE_COLUMNS.TOTAL]; + + // When the columns are resolved with and without the user picking Rejected + const defaultColumns = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [], visibleColumns: [], type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}); + const pickedResult = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [], visibleColumns, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}); + + // Then Rejected is offered in the expense report column picker, stays out of the defaults, and is shown in the + // picked position once chosen, so it is sent in the query columns the backend reads to return the field + expect(SearchUIUtils.getCustomColumns(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT)).toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED); + expect(SearchUIUtils.getCustomColumnDefault(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT)).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED); + expect(defaultColumns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED); + expect(pickedResult).toEqual(visibleColumns); + }); + test('Should not force Avatar for group-by:from when toggled off', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.GROUP_FROM, CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL]; @@ -13502,6 +13593,17 @@ describe('SearchUIUtils', () => { const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.WITHDRAWAL_ID); expect(translationKey).toBe('common.withdrawalID'); }); + + it('should label the DECLINED column with the Rejected filter label', () => { + // Given the Rejected column, whose header is also sent as the export column label + // When its translation key is resolved for expenses and expense reports + const expenseKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.DECLINED, CONST.SEARCH.DATA_TYPES.EXPENSE); + const expenseReportKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.DECLINED, CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); + + // Then it reuses the declined: filter's "Rejected" label, so the column, the filter, and the export header match + expect(expenseKey).toBe('search.filters.rejected'); + expect(expenseReportKey).toBe('search.filters.rejected'); + }); }); describe('createAndOpenSearchTransactionThread', () => { diff --git a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap index d4443f54c67c..a2baf61ab80c 100644 --- a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap +++ b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap @@ -63,6 +63,7 @@ exports[`Column availability single source of truth the derived picker lists mat "reimbursable", "billable", "held", + "declined", "mcc", "taxCode", "taxrate",