diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6e9546ea95f6..fba67fa99df4 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6434,6 +6434,7 @@ const CONST = { }, SORT_BY_COLUMNS: { CATEGORY_GL_CODE: 'glcode', + TAG_GL_CODE: 'tagglcode', }, GROUP_BY: { FROM: 'from', @@ -6480,6 +6481,7 @@ const CONST = { ATTENDEES: {column: this.TABLE_COLUMNS.ATTENDEES, search: true, reportView: true}, TOTAL_PER_ATTENDEE: {column: this.TABLE_COLUMNS.TOTAL_PER_ATTENDEE, search: true, reportView: true}, TAG: {column: this.TABLE_COLUMNS.TAG, search: true, reportView: true}, + TAG_GL_CODE: {column: this.TABLE_COLUMNS.TAG_GL_CODE, search: true, reportView: true}, EXCHANGE_RATE: {column: this.TABLE_COLUMNS.EXCHANGE_RATE, search: true, reportView: true}, ORIGINAL_AMOUNT: {column: this.TABLE_COLUMNS.ORIGINAL_AMOUNT, search: true, reportView: true}, REPORT_ID: {column: this.TABLE_COLUMNS.REPORT_ID, search: true, reportView: false}, @@ -6734,6 +6736,7 @@ const CONST = { MCC: 'mcc', TAX_CODE: 'taxCode', CATEGORY_GL_CODE: 'categoryGLCode', + TAG_GL_CODE: 'tagGLCode', WITHDRAWAL_ID: 'withdrawalID', SUBMITTER_USER_ID: 'submitterUserID', SUBMITTER_PAYROLL_ID: 'submitterPayrollID', @@ -6954,6 +6957,7 @@ const CONST = { [this.TABLE_COLUMNS.MCC]: 'mcc', [this.TABLE_COLUMNS.TAX_CODE]: 'tax-code', [this.TABLE_COLUMNS.CATEGORY_GL_CODE]: 'category-gl-code', + [this.TABLE_COLUMNS.TAG_GL_CODE]: 'tag-gl-code', [this.TABLE_COLUMNS.WITHDRAWAL_ID]: 'withdrawal-id', [this.TABLE_COLUMNS.AVATAR]: 'avatar', [this.TABLE_COLUMNS.STATUS]: 'status', diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx index b389523f5a21..11081eda1b4f 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx @@ -21,7 +21,7 @@ import {hasFlexColumn} from '@libs/SearchUIUtils'; import {getTransactionPendingAction, isTransactionPendingDelete} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {CardList, Policy, PolicyCategories, Report, TransactionViolations} from '@src/types/onyx'; +import type {CardList, Policy, PolicyCategories, PolicyTagLists, Report, TransactionViolations} from '@src/types/onyx'; import type {TransactionWithOptionalHighlight} from './MoneyRequestReportTransactionList'; type MoneyRequestReportTransactionItemProps = { @@ -40,6 +40,9 @@ type MoneyRequestReportTransactionItemProps = { /** Categories for the policy to which the transaction belongs */ policyCategories?: PolicyCategories; + /** Tag lists for the policy to which the transaction belongs */ + policyTagLists?: PolicyTagLists; + /** Whether the mobile selection mode is enabled */ isSelectionModeEnabled: boolean; @@ -104,6 +107,7 @@ function MoneyRequestReportTransactionItemBody({ report, policy, policyCategories, + policyTagLists, isSelectionModeEnabled, toggleTransaction, isSelected, @@ -201,6 +205,7 @@ function MoneyRequestReportTransactionItemBody({ report={report} policy={policy} policyCategories={policyCategories} + policyTagLists={policyTagLists} transactionThreadReportID={transactionThreadReportID} isSelected={isSelected} dateColumnSize={dateColumnSize} diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index f7e8aa9a6fdb..dcb21970d5f3 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -206,6 +206,7 @@ function MoneyRequestReportTransactionList({ const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); + const [policyTagLists] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); const shouldShowGroupedTransactions = isExpenseReport(report) && !isIOUReport(report); @@ -378,15 +379,15 @@ function MoneyRequestReportTransactionList({ } } return compareValues( - getTransactionSortValue(a, sortBy, report, policy, policyCategories), - getTransactionSortValue(b, sortBy, report, policy, policyCategories), + getTransactionSortValue(a, sortBy, report, policy, policyCategories, policyTagLists), + getTransactionSortValue(b, sortBy, report, policy, policyCategories, policyTagLists), sortOrder, sortBy, localeCompare, true, ); }); - }, [sortBy, sortOrder, transactions, localeCompare, report, policy, policyCategories, rbrTransactionIDs]); + }, [sortBy, sortOrder, transactions, localeCompare, report, policy, policyCategories, policyTagLists, rbrTransactionIDs]); const resolvedTransactions = useMemo(() => resolveTransactionCardFields(sortedTransactions, cardList, translate), [sortedTransactions, cardList, translate]); @@ -409,19 +410,8 @@ function MoneyRequestReportTransactionList({ shouldShowReimbursableColumn: hasNonReimbursableTransactions(transactions), reportCurrency: report?.currency, isPolicyTaxEnabled: isTaxEnabled, - policyCategories, }); - }, [ - transactions, - currentUserDetails?.accountID, - isExpenseReportViewFromIOUReport, - shouldShowBillableColumn, - shouldShowCommentsColumn, - reportDetailsColumns, - report, - isTaxEnabled, - policyCategories, - ]); + }, [transactions, currentUserDetails?.accountID, isExpenseReportViewFromIOUReport, shouldShowBillableColumn, shouldShowCommentsColumn, reportDetailsColumns, report, isTaxEnabled]); const {windowWidth, windowHeight} = useWindowDimensions(); const minTableWidth = getTableMinWidth(columnsToShow); @@ -710,6 +700,7 @@ function MoneyRequestReportTransactionList({ report={report} policy={policy} policyCategories={policyCategories} + policyTagLists={policyTagLists} isSelectionModeEnabled={isMobileSelectionModeEnabled} toggleTransaction={toggleTransaction} isSelected={isTransactionSelected(transaction.transactionID)} diff --git a/src/components/Search/FilterDropdowns/SortByPopup.tsx b/src/components/Search/FilterDropdowns/SortByPopup.tsx index 3624b564f96d..89783792269e 100644 --- a/src/components/Search/FilterDropdowns/SortByPopup.tsx +++ b/src/components/Search/FilterDropdowns/SortByPopup.tsx @@ -44,12 +44,11 @@ function SortByPopup({searchResults, queryJSON, groupBy, onSort, onSortOrderPres const {clearSelectedTransactions} = useSearchSelectionActions(); const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); - const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const searchDataType = shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type; const currentColumns = !searchResults?.data ? [] - : getColumnsToShow({currentAccountID: accountID, data: searchResults.data, visibleColumns, type: searchDataType, groupBy: groupBy?.value, policyCategories}); + : getColumnsToShow({currentAccountID: accountID, data: searchResults.data, visibleColumns, type: searchDataType, groupBy: groupBy?.value}); const sortableColumns = getSortByOptions(currentColumns, translate); const sortOrder = queryJSON.sortOrder; diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx index 26ef496102cf..f0640e237a17 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListExpanded.tsx @@ -78,6 +78,7 @@ function TransactionGroupListExpanded({ const [visibleColumns] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM, {selector: columnsSelector}); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); const transactionsSnapshotMetadata = transactionsSnapshot?.search; @@ -123,18 +124,20 @@ function TransactionGroupListExpanded({ data: transactionsSnapshot?.data, visibleColumns, type: transactionsSnapshot?.search.type, - policyCategories, fallbackPolicyID: policyForMovingExpensesID, }); } } - const getPolicyCategoriesForTransaction = (transaction: TransactionListItemType) => { - const transactionPolicyID = - [transaction.policyID, transaction.policy?.id, transaction.report?.policyID].find(Boolean) ?? - (transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? policyForMovingExpensesID : undefined); - return policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(transactionPolicyID)}`]; - }; + const getTransactionPolicyID = (transaction: TransactionListItemType) => + [transaction.policyID, transaction.policy?.id, transaction.report?.policyID].find(Boolean) ?? + (transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? policyForMovingExpensesID : undefined); + + const getPolicyCategoriesForTransaction = (transaction: TransactionListItemType) => + policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(getTransactionPolicyID(transaction))}`]; + + const getPolicyTagListsForTransaction = (transaction: TransactionListItemType) => + policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(getTransactionPolicyID(transaction))}`]; // Currently only the transaction report groups have transactions where the empty view makes sense const shouldDisplayShowMoreButton = isExpenseReportType ? transactions.length > transactionsVisibleLimit : !!transactionsSnapshotMetadata?.hasMoreResults && !isOffline; @@ -341,6 +344,7 @@ function TransactionGroupListExpanded({ report={transaction.report} policy={transaction.policy} policyCategories={getPolicyCategoriesForTransaction(transaction)} + policyTagLists={getPolicyTagListsForTransaction(transaction)} transactionItem={transaction} violations={getTransactionViolations( transaction, diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx index 754764785c53..cd4cc3980196 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/TransactionListItemWide.tsx @@ -38,6 +38,7 @@ function TransactionListItemWide({ transactionPreviewData, exportedReportActions, policyCategories, + policyTagLists, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy, currentSearchHash, @@ -173,6 +174,7 @@ function TransactionListItemWide({ report={transactionItem.report} policy={transactionItem.policy} policyCategories={policyCategories} + policyTagLists={policyTagLists} shouldShowTooltip={showTooltip} onButtonPress={handleActionButtonPress} onCheckboxPress={() => onCheckboxPress?.(item)} diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx index a419d17a07eb..5056e0255557 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/index.tsx @@ -99,6 +99,8 @@ function TransactionListItem({ // Fetch policy categories directly from Onyx since they are not included in the search snapshot const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); + // Fetch policy tags directly from Onyx (not in the snapshot) so the Tag GL code cell can resolve. + const [policyTagLists] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(policyID)}`); const [parentReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionItem.reportID)}`); const [transactionThreadReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionItem?.reportAction?.childReportID}`); @@ -222,6 +224,7 @@ function TransactionListItem({ transactionPreviewData, exportedReportActions, policyCategories, + policyTagLists, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy, }; diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts index 38423137a79c..3a90722efbb3 100644 --- a/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/TransactionListItem/types.ts @@ -3,7 +3,7 @@ import type {ListItemFocusEventHandler} from '@components/SelectionList/ListItem import type {ListItem} from '@components/SelectionList/types'; import type {TransactionPreviewData} from '@libs/actions/Search'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; -import type {CardList, PolicyCategories, ReportAction, TransactionViolation} from '@src/types/onyx'; +import type {CardList, PolicyCategories, PolicyTagLists, ReportAction, TransactionViolation} from '@src/types/onyx'; type TransactionListItemSharedProps = { item: TItem; @@ -26,6 +26,7 @@ type TransactionListItemSharedProps = { transactionPreviewData: TransactionPreviewData; exportedReportActions: ReportAction[]; policyCategories?: PolicyCategories; + policyTagLists?: PolicyTagLists; nonPersonalAndWorkspaceCards?: CardList; isAttendeesEnabledForMovingPolicy?: boolean; }; diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index 23e11a170425..1eb82fc444f9 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -14,6 +14,7 @@ import type { LastPaymentMethod, PersonalDetails, Policy, + PolicyTagLists, Report, ReportAction, SearchResults, @@ -446,6 +447,8 @@ type TransactionListItemProps = ListItemProps & columns?: SearchColumnType[]; /** Non-personal and workspace cards for company card display */ nonPersonalAndWorkspaceCards?: CardList; + /** All policies' tag lists, drilled from the list level so each row can resolve its policy's tags without an Onyx subscription per row */ + policyTags?: OnyxCollection; /** Callback to undelete a transaction */ onUndelete?: (transaction: Transaction) => void; }; diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index f8b0de3a239e..83da263b2b1c 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useImperativeHandle, useMemo, useRef, useState} from import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import type {NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; +import type {OnyxCollection} from 'react-native-onyx'; import AnimatedExitRow from '@components/Search/primitives/AnimatedExitRow'; import HorizontalTableScroll from '@components/Search/primitives/HorizontalTableScroll'; import SelectionTopBar from '@components/Search/primitives/SelectionTopBar'; @@ -29,7 +30,7 @@ import type {TransactionPreviewData} from '@userActions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; -import type {CardList, Transaction} from '@src/types/onyx'; +import type {CardList, PolicyTagLists, Transaction} from '@src/types/onyx'; import BaseSearchList from './BaseSearchList'; import type ChatListItem from './ListItem/ChatListItem'; import type ExpenseReportListItem from './ListItem/ExpenseReportListItem'; @@ -106,6 +107,9 @@ type SearchListProps = Pick, 'onScroll' | 'conten /** Non-personal and workspace cards (same drill path as former custom card names for rows) */ nonPersonalAndWorkspaceCards?: CardList; + /** All policies' tag lists, drilled from the list level so each row can resolve its policy's tags without an Onyx subscription per row */ + policyTags?: OnyxCollection; + /** Whether all transactions have been loaded from snapshots in group-by views */ hasLoadedAllTransactions?: boolean; @@ -194,6 +198,7 @@ function SearchList({ newTransactions = [], nonPersonalAndWorkspaceCards, hasLoadedAllTransactions, + policyTags, isActionColumnWide, isAttendeesEnabledForMovingPolicy, ref, @@ -543,6 +548,7 @@ function SearchList({ userBillingGracePeriodEnds={userBillingGracePeriodEnds} ownerBillingGracePeriodEnd={ownerBillingGracePeriodEnd} nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards} + policyTags={policyTags} onFocus={onFocus} newTransactionID={newTransactionID} onUndelete={handleUndelete} @@ -571,6 +577,7 @@ function SearchList({ userBillingGracePeriodEnds, ownerBillingGracePeriodEnd, nonPersonalAndWorkspaceCards, + policyTags, ListFooterComponent, handleUndelete, firstVisibleIndex, diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index 3a6651e3a1fc..e68bed22c1c6 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -106,6 +106,11 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [ translationKey: 'common.tag', canEdit: true, }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE, + translationKey: 'common.tagGLCode', + sortColumnName: CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE, + }, { columnName: CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, translationKey: 'common.reimbursable', diff --git a/src/components/Search/SortableHeaderText.tsx b/src/components/Search/SortableHeaderText.tsx index 3a2305194261..e350aec2ac9c 100644 --- a/src/components/Search/SortableHeaderText.tsx +++ b/src/components/Search/SortableHeaderText.tsx @@ -19,11 +19,25 @@ type SearchTableHeaderColumnProps = WithSentryLabel & { sortOrder: SortOrder; isSortable?: boolean; containerStyle?: StyleProp; + + /** Styles for the inner content row. Put padding/borders here, not on the container, so flex columns stay aligned. */ + innerContainerStyle?: StyleProp; textStyle?: StyleProp; onPress: (order: SortOrder) => void; }; -export default function SortableHeaderText({text, icon, sortOrder, isActive, textStyle, containerStyle, isSortable = true, onPress, sentryLabel}: SearchTableHeaderColumnProps) { +export default function SortableHeaderText({ + text, + icon, + sortOrder, + isActive, + textStyle, + containerStyle, + innerContainerStyle, + isSortable = true, + onPress, + sentryLabel, +}: SearchTableHeaderColumnProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowDownLong', 'ArrowUpLong']); const styles = useThemeStyles(); const theme = useTheme(); @@ -31,7 +45,7 @@ export default function SortableHeaderText({text, icon, sortOrder, isActive, tex if (!isSortable) { return ( - + {!!icon && ( - + {!!icon && ( [2]; return getSortedSections(type, status, sortInput, localeCompare, translate, sortBy, sortOrder, validGroupBy, { policyCategories, + policyTags, fallbackPolicyID: policyForMovingExpensesID, }).map((item) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- chat variant rows are report actions @@ -338,6 +340,7 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans sortOrder, validGroupBy, policyCategories, + policyTags, policyForMovingExpensesID, isChat, newSearchResultKeys, @@ -362,7 +365,6 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans type: searchDataType, groupBy: validGroupBy, shouldUseStrictDefaultExpenseColumns: currentSearchKey === CONST.SEARCH.SEARCH_KEYS.EXPENSES && isDefaultExpensesQuery(queryJSON), - policyCategories, fallbackPolicyID: policyForMovingExpensesID, }); })(); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index f439bfa2cef5..a010ea25c9dc 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -165,6 +165,7 @@ function Search({ const isAttendeesEnabledForMovingPolicy = shouldShowAttendees(CONST.IOU.TYPE.SUBMIT, policyForMovingExpenses); const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); + const [policyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0); @@ -1002,7 +1003,8 @@ function Search({ } const searchTableHeader = !shouldShowTableHeader ? undefined : ( - + // Match the rows' trailing arrow spacing so the header columns line up with them. + )} diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index 23f57318b8bd..091941745ef8 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -23,6 +23,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getCategoryGLCode} from '@libs/CategoryUtils'; import getBase62ReportID from '@libs/getBase62ReportID'; +import {getTagGLCode} from '@libs/PolicyUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {isExpenseReport} from '@libs/ReportUtils'; import { @@ -60,6 +61,7 @@ function TransactionItemRowWide({ report, policy, policyCategories, + policyTagLists, isSelected, shouldShowTooltip, dateColumnSize, @@ -181,6 +183,15 @@ function TransactionItemRowWide({ /> ); + case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: + return ( + + + + ); case CONST.SEARCH.TABLE_COLUMNS.DATE: return ( !hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) && !Navigation.getIsFullscreenPreInsertedUnderRHP()); @@ -117,7 +116,6 @@ function useSearchOverlay({ type: queryJSON.type, groupBy: validGroupBy, shouldUseStrictDefaultExpenseColumns, - policyCategories, fallbackPolicyID: policyForMovingExpensesID, }); })(); diff --git a/src/languages/de.ts b/src/languages/de.ts index c45021fe94fe..0c325506efc7 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -514,6 +514,7 @@ const translations: TranslationDeepObject = { avatar: 'Avatar', editor: 'Editor', restrictions: 'Beschränkungen', + tagGLCode: 'GL-Code taggen', off: 'Aus', }, socials: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 10d2081542c8..2d041a1ad214 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -526,6 +526,7 @@ const translations = { year: 'Year', quarter: 'Quarter', restrictions: 'Restrictions', + tagGLCode: 'Tag GL code', concierge: { greeting: 'Hi there, how can I help?', showHistory: 'Show history', diff --git a/src/languages/es.ts b/src/languages/es.ts index a1d8c166ea68..4f236c8275d6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -465,6 +465,7 @@ const translations: TranslationDeepObject = { enterDigitLabel: ({digitIndex, totalDigits}: {digitIndex: number; totalDigits: number}) => `introducir dígito ${digitIndex} de ${totalDigits}`, editor: 'Editor', restrictions: 'Restricciones', + tagGLCode: 'Etiquetar código GL', off: 'Desactivado', }, socials: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b6eded4cb850..d1b1511c254b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -514,6 +514,7 @@ const translations: TranslationDeepObject = { avatar: 'Avatar', editor: 'Éditeur', restrictions: 'Restrictions', + tagGLCode: 'Taguer le code GL', off: 'Désactivé', }, socials: { diff --git a/src/languages/it.ts b/src/languages/it.ts index efd84c1b63e8..2f88db73da02 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -514,6 +514,7 @@ const translations: TranslationDeepObject = { avatar: 'Avatar', editor: 'Editor', restrictions: 'Restrizioni', + tagGLCode: 'Tag codice GL', off: 'Disattivato', }, socials: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c28af32576a7..fffbd07d1801 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -513,6 +513,7 @@ const translations: TranslationDeepObject = { avatar: 'アバター', editor: '編集者', restrictions: '制限', + tagGLCode: 'GL コードにタグを付ける', off: 'オフ', }, socials: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fad31d4c17f3..1fd8e9086de5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -513,6 +513,7 @@ const translations: TranslationDeepObject = { avatar: 'Avatar', editor: 'Editor', restrictions: 'Beperkingen', + tagGLCode: 'GL-code labelen', off: 'Uit', }, socials: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8f423f10c49d..bc20014b3319 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -513,6 +513,7 @@ const translations: TranslationDeepObject = { avatar: 'Avatar', editor: 'Edytor', restrictions: 'Ograniczenia', + tagGLCode: 'Oznacz kod GL', off: 'Wyłączone', }, socials: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 165371fdccc2..0bc6c2138911 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -512,6 +512,7 @@ const translations: TranslationDeepObject = { avatar: 'Avatar', editor: 'Editor', restrictions: 'Restrições', + tagGLCode: 'Marcar código GL', off: 'Desligado', }, socials: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f229588b3cc8..fa736cf5a7f1 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -509,6 +509,7 @@ const translations: TranslationDeepObject = { avatar: '头像', editor: '编辑', restrictions: '限制', + tagGLCode: '标记总账代码', off: '关', }, socials: { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 4fc3e4a41d52..03b8286748d9 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1066,6 +1066,45 @@ function getLengthOfTag(tag: string): number { return getTagArrayFromName(tag).length; } +/** + * Resolves a transaction's tag to the GL codes configured on the matching policy tags. + * Multi-level tags resolve each level against the tag list with the same order weight, + * and the non-empty GL codes are joined into a single comma-separated string. + */ +function getTagGLCode(policyTagLists: OnyxEntry, transactionTag: string | undefined): string { + if (isEmptyObject(policyTagLists) || !transactionTag) { + return ''; + } + + const tagLists = getTagLists(policyTagLists); + const tagParts = getTagArrayFromName(transactionTag); + return tagParts + .map((tagName, index) => { + const levelTags = tagLists.at(index)?.tags; + if (!levelTags) { + return ''; + } + + // Dependent tag lists can hold same-named child tags under different parents (stored under unique + // record keys), so a tag only matches by name when its parent filter also matches the parent tag path. + const parentTagPath = tagParts.slice(0, index).join(':'); + const matchesTagAtLevel = (levelTag: ValueOf | undefined): levelTag is ValueOf => { + if (!levelTag || levelTag.name !== tagName) { + return false; + } + const filterRegex = levelTag.rules?.parentTagsFilter ?? levelTag.parentTagsFilter; + return !filterRegex || new RegExp(filterRegex).test(parentTagPath); + }; + + const directMatch = levelTags[tagName]; + const matchingTag = matchesTagAtLevel(directMatch) ? directMatch : Object.values(levelTags).find(matchesTagAtLevel); + const glCode = matchingTag?.['GL Code']; + return glCode != null ? String(glCode).replaceAll('"', '') : ''; + }) + .filter(Boolean) + .join(', '); +} + /** * Escape colon from tag name */ @@ -2766,6 +2805,7 @@ export { getPolicyRole, hasIndependentTags, getLengthOfTag, + getTagGLCode, isPolicyMemberWithoutPendingDelete, hasDynamicExternalWorkflow, getPolicyEmployeeAccountIDs, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 9528c2baf8e8..b21eb0a7a9cf 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -148,6 +148,7 @@ import { getPolicyRole, getRuleApprovers, getSubmitToAccountID, + getTagGLCode, hasDependentTags as hasDependentTagsPolicyUtils, hasDynamicExternalWorkflow, isExpensifyTeam, @@ -13088,6 +13089,8 @@ const sortableColumnNames = [ CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE, CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE, CONST.SEARCH.TABLE_COLUMNS.TAG, + CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE, + CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.TOTAL, CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, @@ -13137,6 +13140,7 @@ function getTransactionSortValue( report: Report, policy: OnyxEntry, policyCategories?: PolicyCategories, + policyTagLists?: PolicyTagLists, ): string | number | undefined { switch (key) { case CONST.SEARCH.TABLE_COLUMNS.DATE: @@ -13150,6 +13154,9 @@ function getTransactionSortValue( return getCategoryGLCode(policyCategories, getCategory(transaction)); case CONST.SEARCH.TABLE_COLUMNS.TAG: return getTag(transaction); + case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: + case CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE: + return getTagGLCode(policyTagLists, getTag(transaction)); case CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT: return getTransactionAmount(transaction, isExpenseReport(report), transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID); case CONST.SEARCH.TABLE_COLUMNS.TOTAL: diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index cc91dc6de32e..3916aa442b73 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -117,6 +117,7 @@ import { getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getSubmitToAccountID, + getTagGLCode, isGroupPolicy, isPaidGroupPolicy, isPolicyAdmin, @@ -212,9 +213,11 @@ import {isInvalidMerchantValue} from './ValidationUtils'; type ColumnSortMapping = Partial>; type ColumnVisibility = Partial>; type PolicyCategoriesLookup = OnyxEntry | OnyxCollection; +type PolicyTagsLookup = OnyxEntry | OnyxCollection; type SortSectionsOptions = { policyCategories?: PolicyCategoriesLookup; + policyTags?: PolicyTagsLookup; fallbackPolicyID?: string; }; type GroupBySection = { @@ -1510,7 +1513,8 @@ function shouldShowYear( } // Posted date is in the YYYYMMDD format, so we extract the year manually here since JS's Date constructor interprets it as an invalid date. - if (item?.posted) { + // Latch once true so a later current-year date can't reset it (the row renderer latches the same way). + if (!result.shouldShowYearPosted && item?.posted) { const postedYear = parseInt(item.posted.slice(0, 4), 10); result.shouldShowYearPosted = postedYear !== currentYear; } @@ -1542,7 +1546,8 @@ function shouldShowYear( } // Posted date is in the YYYYMMDD format, so we extract the year manually here since JS's Date constructor interprets it as an invalid date. - if (item?.posted) { + // Latch once true so a later current-year date can't reset it (the row renderer latches the same way). + if (!result.shouldShowYearPosted && item?.posted) { const postedYear = parseInt(item.posted.slice(0, 4), 10); result.shouldShowYearPosted = postedYear !== currentYear; } @@ -3827,6 +3832,12 @@ function getTransactionCategoryGLCodeSortValue(transaction: TransactionListItemT return getCategoryGLCode(transactionPolicyCategories, transaction.category); } +function getTransactionTagGLCodeSortValue(transaction: TransactionListItemType, options?: SortSectionsOptions): string { + const transactionPolicyID = getTransactionPolicyID(transaction, options?.fallbackPolicyID); + const transactionPolicyTags = getPolicyTagsForPolicyID(options?.policyTags, transactionPolicyID); + return getTagGLCode(transactionPolicyTags, getTag(transaction)); +} + /** * @private * Sorts transaction sections based on a specified column and sort order. @@ -3867,7 +3878,8 @@ function getSortedTransactionData( }); } - const sortingProperty = sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE ? undefined : transactionColumnNamesToSortingProperty[sortBy]; + const sortingProperty = + sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE || sortBy === CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE ? undefined : transactionColumnNamesToSortingProperty[sortBy]; if (sortBy === CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME) { return data.sort((a, b) => { @@ -3947,6 +3959,14 @@ function getSortedTransactionData( }); } + if (sortBy === CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE) { + return data.sort((a, b) => { + const aValue = getTransactionTagGLCodeSortValue(a, options); + const bValue = getTransactionTagGLCodeSortValue(b, options); + return compareValues(aValue, bValue, sortOrder, sortBy, localeCompare); + }); + } + if (sortBy === CONST.SEARCH.TABLE_COLUMNS.ATTENDEES) { return data.sort((a, b) => { const aValue = convertAttendeesToArray(a.comment?.attendees).length; @@ -4002,7 +4022,8 @@ function getSortedTaskData(data: TaskListItemType[], localeCompare: LocaleContex return data; } - const sortingProperty = sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE ? undefined : taskColumnNamesToSortingProperty[sortBy]; + const sortingProperty = + sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE || sortBy === CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE ? undefined : taskColumnNamesToSortingProperty[sortBy]; if (!sortingProperty) { return data; @@ -4099,7 +4120,8 @@ function getSortedReportData( }); } - const sortingProperty = sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE ? undefined : expenseReportColumnNamesToSortingProperty[sortBy]; + const sortingProperty = + sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE || sortBy === CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE ? undefined : expenseReportColumnNamesToSortingProperty[sortBy]; if (!sortingProperty) { return data; @@ -4138,7 +4160,8 @@ function getSortedData( return data.sort(defaultComparator); } - const sortingProperty = sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE ? undefined : columnNamesToSortingProperty[sortBy]; + const sortingProperty = + sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE || sortBy === CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE ? undefined : columnNamesToSortingProperty[sortBy]; if (!sortingProperty) { return data; @@ -4294,6 +4317,8 @@ function getSearchColumnTranslationKey(column: SearchSortBy): TranslationPaths { return 'common.category'; case CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE: return 'common.categoryGLCode'; + case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: + return 'common.tagGLCode'; case CONST.SEARCH.TABLE_COLUMNS.ATTENDEES: return 'iou.attendees'; case CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE: @@ -4392,6 +4417,8 @@ function getSearchColumnTranslationKey(column: SearchSortBy): TranslationPaths { return 'search.exportedTo'; case CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE: return 'common.categoryGLCode'; + case CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE: + return 'common.tagGLCode'; default: // This should never happen, but TypeScript requires a default case return 'common.expenses' as TranslationPaths; @@ -4403,7 +4430,13 @@ function isColumnSortable(column: SearchColumnType) { } function getSortByForColumn(column: SearchColumnType): SearchSortBy { - return column === CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE ? CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE : column; + if (column === CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE) { + return CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE; + } + if (column === CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE) { + return CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE; + } + return column; } type OverflowMenuIconsType = Record<'Pencil' | 'Trashcan' | 'LinkCopy' | 'Checkmark', IconAsset>; @@ -5563,6 +5596,28 @@ function getPolicyCategoriesForPolicyID(policyCategories: PolicyCategoriesLookup return policyCategoriesKey ? policyCategories[policyCategoriesKey] : undefined; } +/** + * `policyTags` can be a single policy's tag lists or an Onyx collection holding many policies' + * tag lists keyed by `policyTags_`. A collection is the only variant whose keys start + * with that prefix, so we use that to discriminate the union. + */ +function isPolicyTagsCollection(policyTags: NonNullable): policyTags is NonNullable> { + return Object.keys(policyTags).some((key) => key.startsWith(ONYXKEYS.COLLECTION.POLICY_TAGS)); +} + +function getPolicyTagsForPolicyID(policyTags: PolicyTagsLookup | undefined, policyID?: string): OnyxEntry { + if (!policyTags) { + return undefined; + } + + if (!isPolicyTagsCollection(policyTags)) { + return policyTags; + } + + const policyTagsKey = policyID ? `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}` : undefined; + return policyTagsKey ? policyTags[policyTagsKey] : undefined; +} + /** * Determines what columns to show based on available data * @param isExpenseReportView: true when we are inside an expense report view, false if we're in the Reports page. @@ -5583,7 +5638,6 @@ function getColumnsToShow({ reportCurrency, shouldUseStrictDefaultExpenseColumns = false, isPolicyTaxEnabled = false, - policyCategories, fallbackPolicyID, }: { currentAccountID: number | undefined; @@ -5600,7 +5654,6 @@ function getColumnsToShow({ reportCurrency?: string; shouldUseStrictDefaultExpenseColumns?: boolean; isPolicyTaxEnabled?: boolean; - policyCategories?: PolicyCategoriesLookup; fallbackPolicyID?: string; }): SearchColumnType[] { const reportCustomColumns = new Set([ @@ -5738,6 +5791,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.CATEGORY]: false, [CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]: false, [CONST.SEARCH.TABLE_COLUMNS.TAG]: false, + [CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE]: false, [CONST.SEARCH.TABLE_COLUMNS.CARD]: false, [CONST.SEARCH.TABLE_COLUMNS.MCC]: false, [CONST.SEARCH.TABLE_COLUMNS.TAX_CODE]: false, @@ -5770,6 +5824,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.CATEGORY]: false, [CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]: false, [CONST.SEARCH.TABLE_COLUMNS.TAG]: false, + [CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE]: false, [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE]: false, [CONST.SEARCH.TABLE_COLUMNS.BILLABLE]: false, [CONST.SEARCH.TABLE_COLUMNS.MCC]: false, @@ -5792,7 +5847,6 @@ function getColumnsToShow({ const filteredVisibleColumns = visibleColumns.filter((column) => allowedColumns.includes(column)); const isDefaultExpenseColumnSelection = arraysEqual(Object.values(CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE), filteredVisibleColumns); const shouldUseCustomResult = !isDefaultExpenseColumnSelection && filteredVisibleColumns.length > 0; - const isCategoryGLCodeSelected = filteredVisibleColumns.includes(CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE); let customResult: SearchColumnType[] | undefined; @@ -5853,7 +5907,6 @@ function getColumnsToShow({ if (!transactionPolicyID && transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { transactionPolicyID = fallbackPolicyID; } - const transactionPolicyCategories = getPolicyCategoriesForPolicyID(policyCategories, transactionPolicyID); const merchant = transaction.modifiedMerchant ? transaction.modifiedMerchant : (transaction.merchant ?? ''); if (!isInvalidMerchantValue(merchant) || isScanning(transaction)) { columns[CONST.SEARCH.TABLE_COLUMNS.MERCHANT] = true; @@ -5876,11 +5929,7 @@ function getColumnsToShow({ // Category/tag: set for all paths (default search, custom search, report view). // Will be refined later for search page non-IOU check. if (hasCategory) { - const categoryGLCode = getCategoryGLCode(transactionPolicyCategories, transaction.category); columns[CONST.SEARCH.TABLE_COLUMNS.CATEGORY] = !isExpenseReportViewFromIOUReport; - if (isCategoryGLCodeSelected && !isExpenseReportViewFromIOUReport && categoryGLCode) { - columns[CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE] = true; - } } if (hasTag) { columns[CONST.SEARCH.TABLE_COLUMNS.TAG] = !isExpenseReportViewFromIOUReport; @@ -5921,32 +5970,12 @@ function getColumnsToShow({ if (hasDisplayableMCC(transaction.mcc)) { columns[CONST.SEARCH.TABLE_COLUMNS.MCC] = true; } - const hasExchangeRate = getExchangeRate(transaction, reportCurrency) !== ''; if (hasExchangeRate) { columns[CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE] = true; } - // Expense report view: TOTAL (workspace currency) is always shown when a conversion - // exists. ORIGINAL_AMOUNT (the transaction's original/foreign amount) is a separate, - // user-selectable report column — in the report view it's gated behind an explicit - // selection (customResult) so it never renders by default, only when the user picks it. - // Search page: ORIGINAL_AMOUNT stays data-driven (shown whenever a conversion exists). - if (hasExchangeRate) { - if (isExpenseReportView) { - columns[CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT] = true; - if (customResult) { - columns[CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT] = true; - } - } else { - columns[CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT] = true; - } - } - - // POSTED (card posting date) is a user-selectable report column. In the report view it's - // gated behind an explicit selection (customResult) so it never renders by default — - // it shows only when the user picks it and the transaction actually has a posting date. - if (customResult && isExpenseReportView && transaction.posted) { - columns[CONST.SEARCH.TABLE_COLUMNS.POSTED] = true; + if (hasExchangeRate && isExpenseReportView) { + columns[CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT] = true; } if (!Array.isArray(data)) { @@ -6017,32 +6046,7 @@ function getColumnsToShow({ } if (customResult) { - // Columns that always have content and don't need data-presence checks. - // These are false in the default columns map (so they don't appear by default) - // but should be kept when explicitly selected by the user in custom columns. - const nonDataColumns = new Set([ - CONST.SEARCH.TABLE_COLUMNS.AVATAR, - CONST.SEARCH.TABLE_COLUMNS.RECEIPT, - CONST.SEARCH.TABLE_COLUMNS.TYPE, - CONST.SEARCH.TABLE_COLUMNS.DATE, - CONST.SEARCH.TABLE_COLUMNS.STATUS, - // TOTAL_AMOUNT (Amount) is data-driven in expense report view: shown only when a - // conversion exists. In search view, TOTAL_AMOUNT is always-true via the default - // columns map, so we don't need to list it here as non-data for either surface. - CONST.SEARCH.TABLE_COLUMNS.TOTAL, - CONST.SEARCH.TABLE_COLUMNS.COMMENTS, - CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, - CONST.SEARCH.TABLE_COLUMNS.BILLABLE, - CONST.SEARCH.TABLE_COLUMNS.BASE_62_REPORT_ID, - CONST.SEARCH.TABLE_COLUMNS.REPORT_ID, - CONST.SEARCH.TABLE_COLUMNS.TITLE, - CONST.SEARCH.TABLE_COLUMNS.ACTION, - CONST.SEARCH.TABLE_COLUMNS.ATTENDEES, - CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE, - CONST.SEARCH.TABLE_COLUMNS.WITHDRAWAL_ID, - ]); - - return customResult.filter((col) => nonDataColumns.has(col) || columns[col]); + return customResult; } return (Object.keys(columns) as SearchColumnType[]).filter((col) => columns[col]); @@ -6193,7 +6197,7 @@ function getTableMinWidth(columns: SearchColumnType[], type?: SearchDataTypes, i column === CONST.SEARCH.TABLE_COLUMNS.TAX_CODE ) { minWidth += 80; - } else if (column === CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE) { + } else if (column === CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE || column === CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE) { minWidth += 130; } else { minWidth += 200; @@ -6307,6 +6311,7 @@ const FLEX_COLUMNS = new Set([ CONST.SEARCH.TABLE_COLUMNS.CATEGORY, CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE, CONST.SEARCH.TABLE_COLUMNS.TAG, + CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE, CONST.SEARCH.TABLE_COLUMNS.TAX_RATE, CONST.SEARCH.TABLE_COLUMNS.CARD, CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE, diff --git a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx index 91eccdaeffbc..716d3d8f2592 100644 --- a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx +++ b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx @@ -39,7 +39,6 @@ function ReportDetailsColumnsPage() { const [reportDetailsColumns] = useOnyx(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); // Selector keeps re-renders scoped to this report's transactions. We intentionally return undefined // while the collection is loading so the caller can distinguish "loading" from "no transactions". const reportTransactionsSelector = useCallback( @@ -84,12 +83,11 @@ function ReportDetailsColumnsPage() { shouldShowReimbursableColumn: hasNonReimbursableTransactions(reportTransactions), reportCurrency: report?.currency, isPolicyTaxEnabled: isPolicyTaxEnabled(policy), - policyCategories, }); // Filter to only columns available in the custom columns list (drops RECEIPT/TYPE/COMMENTS etc.) return visibleColumns.filter((col) => allTypeCustomColumns.includes(col as SearchCustomColumnIds)) as SearchCustomColumnIds[]; - }, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy, policyCategories, allTypeCustomColumns]); + }, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy, allTypeCustomColumns]); const requiredColumns = new Set([CONST.SEARCH.TABLE_COLUMNS.TOTAL]); diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 131c8e1327d7..f974eec5c98c 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1956,6 +1956,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ columnWidth = {...getWidthStyle(variables.w92)}; break; case CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE: + case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: columnWidth = {...getWidthStyle(variables.w130), ...styles.flex1}; break; case CONST.SEARCH.TABLE_COLUMNS.TAX_RATE: diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 5debb92aed3c..42b59b8f6521 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -31,6 +31,7 @@ import { getRateDisplayValue, getSubmitToAccountID, getTagApproverRule, + getTagGLCode, getTagList, getTagListByOrderWeight, getUberConnectionErrorDirectlyFromPolicy, @@ -1177,6 +1178,137 @@ describe('PolicyUtils', () => { expect(tagList.name).toEqual(expected); }); }); + describe('getTagGLCode', () => { + // Tag lists are intentionally declared out of orderWeight order to verify levels resolve by orderWeight + const glCodePolicyTagLists: PolicyTagLists = { + Project: { + name: 'Project', + orderWeight: 1, + required: false, + tags: { + Roadshow: {name: 'Roadshow', enabled: true, 'GL Code': '5678'}, + Internal: {name: 'Internal', enabled: true}, + }, + }, + Department: { + name: 'Department', + orderWeight: 0, + required: false, + tags: { + Engineering: {name: 'Engineering', enabled: true, 'GL Code': '1234'}, + Marketing: {name: 'Marketing', enabled: true}, + 'Sales\\:EMEA': {name: 'Sales\\:EMEA', enabled: true, 'GL Code': '"4321"'}, + }, + }, + }; + + it('returns empty string when policy tags are undefined or empty', () => { + expect(getTagGLCode(undefined, 'Engineering')).toBe(''); + expect(getTagGLCode({}, 'Engineering')).toBe(''); + }); + + it('returns empty string when the transaction tag is undefined or empty', () => { + expect(getTagGLCode(glCodePolicyTagLists, undefined)).toBe(''); + expect(getTagGLCode(glCodePolicyTagLists, '')).toBe(''); + }); + + it('returns empty string when the tag is missing from the policy or has no GL code', () => { + expect(getTagGLCode(glCodePolicyTagLists, 'Nonexistent')).toBe(''); + expect(getTagGLCode(glCodePolicyTagLists, 'Marketing')).toBe(''); + }); + + it('returns the GL code of a single-level tag', () => { + expect(getTagGLCode(glCodePolicyTagLists, 'Engineering')).toBe('1234'); + }); + + it('joins the GL codes of multi-level tags in tag list order', () => { + expect(getTagGLCode(glCodePolicyTagLists, 'Engineering:Roadshow')).toBe('1234, 5678'); + }); + + it('skips multi-level tag levels without a GL code', () => { + expect(getTagGLCode(glCodePolicyTagLists, 'Marketing:Roadshow')).toBe('5678'); + expect(getTagGLCode(glCodePolicyTagLists, 'Engineering:Internal')).toBe('1234'); + }); + + it('resolves tags with escaped colons against the matching tag list level and strips double quotes', () => { + expect(getTagGLCode(glCodePolicyTagLists, 'Sales\\:EMEA:Roadshow')).toBe('4321, 5678'); + }); + + it('resolves dependent tags by name and parent filter when same-named children exist under different parents', () => { + // Same-named child tags of dependent lists are stored under unique record keys, + // so they can only be told apart by their parentTagsFilter + const dependentPolicyTagLists: PolicyTagLists = { + Department: { + name: 'Department', + orderWeight: 0, + required: false, + tags: { + Engineering: {name: 'Engineering', enabled: true, 'GL Code': '1234'}, + Marketing: {name: 'Marketing', enabled: true}, + }, + }, + Project: { + name: 'Project', + orderWeight: 1, + required: false, + tags: { + Roadshow: {name: 'Roadshow', enabled: true, 'GL Code': '1111', rules: {parentTagsFilter: '^Marketing$'}}, + 'Roadshow-1': {name: 'Roadshow', enabled: true, 'GL Code': '2222', rules: {parentTagsFilter: '^Engineering$'}}, + }, + }, + }; + + expect(getTagGLCode(dependentPolicyTagLists, 'Engineering:Roadshow')).toBe('1234, 2222'); + expect(getTagGLCode(dependentPolicyTagLists, 'Marketing:Roadshow')).toBe('1111'); + }); + + it('matches a dependent tag deeper in the hierarchy against the accumulated parent tag path', () => { + const deepDependentPolicyTagLists: PolicyTagLists = { + State: { + name: 'State', + orderWeight: 0, + required: false, + tags: { + California: {name: 'California', enabled: true}, + }, + }, + City: { + name: 'City', + orderWeight: 1, + required: false, + tags: { + 'San Francisco': {name: 'San Francisco', enabled: true, rules: {parentTagsFilter: '^California$'}}, + }, + }, + District: { + name: 'District', + orderWeight: 2, + required: false, + tags: { + Mission: {name: 'Mission', enabled: true, 'GL Code': '9000', rules: {parentTagsFilter: '^California:San Francisco$'}}, + 'Mission-1': {name: 'Mission', enabled: true, 'GL Code': '9999', rules: {parentTagsFilter: '^Texas:Austin$'}}, + }, + }, + }; + + expect(getTagGLCode(deepDependentPolicyTagLists, 'California:San Francisco:Mission')).toBe('9000'); + }); + + it('returns the GL code as a string when malformed Onyx data stores it as a number', () => { + const tagListsWithNumberGLCode: PolicyTagLists = { + Department: { + name: 'Department', + orderWeight: 0, + required: false, + tags: { + // @ts-expect-error - Defensively handles malformed Onyx data that violates the string type. + Engineering: {name: 'Engineering', enabled: true, 'GL Code': 1234}, + }, + }, + }; + expect(getTagGLCode(tagListsWithNumberGLCode, 'Engineering')).toBe('1234'); + }); + }); describe('sortWorkspacesBySelected', () => { it('should order workspaces with selected workspace first', () => { const workspace1 = {policyID: '1', name: 'Workspace 1'}; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 46135823326e..f016cb3cd90b 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -17741,6 +17741,31 @@ describe('ReportUtils', () => { expect(result).toBe('Project A'); }); + it('should return tag GL code for TAG_GL_CODE column', () => { + const transaction = createMockTransaction({tag: 'Engineering:Roadshow'}); + const policyTagLists = { + Department: { + name: 'Department', + orderWeight: 0, + required: false, + tags: { + Engineering: {name: 'Engineering', enabled: true, 'GL Code': '1234'}, + }, + }, + Project: { + name: 'Project', + orderWeight: 1, + required: false, + tags: { + Roadshow: {name: 'Roadshow', enabled: true, 'GL Code': '5678'}, + }, + }, + }; + + expect(getTransactionSortValue(transaction, CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE, mockReport, mockPolicy, undefined, policyTagLists)).toBe('1234, 5678'); + expect(getTransactionSortValue(transaction, CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE, mockReport, mockPolicy, undefined, policyTagLists)).toBe('1234, 5678'); + }); + it('should return 1 for billable and 0 for non-billable', () => { const billable = createMockTransaction({billable: true}); const nonBillable = createMockTransaction({billable: false}); @@ -17811,6 +17836,7 @@ describe('ReportUtils', () => { expect(isSortableColumnName(CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE)).toBe(true); expect(isSortableColumnName(CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE)).toBe(true); expect(isSortableColumnName(CONST.SEARCH.TABLE_COLUMNS.TAG)).toBe(true); + expect(isSortableColumnName(CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE)).toBe(true); expect(isSortableColumnName(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT)).toBe(true); expect(isSortableColumnName(CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE)).toBe(true); expect(isSortableColumnName(CONST.SEARCH.TABLE_COLUMNS.TAX_RATE)).toBe(true); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index e86a0caf4a78..8d10b1eb8f1e 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -8501,22 +8501,12 @@ describe('SearchUIUtils', () => { category: 'Advertising', policyID, }; - const policyCategories = { - [`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]: { - Advertising: { - name: 'Advertising', - enabled: true, - 'GL Code': '10', - }, - }, - }; const defaultVisibleColumns = Object.values(CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE); let columns = SearchUIUtils.getColumnsToShow({ currentAccountID: submitterAccountID, data: [transactionWithCategoryGLCode], visibleColumns: defaultVisibleColumns, - policyCategories, }); expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.CATEGORY); @@ -8526,7 +8516,6 @@ describe('SearchUIUtils', () => { currentAccountID: submitterAccountID, data: [transactionWithCategoryGLCode], visibleColumns: [...defaultVisibleColumns, CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE], - policyCategories, }); expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE); @@ -8547,7 +8536,54 @@ describe('SearchUIUtils', () => { expect(categoryGLCodeHeader?.sortColumnName).toBe(CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE); }); - test('Should only show MCC when that column is selected and at least one transaction has a displayable MCC', () => { + test('Should show Tag GL Code whenever that column is selected, even when no transaction resolves a tag GL code', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + const transactionWithTagGLCode = { + ...baseTransaction, + transactionID: 'tag-gl-code', + tag: 'Engineering:Roadshow', + policyID, + }; + const defaultVisibleColumns = Object.values(CONST.SEARCH.TYPE_DEFAULT_COLUMNS.EXPENSE); + + let columns = SearchUIUtils.getColumnsToShow({ + currentAccountID: submitterAccountID, + data: [transactionWithTagGLCode], + visibleColumns: defaultVisibleColumns, + }); + + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TAG); + expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE); + + columns = SearchUIUtils.getColumnsToShow({ + currentAccountID: submitterAccountID, + data: [transactionWithTagGLCode], + visibleColumns: [...defaultVisibleColumns, CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE], + }); + + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE); + + columns = SearchUIUtils.getColumnsToShow({ + currentAccountID: submitterAccountID, + data: [{...transactionWithTagGLCode, tag: 'TagWithoutGLCode'}], + visibleColumns: [...defaultVisibleColumns, CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE], + }); + + // Selected but the tag has no GL code: the column still shows because it's enabled, so a + // GL-code sort that floats empty values to the top doesn't make the column disappear. + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE); + }); + + test('Should offer sorting by Tag GL Code', () => { + expect(SearchUIUtils.getSortByOptions([CONST.SEARCH.TABLE_COLUMNS.RECEIPT, CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE, CONST.SEARCH.TABLE_COLUMNS.ACTION], translateLocal)).toEqual([ + {text: translateLocal('common.tagGLCode'), value: CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE}, + ]); + + const tagGLCodeHeader = getExpenseHeaders().find(({columnName}) => columnName === CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE); + expect(tagGLCodeHeader?.sortColumnName).toBe(CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE); + }); + + test('Should show MCC whenever that column is selected, even with no displayable MCC', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const transactionWithoutMCC = { ...baseTransaction, @@ -8572,7 +8608,8 @@ describe('SearchUIUtils', () => { visibleColumns, }); - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.MCC); + // MCC is selected, so it shows even when no transaction has a displayable MCC + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.MCC); columns = SearchUIUtils.getColumnsToShow({ currentAccountID: submitterAccountID, @@ -8913,7 +8950,7 @@ describe('SearchUIUtils', () => { expect(commentsCount).toBe(1); }); - test('Should hide empty EXCHANGE_RATE column in expense report view with custom columns', () => { + test('Should show empty EXCHANGE_RATE column in expense report view when selected', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const testTransaction = { ...baseTransaction, @@ -8926,8 +8963,8 @@ describe('SearchUIUtils', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE, CONST.SEARCH.TABLE_COLUMNS.TOTAL]; const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data: [testTransaction], visibleColumns, isExpenseReportView: true}); - // EXCHANGE_RATE should be hidden because no transaction has exchange rate data - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE); + // EXCHANGE_RATE is selected, so it shows even when no transaction has exchange rate data + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE); // Always-shown columns should still be present expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.DATE); expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TOTAL); @@ -8950,7 +8987,7 @@ describe('SearchUIUtils', () => { expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE); }); - test('Should hide empty CARD column in expense report view with custom columns', () => { + test('Should show empty CARD column in expense report view when selected', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const testTransaction = { ...baseTransaction, @@ -8962,7 +8999,8 @@ describe('SearchUIUtils', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.CARD, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data: [testTransaction], visibleColumns, isExpenseReportView: true}); - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.CARD); + // CARD is selected, so it shows even when no transaction has a card name + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.CARD); }); test('Should show CARD column when transaction has cardName', () => { @@ -8981,7 +9019,7 @@ describe('SearchUIUtils', () => { expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.CARD); }); - test('Should hide empty POSTED column in expense report view with custom columns', () => { + test('Should show empty POSTED column in expense report view when selected', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const testTransaction = { ...baseTransaction, @@ -8993,8 +9031,8 @@ describe('SearchUIUtils', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.POSTED, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data: [testTransaction], visibleColumns, isExpenseReportView: true}); - // POSTED is data-driven in the report view: hidden when no transaction has a posting date - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.POSTED); + // POSTED is selected, so it shows even when no transaction has a posting date + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.POSTED); }); test('Should show POSTED column when a transaction has a posting date', () => { @@ -9012,7 +9050,7 @@ describe('SearchUIUtils', () => { expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.POSTED); }); - test('Should hide empty ORIGINAL_AMOUNT column in expense report view with custom columns', () => { + test('Should show empty ORIGINAL_AMOUNT column in expense report view when selected', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const testTransaction = { ...baseTransaction, @@ -9025,8 +9063,8 @@ describe('SearchUIUtils', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.TOTAL]; const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data: [testTransaction], visibleColumns, isExpenseReportView: true}); - // ORIGINAL_AMOUNT is data-driven in the report view: hidden when no transaction has a currency conversion - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT); + // ORIGINAL_AMOUNT is selected, so it shows even when no transaction has a currency conversion + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT); }); test('Should show ORIGINAL_AMOUNT column when a transaction has a currency conversion', () => { @@ -9067,7 +9105,7 @@ describe('SearchUIUtils', () => { expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT); }); - test('Should hide empty TAX columns in expense report view with custom columns', () => { + test('Should show empty TAX columns in expense report view when selected', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const testTransaction = { ...baseTransaction, @@ -9080,8 +9118,9 @@ describe('SearchUIUtils', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.TAX_RATE, CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data: [testTransaction], visibleColumns, isExpenseReportView: true}); - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_RATE); - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT); + // TAX columns are selected, so they show even when no transaction has tax data + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_RATE); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT); }); test('Should show TAX columns when transaction has tax data', () => { @@ -9153,9 +9192,9 @@ describe('SearchUIUtils', () => { expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT); }); - test('Should hide TAX columns when the workspace does not have taxes enabled and transactions have no tax data', () => { - // Regression guard: without a tax-enabled policy and without per-transaction tax - // data, the columns must stay hidden even if the user selected them. + test('Should show selected TAX columns even when the workspace has no taxes enabled', () => { + // The columns are part of the user's custom selection, so they show even without a + // tax-enabled policy and without per-transaction tax data. const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const legacyTransaction = { ...baseTransaction, @@ -9173,11 +9212,11 @@ describe('SearchUIUtils', () => { isPolicyTaxEnabled: false, }); - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_RATE); - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_RATE); + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT); }); - test('Should hide empty AMOUNT column in expense report view when no conversion', () => { + test('Should show empty AMOUNT column in expense report view when selected and no conversion', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const testTransaction = { ...baseTransaction, @@ -9189,7 +9228,8 @@ describe('SearchUIUtils', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.TOTAL, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data: [testTransaction], visibleColumns, isExpenseReportView: true}); - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); + // TOTAL_AMOUNT is selected, so it shows even when no transaction has a conversion + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); }); test('Should show AMOUNT column when transaction has a real conversion (currencies differ)', () => { @@ -9210,7 +9250,7 @@ describe('SearchUIUtils', () => { expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); }); - test('Should show column if at least one transaction has data for it', () => { + test('Should show selected columns regardless of whether any transaction has data for them', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const emptyTransaction = { ...baseTransaction, @@ -9231,13 +9271,13 @@ describe('SearchUIUtils', () => { const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.CARD, CONST.SEARCH.TABLE_COLUMNS.TAX_RATE, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data: [emptyTransaction, transactionWithCard], visibleColumns, isExpenseReportView: true}); - // CARD should be shown because one transaction has a card name + // CARD is selected and one transaction has a card name expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.CARD); - // TAX_RATE should be hidden because no transaction has taxCode - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_RATE); + // TAX_RATE is selected, so it shows even though no transaction has taxCode + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TAX_RATE); }); - test('Should always show RECEIPT, TYPE, DATE, TOTAL in custom columns regardless of data, and COMMENTS when shouldShowCommentsColumn is true', () => { + test('Should always show selected columns regardless of data, and COMMENTS when shouldShowCommentsColumn is true', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const testTransaction = { ...baseTransaction, @@ -9261,8 +9301,8 @@ describe('SearchUIUtils', () => { expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TOTAL); expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.TYPE); expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.COMMENTS); - // MERCHANT should be hidden because no data - expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.MERCHANT); + // MERCHANT is selected, so it shows even with no data + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.MERCHANT); // Without shouldShowCommentsColumn, COMMENTS should not appear const columnsWithoutComments = SearchUIUtils.getColumnsToShow({ @@ -9367,6 +9407,11 @@ describe('SearchUIUtils', () => { expect(translationKey).toBe('common.categoryGLCode'); }); + it('should return correct translation key for TAG_GL_CODE column', () => { + const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE); + expect(translationKey).toBe('common.tagGLCode'); + }); + it('should return correct translation key for WITHDRAWAL_ID column', () => { const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.WITHDRAWAL_ID); expect(translationKey).toBe('common.withdrawalID'); diff --git a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap index 0c403133233d..23b2ccc516da 100644 --- a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap +++ b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap @@ -13,6 +13,7 @@ exports[`Column availability single source of truth the derived picker lists mat "attendees", "totalPerAttendee", "tag", + "tagGLCode", "exchangeRate", "originalamount", "reimbursable", @@ -50,6 +51,7 @@ exports[`Column availability single source of truth the derived picker lists mat "attendees", "totalPerAttendee", "tag", + "tagGLCode", "exchangeRate", "originalamount", "reportID",