diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f50532c16f19..ddfe0a92bd09 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7417,6 +7417,11 @@ const CONST = { search: true, reportView: true, }, + VENDOR: { + column: this.TABLE_COLUMNS.VENDOR, + search: true, + reportView: true, + }, DESCRIPTION: { column: this.TABLE_COLUMNS.DESCRIPTION, search: true, @@ -7778,6 +7783,7 @@ const CONST = { POSTED: 'posted', EXPORTED: 'exported', MERCHANT: 'merchant', + VENDOR: 'vendor', DESCRIPTION: 'description', FROM: 'from', TO: 'to', @@ -8020,6 +8026,7 @@ const CONST = { [this.TABLE_COLUMNS.POSTED]: 'posted', [this.TABLE_COLUMNS.EXPORTED]: 'exported', [this.TABLE_COLUMNS.MERCHANT]: 'merchant', + [this.TABLE_COLUMNS.VENDOR]: 'vendor', [this.TABLE_COLUMNS.DESCRIPTION]: 'description', [this.TABLE_COLUMNS.FROM]: 'from', [this.TABLE_COLUMNS.TO]: 'to', diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index 2ce48fa2bba1..a50c26142ada 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -12,6 +12,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNavigateToTransactionThread from '@hooks/useNavigateToTransactionThread'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useTheme from '@hooks/useTheme'; @@ -23,7 +24,7 @@ import {clearActiveTransactionIDs, getActiveTransactionIDs, setActiveTransaction import {resolveTransactionCardFields} from '@libs/CardUtils'; import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import {navigationRef} from '@libs/Navigation/Navigation'; -import {isPolicyTaxEnabled} from '@libs/PolicyUtils'; +import {hasVendorFeature, isPolicyTaxEnabled} from '@libs/PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {groupTransactionsByCategory, groupTransactionsByTag} from '@libs/ReportLayoutUtils'; import { @@ -269,6 +270,7 @@ function MoneyRequestReportTransactionList({ const longPressModalRef = useRef(null); const {reportPendingAction} = getReportOfflinePendingActionAndErrors(report); const {isOffline} = useNetwork(); + const {isBetaEnabled} = usePermissions(); const isTaxEnabled = isPolicyTaxEnabled(policy); const {totalDisplaySpend} = getMoneyRequestSpendBreakdown(report); @@ -446,12 +448,15 @@ function MoneyRequestReportTransactionList({ const isExpenseReportViewFromIOUReport = isIOUReport(report); const shouldShowBillableColumn = isBillableEnabledOnPolicy(policy); const shouldShowCommentsColumn = useMemo(() => Object.values(reportActions ?? {}).some((action) => (action?.childVisibleActionCount ?? 0) > 0), [reportActions]); + // The saved column list is account-wide, so drop the vendor column on reports whose workspace lacks the vendor feature. + const isVendorColumnAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); const columnsToShow = useMemo(() => { + const savedColumns = (reportDetailsColumns ?? []).filter((column) => isVendorColumnAvailable || column !== CONST.SEARCH.TABLE_COLUMNS.VENDOR); return getColumnsToShow({ currentAccountID: currentUserDetails?.accountID, data: transactions, report, - visibleColumns: (isExpenseReportViewFromIOUReport ? [] : (reportDetailsColumns ?? [])) as SearchCustomColumnIds[], + visibleColumns: (isExpenseReportViewFromIOUReport ? [] : savedColumns) as SearchCustomColumnIds[], isExpenseReportView: true, isExpenseReportViewFromIOUReport, shouldShowBillableColumn, @@ -467,6 +472,7 @@ function MoneyRequestReportTransactionList({ shouldShowBillableColumn, shouldShowCommentsColumn, reportDetailsColumns, + isVendorColumnAvailable, report, isTaxEnabled, shouldShowExpenseReportBreakDown, diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx index 1969275c0f7b..93cdab2a6dfe 100644 --- a/src/components/Search/SearchTableHeader.tsx +++ b/src/components/Search/SearchTableHeader.tsx @@ -67,6 +67,11 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [ translationKey: 'common.merchant', canEdit: true, }, + { + columnName: CONST.SEARCH.TABLE_COLUMNS.VENDOR, + translationKey: 'common.vendor', + isColumnSortable: false, + }, { columnName: CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION, translationKey: 'common.description', diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx index bcf69deb6697..13610187f0f8 100644 --- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx +++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx @@ -25,7 +25,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getCategoryGLCode} from '@libs/CategoryUtils'; import getBase62ReportID from '@libs/getBase62ReportID'; -import {isTaxCodeCustomized, getTagGLCode} from '@libs/PolicyUtils'; +import {getTagGLCode, getVendorDisplayName, isTaxCodeCustomized} from '@libs/PolicyUtils'; import {getReportName} from '@libs/ReportNameUtils'; import {getReimbursableTotal, isExpenseReport} from '@libs/ReportUtils'; import {getViolationsForTransaction} from '@libs/SearchUIUtils'; @@ -391,6 +391,15 @@ function TransactionItemRowWide({ /> ); + case CONST.SEARCH.TABLE_COLUMNS.VENDOR: + return ( + + + + ); case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: return ( , isVendorMatchingBetaEnabled return isVendorMatchingBetaEnabled && (isXeroVendorMatchingActive(policy) || isRilletVendorMatchingActive(policy) || isDualEntryVendorMatchingActive(policy)); } +/** + * Search spans every workspace at once, so the vendor column is offered when any workspace has the vendor feature. + */ +function hasVendorFeatureOnAnyPolicy(policies: OnyxCollection, isVendorMatchingBetaEnabled: boolean): boolean { + return Object.values(policies ?? {}).some((policy) => hasVendorFeature(policy, isVendorMatchingBetaEnabled)); +} + /** * Single source of truth for which connected integration scopes the vendor field for this workspace * (QBO, Sage Intacct, Xero, Rillet, or DualEntry) and what its vendor list looks like. Returns `undefined` when no @@ -2917,6 +2925,18 @@ function findVendorByID(policy: OnyxEntry, vendorID: string | undefined) return getDualEntryVendors(policy).find((vendor) => vendor.id === vendorID); } +/** + * Display name of a transaction's vendor, or an empty string when none is assigned. The workspace's synced vendor list + * wins so renames in the accounting system show through. The name stored on the transaction covers vendors since + * removed from that list. + */ +function getVendorDisplayName(policy: OnyxEntry, vendor: TransactionCommentVendor | undefined): string { + if (!vendor?.externalID) { + return ''; + } + return findVendorByID(policy, vendor.externalID)?.name ?? vendor.name ?? ''; +} + /** * Resolves the text shown for a stored merchant-rule vendor ID. Prefer the active vendor-matching * source, use the unavailable label when its loaded list no longer contains the vendor, and retain @@ -3482,6 +3502,7 @@ export { getConnectedIntegration, getConnectionExporters, findVendorByID, + getVendorDisplayName, getActiveVendorMatchingIntegration, getMatchingVendorByID, getMatchingVendors, @@ -3496,6 +3517,7 @@ export { isXeroActiveMatchingSource, isXeroVendorMatchingActive, hasVendorFeature, + hasVendorFeatureOnAnyPolicy, isMatchingVendorListLoaded, getValidConnectedIntegration, getCountOfEnabledTagsOfList, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 7a54f9dcb0d6..a8c426554884 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -320,6 +320,7 @@ const transactionColumnNamesToSortingProperty: TransactionSorting = { [CONST.SEARCH.TABLE_COLUMNS.EXPORTED]: 'exported' as const, [CONST.SEARCH.TABLE_COLUMNS.TAG]: 'tag' as const, [CONST.SEARCH.TABLE_COLUMNS.MERCHANT]: 'formattedMerchant' as const, + [CONST.SEARCH.TABLE_COLUMNS.VENDOR]: null, [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]: 'formattedTotal' as const, [CONST.SEARCH.TABLE_COLUMNS.CATEGORY]: 'category' as const, [CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT]: 'originalAmount' as const, @@ -4328,6 +4329,8 @@ function getSearchColumnTranslationKey(column: SearchSortBy): TranslationPaths { return 'search.filters.exported'; case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: return 'common.merchant'; + case CONST.SEARCH.TABLE_COLUMNS.VENDOR: + return 'common.vendor'; case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: return 'common.description'; case CONST.SEARCH.TABLE_COLUMNS.FROM: @@ -6357,6 +6360,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.DATE]: true, [CONST.SEARCH.TABLE_COLUMNS.POSTED]: false, [CONST.SEARCH.TABLE_COLUMNS.MERCHANT]: false, + [CONST.SEARCH.TABLE_COLUMNS.VENDOR]: false, [CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION]: false, [CONST.SEARCH.TABLE_COLUMNS.CATEGORY]: false, [CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]: false, @@ -6387,6 +6391,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.SUBMITTED]: false, [CONST.SEARCH.TABLE_COLUMNS.APPROVED]: false, [CONST.SEARCH.TABLE_COLUMNS.MERCHANT]: false, + [CONST.SEARCH.TABLE_COLUMNS.VENDOR]: false, [CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION]: false, [CONST.SEARCH.TABLE_COLUMNS.FROM]: false, [CONST.SEARCH.TABLE_COLUMNS.TO]: false, @@ -6532,6 +6537,10 @@ function getColumnsToShow({ columns[CONST.SEARCH.TABLE_COLUMNS.CARD] = true; } + if (transaction.comment?.vendor?.externalID) { + columns[CONST.SEARCH.TABLE_COLUMNS.VENDOR] = true; + } + // Only show tax columns when the user explicitly chooses to display them. if (customResult) { // Show both TAX_RATE and TAX_AMOUNT when the transaction has a meaningful tax signal. @@ -7001,6 +7010,7 @@ function shouldShowDeleteOption( const FLEX_COLUMNS = new Set([ CONST.SEARCH.TABLE_COLUMNS.MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.VENDOR, CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION, CONST.SEARCH.TABLE_COLUMNS.CATEGORY, CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE, diff --git a/src/libs/getSearchColumnContentToMeasure.ts b/src/libs/getSearchColumnContentToMeasure.ts index 79aaacabfe8c..b63c270e4de2 100644 --- a/src/libs/getSearchColumnContentToMeasure.ts +++ b/src/libs/getSearchColumnContentToMeasure.ts @@ -17,7 +17,7 @@ import type {MeasurableFont} from './measureTextWidth/types'; import {getCompanyCardDescription} from './CardUtils'; import {getCategoryGLCode, getDecodedLeafCategoryName, isCategoryMissing} from './CategoryUtils'; import getBase62ReportID from './getBase62ReportID'; -import {getTagGLCode} from './PolicyUtils'; +import {getTagGLCode, getVendorDisplayName} from './PolicyUtils'; import {getReportName} from './ReportNameUtils'; import {getReportStatusTranslation} from './ReportUtils'; import { @@ -90,6 +90,7 @@ const EDITABLE_SEARCH_COLUMNS = new Set([ const DYNAMICALLY_SIZED_SEARCH_COLUMNS = new Set([ CONST.SEARCH.TABLE_COLUMNS.STATUS, CONST.SEARCH.TABLE_COLUMNS.MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.VENDOR, CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION, CONST.SEARCH.TABLE_COLUMNS.CATEGORY, CONST.SEARCH.TABLE_COLUMNS.TAG, @@ -131,6 +132,7 @@ const HUGGED_SEARCH_COLUMNS = new Set([CONST.SEARCH.TABLE_COLU const SEARCH_COLUMN_HEADER_TRANSLATION_KEYS: Partial> = { [CONST.SEARCH.TABLE_COLUMNS.STATUS]: 'common.status', [CONST.SEARCH.TABLE_COLUMNS.MERCHANT]: 'common.merchant', + [CONST.SEARCH.TABLE_COLUMNS.VENDOR]: 'common.vendor', [CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION]: 'common.description', [CONST.SEARCH.TABLE_COLUMNS.CATEGORY]: 'common.category', [CONST.SEARCH.TABLE_COLUMNS.TAG]: 'common.tag', @@ -216,6 +218,8 @@ function getTransactionColumnContentToMeasure( ]; case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: return [{text: getMerchantName(item, translate)}]; + case CONST.SEARCH.TABLE_COLUMNS.VENDOR: + return [{text: getVendorDisplayName(item.policy, item.comment?.vendor)}]; case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: return [{text: getDescription(item)}]; case CONST.SEARCH.TABLE_COLUMNS.CATEGORY: diff --git a/src/pages/Search/SearchColumnsPage.tsx b/src/pages/Search/SearchColumnsPage.tsx index 41939acbbef9..6af89ca55bd7 100644 --- a/src/pages/Search/SearchColumnsPage.tsx +++ b/src/pages/Search/SearchColumnsPage.tsx @@ -3,8 +3,10 @@ import {useSearchQueryContext} from '@components/Search/SearchContext'; import type {SearchCustomColumnIds} from '@components/Search/types'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import Navigation from '@libs/Navigation/Navigation'; +import {hasVendorFeatureOnAnyPolicy} from '@libs/PolicyUtils'; import {buildQueryStringFromFilterFormValues, getCurrentSearchQueryJSON, hasValuesIncludeViolationFilter} from '@libs/SearchQueryUtils'; import {getCustomColumnDefault, getCustomColumns, insertColumnBeforeTotalAmount} from '@libs/SearchUIUtils'; @@ -12,12 +14,22 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {Policy} from '@src/types/onyx'; -import React from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; + +import React, {useCallback} from 'react'; function SearchColumnsPage() { const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const {currentSearchKey} = useSearchQueryContext(); + const {isBetaEnabled} = usePermissions(); + const isVendorMatchingBetaEnabled = isBetaEnabled(CONST.BETAS.VENDOR_MATCHING); + const isVendorColumnAvailableSelector = useCallback( + (allPolicies: OnyxCollection) => hasVendorFeatureOnAnyPolicy(allPolicies, isVendorMatchingBetaEnabled), + [isVendorMatchingBetaEnabled], + ); + const [isVendorColumnAvailable = false] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: isVendorColumnAvailableSelector}); const groupBy = searchAdvancedFiltersForm?.groupBy; const queryType = searchAdvancedFiltersForm?.type ?? CONST.SEARCH.DATA_TYPES.EXPENSE; @@ -25,11 +37,15 @@ function SearchColumnsPage() { // Violations data is only returned when these filters are set, so hide the column otherwise. const shouldRequireViolationsColumn = hasValuesIncludeViolationFilter(searchAdvancedFiltersForm?.has); - const allTypeCustomColumns = getCustomColumns(queryType).filter((column) => shouldRequireViolationsColumn || column !== CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + // The vendor column only exists for workspaces with the vendor feature, so hide it when none of the user's workspaces has it. + const isColumnAvailable = (column: SearchCustomColumnIds) => + (shouldRequireViolationsColumn || column !== CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS) && (isVendorColumnAvailable || column !== CONST.SEARCH.TABLE_COLUMNS.VENDOR); + + const allTypeCustomColumns = getCustomColumns(queryType).filter(isColumnAvailable); const allGroupCustomColumns = getCustomColumns(groupBy); const defaultGroupCustomColumns = getCustomColumnDefault(groupBy); const defaultTypeCustomColumns = [...getCustomColumnDefault(queryType)]; - const currentColumns = [...(searchAdvancedFiltersForm?.columns ?? [])].filter((column) => shouldRequireViolationsColumn || column !== CONST.SEARCH.TABLE_COLUMNS.VIOLATIONS); + const currentColumns = [...(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 // since it makes sense for them to show up in an expense management App and it fixes the layout issues. diff --git a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx index 84fd8918fc7d..2428e21379e2 100644 --- a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx +++ b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx @@ -3,12 +3,13 @@ import type {SearchCustomColumnIds} from '@components/Search/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import {setReportDetailsColumns} from '@libs/actions/ReportLayout'; import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import {isPolicyTaxEnabled} from '@libs/PolicyUtils'; +import {hasVendorFeature, isPolicyTaxEnabled} from '@libs/PolicyUtils'; import {isIOUReport} from '@libs/ReportUtils'; import {getColumnsToShow} from '@libs/SearchUIUtils'; import {hasNonReimbursableTransactions} from '@libs/TransactionUtils'; @@ -65,6 +66,11 @@ function ReportDetailsColumnsPage() { }, }); const currentUserDetails = useCurrentUserPersonalDetails(); + const {isBetaEnabled} = usePermissions(); + + // The vendor column only exists for workspaces with the vendor feature, so hide it when this report's workspace lacks it. + const isVendorColumnAvailable = hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)); + const isColumnAvailable = (column: SearchCustomColumnIds) => isVendorColumnAvailable || column !== CONST.SEARCH.TABLE_COLUMNS.VENDOR; // Wait for transactions to load before rendering. ColumnsSettingsList snapshots // currentColumns in useState on mount and does not sync prop updates, so we must @@ -100,12 +106,14 @@ function ReportDetailsColumnsPage() { return visibleColumns.filter(isReportDetailsCustomColumn); }, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy]); + const allColumns = ALL_REPORT_DETAILS_CUSTOM_COLUMNS.filter(isColumnAvailable); + const currentColumns = effectiveColumns.filter(isColumnAvailable); const requiredColumns = new Set([CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]); const handleSave = (selectedColumnIds: SearchCustomColumnIds[]) => { // Skip saving if columns haven't changed from the effective state, to avoid // switching from the default path to the custom path in getColumnsToShow unnecessarily. - if (!arraysEqual(selectedColumnIds, effectiveColumns)) { + if (!arraysEqual(selectedColumnIds, currentColumns)) { setReportDetailsColumns(selectedColumnIds, reportDetailsColumns); } Navigation.goBack(); @@ -117,9 +125,9 @@ function ReportDetailsColumnsPage() { return ( diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 314ec3de6b00..4cd604c0f0e0 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2148,6 +2148,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ case CONST.SEARCH.TABLE_COLUMNS.REPORT_ID: case CONST.SEARCH.TABLE_COLUMNS.BASE_62_REPORT_ID: case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: + case CONST.SEARCH.TABLE_COLUMNS.VENDOR: case CONST.SEARCH.TABLE_COLUMNS.FROM: case CONST.SEARCH.TABLE_COLUMNS.TO: case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER: diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 568769de42e3..c123ac79551b 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -15,6 +15,8 @@ import { canSendInvoiceFromWorkspace, evaluateApprovalWorkflowRule, findVendorByID, + getVendorDisplayName, + hasVendorFeatureOnAnyPolicy, getActivePolicies, getActivePoliciesWithExpenseChat, getActivePoliciesWithExpenseChatAndPerDiemEnabled, @@ -4621,6 +4623,51 @@ describe('PolicyUtils', () => { }); }); + describe('hasVendorFeatureOnAnyPolicy', () => { + const qboPolicy: Policy = {...buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD), id: 'qbo'}; + const xeroPolicy: Policy = {...buildXeroPolicy(), id: 'xero'}; + const plainPolicy: Policy = {...createRandomPolicy(3), connections: undefined, id: 'plain'}; + const qboKey = `${ONYXKEYS.COLLECTION.POLICY}qbo`; + const xeroKey = `${ONYXKEYS.COLLECTION.POLICY}xero`; + const plainKey = `${ONYXKEYS.COLLECTION.POLICY}plain`; + + it('is false when no workspace has the vendor feature', () => { + expect(hasVendorFeatureOnAnyPolicy({[plainKey]: plainPolicy}, true)).toBe(false); + }); + + it('is true for a QBO workspace exporting card expenses as credit card transactions, without the beta', () => { + expect(hasVendorFeatureOnAnyPolicy({[qboKey]: qboPolicy, [plainKey]: plainPolicy}, false)).toBe(true); + }); + + it('is true for a Xero workspace with the beta', () => { + expect(hasVendorFeatureOnAnyPolicy({[xeroKey]: xeroPolicy, [plainKey]: plainPolicy}, true)).toBe(true); + }); + + it('ignores beta-gated integrations while the beta is off', () => { + expect(hasVendorFeatureOnAnyPolicy({[xeroKey]: xeroPolicy}, false)).toBe(false); + }); + }); + + describe('getVendorDisplayName', () => { + const qboPolicy = buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.CREDIT_CARD); + + it('returns an empty string when no vendor is assigned', () => { + expect(getVendorDisplayName(qboPolicy, undefined)).toBe(''); + }); + + it('prefers the synced vendor name over the name stored on the transaction', () => { + expect(getVendorDisplayName(qboPolicy, {externalID: 'v-1', name: 'Old Acme', wasManuallySet: true})).toBe('Acme Co'); + }); + + it('falls back to the stored name when the vendor is no longer in the synced list', () => { + expect(getVendorDisplayName(qboPolicy, {externalID: 'gone', name: 'Former Vendor', wasManuallySet: true})).toBe('Former Vendor'); + }); + + it('returns an empty string for a legacy vendor with neither a synced nor a stored name', () => { + expect(getVendorDisplayName(undefined, {externalID: 'v-9', wasManuallySet: true})).toBe(''); + }); + }); + describe('findVendorByID', () => { it('resolves a QBO vendor even when the current export mode is no longer vendor-matching (Vendor Bill)', () => { const policy = buildQBOPolicy(CONST.QUICKBOOKS_NON_REIMBURSABLE_EXPORT_ACCOUNT_TYPE.VENDOR_BILL, [{id: 'v-1', name: 'Acme', currency: 'USD'}]); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index a780b7558c6f..de756d9eacca 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -49,6 +49,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import createRandomPolicy from '../../utils/collections/policies'; +import createRandomTransaction from '../../utils/collections/transaction'; import createMock from '../../utils/createMock'; import getOnyxValue from '../../utils/getOnyxValue'; import {convertToDisplayString, formatPhoneNumber, getCurrencyDecimalsLocal, localeCompare, translateLocal} from '../../utils/TestHelper'; @@ -11090,6 +11091,25 @@ describe('SearchUIUtils', () => { }); describe('Test getColumnsToShow', () => { + test('Should show the vendor column on Search only when picked, and in the report view when an expense has a vendor assigned', () => { + const transactionWithoutVendor = createRandomTransaction(1); + const transactionWithVendor = {...createRandomTransaction(2), comment: {vendor: {externalID: 'qbo-1', name: 'Acme Tools', wasManuallySet: true}}}; + const pickedColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.VENDOR, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; + + expect(SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [transactionWithVendor], visibleColumns: [], type: CONST.SEARCH.DATA_TYPES.EXPENSE})).not.toContain( + CONST.SEARCH.TABLE_COLUMNS.VENDOR, + ); + expect(SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [transactionWithVendor], visibleColumns: pickedColumns, type: CONST.SEARCH.DATA_TYPES.EXPENSE})).toContain( + CONST.SEARCH.TABLE_COLUMNS.VENDOR, + ); + expect( + SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [transactionWithoutVendor], visibleColumns: [], type: CONST.SEARCH.DATA_TYPES.EXPENSE, isExpenseReportView: true}), + ).not.toContain(CONST.SEARCH.TABLE_COLUMNS.VENDOR); + expect( + SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [transactionWithVendor], visibleColumns: [], type: CONST.SEARCH.DATA_TYPES.EXPENSE, isExpenseReportView: true}), + ).toContain(CONST.SEARCH.TABLE_COLUMNS.VENDOR); + }); + test('Should show all default columns when no custom columns are saved & viewing expense reports', () => { expect(SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [], visibleColumns: [], type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT})).toEqual([ CONST.SEARCH.TABLE_COLUMNS.AVATAR, @@ -13967,6 +13987,12 @@ describe('SearchUIUtils', () => { }); }); + describe('vendor column label', () => { + test('Should label the vendor column as Vendor', () => { + expect(SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.VENDOR)).toBe('common.vendor'); + }); + }); + describe('getDisplayValue', () => { test('returns translated has option labels from getHasOptions', () => { const result = SearchUIUtils.getDisplayValue('has', {has: [CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION]}, CONST.SEARCH.DATA_TYPES.EXPENSE, translateLocal, localeCompare); diff --git a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap index d415876f37bb..c85a7d23b544 100644 --- a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap +++ b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap @@ -6,6 +6,7 @@ exports[`Column availability single source of truth the derived picker lists mat "date", "posted", "merchant", + "vendor", "description", "card", "category", @@ -41,6 +42,7 @@ exports[`Column availability single source of truth the derived picker lists mat "posted", "exported", "merchant", + "vendor", "description", "from", "to",