diff --git a/cspell.json b/cspell.json index 350cc0b49cb2..bbfbd042ee91 100644 --- a/cspell.json +++ b/cspell.json @@ -585,6 +585,7 @@ "expensifyneue", "expensifynewkansas", "expensifyreactnative", + "exportedto", "fabs", "falso", "favicons", @@ -900,6 +901,7 @@ "symbolspacer", "systempreferences", "tabindex", + "taxrate", "teachersunite", "testflight", "textanchor", @@ -910,6 +912,7 @@ "tickvalues", "tnode", "tobe", + "toggleable", "togglefullscreen", "tosorted", "touchables", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3d3410e0d8f9..cc92a7c05b68 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6641,43 +6641,65 @@ const CONST = { YEAR: 'year', QUARTER: 'quarter', }, + /** + * Single source of truth for which transaction-level columns are available in each column-picker surface: + * - `search`: the Spend/Search picker (`/search` → Display → Edit columns), read via `TYPE_CUSTOM_COLUMNS.EXPENSE`. + * - `reportView`: the single-report picker (`r/:reportID/settings/columns`), read via `REPORT_DETAILS_CUSTOM_COLUMNS`. + * + * Both `TYPE_CUSTOM_COLUMNS.EXPENSE` and `REPORT_DETAILS_CUSTOM_COLUMNS` are derived from this map (preserving + * this declaration order), so adding a new column forces a single, deliberate availability decision here instead + * of silently drifting between two hand-maintained lists. A guard test (`tests/unit/Search/ColumnAvailabilityTest.ts`) + * asserts every expense-relevant `TABLE_COLUMNS` value is classified here or explicitly owned by another surface. + * + * Rule of thumb for `reportView`: a column belongs in the single-report view only if its value can differ between + * expenses within the same report. Report-level values (identical on every row) stay Search-only. + */ + get COLUMN_AVAILABILITY() { + return { + RECEIPT: {column: this.TABLE_COLUMNS.RECEIPT, search: true, reportView: true}, + DATE: {column: this.TABLE_COLUMNS.DATE, search: true, reportView: true}, + STATUS: {column: this.TABLE_COLUMNS.STATUS, search: true, reportView: false}, + SUBMITTED: {column: this.TABLE_COLUMNS.SUBMITTED, search: true, reportView: false}, + APPROVED: {column: this.TABLE_COLUMNS.APPROVED, search: true, reportView: false}, + POSTED: {column: this.TABLE_COLUMNS.POSTED, search: true, reportView: true}, + EXPORTED: {column: this.TABLE_COLUMNS.EXPORTED, search: true, reportView: false}, + MERCHANT: {column: this.TABLE_COLUMNS.MERCHANT, search: true, reportView: true}, + DESCRIPTION: {column: this.TABLE_COLUMNS.DESCRIPTION, search: true, reportView: true}, + FROM: {column: this.TABLE_COLUMNS.FROM, search: true, reportView: false}, + TO: {column: this.TABLE_COLUMNS.TO, search: true, reportView: false}, + POLICY_NAME: {column: this.TABLE_COLUMNS.POLICY_NAME, search: true, reportView: false}, + CARD: {column: this.TABLE_COLUMNS.CARD, search: true, reportView: true}, + CATEGORY: {column: this.TABLE_COLUMNS.CATEGORY, search: true, reportView: true}, + CATEGORY_GL_CODE: {column: this.TABLE_COLUMNS.CATEGORY_GL_CODE, search: true, reportView: true}, + 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}, + 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}, + BASE_62_REPORT_ID: {column: this.TABLE_COLUMNS.BASE_62_REPORT_ID, search: true, reportView: false}, + REIMBURSABLE: {column: this.TABLE_COLUMNS.REIMBURSABLE, search: true, reportView: true}, + BILLABLE: {column: this.TABLE_COLUMNS.BILLABLE, search: true, reportView: true}, + MCC: {column: this.TABLE_COLUMNS.MCC, search: true, reportView: true}, + TAX_CODE: {column: this.TABLE_COLUMNS.TAX_CODE, search: true, reportView: true}, + TAX_RATE: {column: this.TABLE_COLUMNS.TAX_RATE, search: true, reportView: true}, + TAX_AMOUNT: {column: this.TABLE_COLUMNS.TAX_AMOUNT, search: true, reportView: true}, + TITLE: {column: this.TABLE_COLUMNS.TITLE, search: true, reportView: false}, + AMOUNT: {column: this.TABLE_COLUMNS.TOTAL_AMOUNT, search: true, reportView: true}, + TOTAL: {column: this.TABLE_COLUMNS.TOTAL, search: false, reportView: true}, + EXPORTED_TO: {column: this.TABLE_COLUMNS.EXPORTED_TO, search: true, reportView: false}, + ACTION: {column: this.TABLE_COLUMNS.ACTION, search: true, reportView: false}, + WITHDRAWAL_ID: {column: this.TABLE_COLUMNS.WITHDRAWAL_ID, search: true, reportView: true}, + }; + }, get TYPE_CUSTOM_COLUMNS() { return { - EXPENSE: { - RECEIPT: this.TABLE_COLUMNS.RECEIPT, - DATE: this.TABLE_COLUMNS.DATE, - STATUS: this.TABLE_COLUMNS.STATUS, - SUBMITTED: this.TABLE_COLUMNS.SUBMITTED, - APPROVED: this.TABLE_COLUMNS.APPROVED, - POSTED: this.TABLE_COLUMNS.POSTED, - EXPORTED: this.TABLE_COLUMNS.EXPORTED, - MERCHANT: this.TABLE_COLUMNS.MERCHANT, - DESCRIPTION: this.TABLE_COLUMNS.DESCRIPTION, - FROM: this.TABLE_COLUMNS.FROM, - TO: this.TABLE_COLUMNS.TO, - POLICY_NAME: this.TABLE_COLUMNS.POLICY_NAME, - CARD: this.TABLE_COLUMNS.CARD, - CATEGORY: this.TABLE_COLUMNS.CATEGORY, - CATEGORY_GL_CODE: this.TABLE_COLUMNS.CATEGORY_GL_CODE, - ATTENDEES: this.TABLE_COLUMNS.ATTENDEES, - TOTAL_PER_ATTENDEE: this.TABLE_COLUMNS.TOTAL_PER_ATTENDEE, - TAG: this.TABLE_COLUMNS.TAG, - EXCHANGE_RATE: this.TABLE_COLUMNS.EXCHANGE_RATE, - ORIGINAL_AMOUNT: this.TABLE_COLUMNS.ORIGINAL_AMOUNT, - REPORT_ID: this.TABLE_COLUMNS.REPORT_ID, - BASE_62_REPORT_ID: this.TABLE_COLUMNS.BASE_62_REPORT_ID, - REIMBURSABLE: this.TABLE_COLUMNS.REIMBURSABLE, - BILLABLE: this.TABLE_COLUMNS.BILLABLE, - MCC: this.TABLE_COLUMNS.MCC, - TAX_CODE: this.TABLE_COLUMNS.TAX_CODE, - TAX_RATE: this.TABLE_COLUMNS.TAX_RATE, - TAX_AMOUNT: this.TABLE_COLUMNS.TAX_AMOUNT, - TITLE: this.TABLE_COLUMNS.TITLE, - AMOUNT: this.TABLE_COLUMNS.TOTAL_AMOUNT, - EXPORTED_TO: this.TABLE_COLUMNS.EXPORTED_TO, - ACTION: this.TABLE_COLUMNS.ACTION, - WITHDRAWAL_ID: this.TABLE_COLUMNS.WITHDRAWAL_ID, - }, + // Derived from COLUMN_AVAILABILITY: every column flagged `search`, in declaration order. + EXPENSE: Object.fromEntries( + Object.entries(this.COLUMN_AVAILABILITY) + .filter(([, availability]) => availability.search) + .map(([key, availability]) => [key, availability.column] as const), + ), EXPENSE_REPORT: { AVATAR: this.TABLE_COLUMNS.AVATAR, DATE: this.TABLE_COLUMNS.DATE, @@ -6703,27 +6725,13 @@ const CONST = { CHAT: {}, }; }, + // Derived from COLUMN_AVAILABILITY: every column flagged `reportView`, in declaration order. get REPORT_DETAILS_CUSTOM_COLUMNS() { - return { - RECEIPT: this.TABLE_COLUMNS.RECEIPT, - DATE: this.TABLE_COLUMNS.DATE, - MERCHANT: this.TABLE_COLUMNS.MERCHANT, - DESCRIPTION: this.TABLE_COLUMNS.DESCRIPTION, - CARD: this.TABLE_COLUMNS.CARD, - CATEGORY: this.TABLE_COLUMNS.CATEGORY, - CATEGORY_GL_CODE: this.TABLE_COLUMNS.CATEGORY_GL_CODE, - TAG: this.TABLE_COLUMNS.TAG, - EXCHANGE_RATE: this.TABLE_COLUMNS.EXCHANGE_RATE, - REIMBURSABLE: this.TABLE_COLUMNS.REIMBURSABLE, - BILLABLE: this.TABLE_COLUMNS.BILLABLE, - MCC: this.TABLE_COLUMNS.MCC, - TAX_CODE: this.TABLE_COLUMNS.TAX_CODE, - TAX_RATE: this.TABLE_COLUMNS.TAX_RATE, - TAX_AMOUNT: this.TABLE_COLUMNS.TAX_AMOUNT, - AMOUNT: this.TABLE_COLUMNS.TOTAL_AMOUNT, - TOTAL: this.TABLE_COLUMNS.TOTAL, - WITHDRAWAL_ID: this.TABLE_COLUMNS.WITHDRAWAL_ID, - }; + return Object.fromEntries( + Object.entries(this.COLUMN_AVAILABILITY) + .filter(([, availability]) => availability.reportView) + .map(([key, availability]) => [key, availability.column] as const), + ); }, get GROUP_CUSTOM_COLUMNS() { return { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 29ef2ccc7e3d..4ba8d6ddf7c9 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -5656,6 +5656,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.RECEIPT]: true, [CONST.SEARCH.TABLE_COLUMNS.TYPE]: true, [CONST.SEARCH.TABLE_COLUMNS.DATE]: true, + [CONST.SEARCH.TABLE_COLUMNS.POSTED]: false, [CONST.SEARCH.TABLE_COLUMNS.MERCHANT]: false, [CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION]: false, [CONST.SEARCH.TABLE_COLUMNS.CATEGORY]: false, @@ -5667,6 +5668,7 @@ function getColumnsToShow({ [CONST.SEARCH.TABLE_COLUMNS.TAX_RATE]: false, [CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT]: false, [CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE]: false, + [CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT]: false, [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE]: shouldShowReimbursableColumn, [CONST.SEARCH.TABLE_COLUMNS.BILLABLE]: shouldShowBillableColumn, [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]: false, @@ -5839,17 +5841,29 @@ function getColumnsToShow({ if (hasExchangeRate) { columns[CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE] = true; } - // Expense report view: TOTAL (workspace currency) is always shown; add AMOUNT - // (transaction's own currency) when a conversion exists so both are visible. - // Search page: show ORIGINAL_AMOUNT column (transaction's original amount). + // 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 (!Array.isArray(data)) { if (transactionReport?.submitted) { columns[CONST.SEARCH.TABLE_COLUMNS.SUBMITTED] = true; diff --git a/tests/unit/Search/ColumnAvailabilityTest.ts b/tests/unit/Search/ColumnAvailabilityTest.ts new file mode 100644 index 000000000000..5927f9cf896f --- /dev/null +++ b/tests/unit/Search/ColumnAvailabilityTest.ts @@ -0,0 +1,130 @@ +import CONST from '@src/CONST'; + +/** + * Guard tests for the single source of truth that drives the two expense column pickers. + * + * `CONST.SEARCH.COLUMN_AVAILABILITY` declares, per transaction-level column, whether it is available in the + * Search picker (`search`) and/or the single-report picker (`reportView`). Both + * `CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE` and `CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS` are derived from it. + * + * These tests fail if a new `TABLE_COLUMNS` value is added without a deliberate availability decision (either an + * entry in `COLUMN_AVAILABILITY` or an explicit listing under another surface), preventing the two pickers from + * silently drifting apart the way they did historically. + */ +describe('Column availability single source of truth', () => { + const TABLE_COLUMNS = CONST.SEARCH.TABLE_COLUMNS; + const allTableColumns = new Set(Object.values(TABLE_COLUMNS)); + const availabilityColumns: string[] = Object.values(CONST.SEARCH.COLUMN_AVAILABILITY).map((availability) => availability.column); + + // Columns that intentionally belong to surfaces other than the two expense column pickers. Every + // TABLE_COLUMNS value must be either classified in COLUMN_AVAILABILITY or listed here, so adding a new + // column forces a deliberate decision instead of silently doing nothing. + const OTHER_SURFACE_COLUMNS = new Set([ + // Structural / row-chrome columns rendered implicitly, never user-toggleable. + TABLE_COLUMNS.AVATAR, + TABLE_COLUMNS.TYPE, + TABLE_COLUMNS.COMMENTS, + // Task search view. + TABLE_COLUMNS.ASSIGNEE, + TABLE_COLUMNS.IN, + // Expense-report (report list) view. + TABLE_COLUMNS.REIMBURSABLE_TOTAL, + TABLE_COLUMNS.NON_REIMBURSABLE_TOTAL, + // Grouped search views. + TABLE_COLUMNS.EXPENSES, + TABLE_COLUMNS.FEED, + TABLE_COLUMNS.WITHDRAWN, + TABLE_COLUMNS.BANK_ACCOUNT, + TABLE_COLUMNS.GROUP_FROM, + TABLE_COLUMNS.GROUP_EXPENSES, + TABLE_COLUMNS.GROUP_TOTAL, + TABLE_COLUMNS.GROUP_CARD, + TABLE_COLUMNS.GROUP_FEED, + TABLE_COLUMNS.GROUP_BANK_ACCOUNT, + TABLE_COLUMNS.GROUP_WITHDRAWN, + TABLE_COLUMNS.GROUP_WITHDRAWAL_ID, + TABLE_COLUMNS.GROUP_CATEGORY, + TABLE_COLUMNS.GROUP_MERCHANT, + TABLE_COLUMNS.GROUP_TAG, + TABLE_COLUMNS.GROUP_MONTH, + TABLE_COLUMNS.GROUP_WEEK, + TABLE_COLUMNS.GROUP_YEAR, + TABLE_COLUMNS.GROUP_QUARTER, + TABLE_COLUMNS.GROUP_WITHDRAWAL_STATUS, + ]); + + test('every TABLE_COLUMNS value is classified in COLUMN_AVAILABILITY or owned by another surface', () => { + const unclassified = [...allTableColumns].filter((column) => !availabilityColumns.includes(column) && !OTHER_SURFACE_COLUMNS.has(column)); + + // If this fails, a new TABLE_COLUMNS value was added without deciding its picker availability. + // Add it to CONST.SEARCH.COLUMN_AVAILABILITY (with search/reportView flags) or, if it belongs to + // another surface (grouped/report-list/task view), to OTHER_SURFACE_COLUMNS above. + expect(unclassified).toEqual([]); + }); + + test('COLUMN_AVAILABILITY and OTHER_SURFACE_COLUMNS are disjoint', () => { + const overlap = availabilityColumns.filter((column) => OTHER_SURFACE_COLUMNS.has(column)); + expect(overlap).toEqual([]); + }); + + test('every value in COLUMN_AVAILABILITY is a valid TABLE_COLUMNS value', () => { + const invalid = availabilityColumns.filter((column) => !allTableColumns.has(column)); + expect(invalid).toEqual([]); + }); + + test('TYPE_CUSTOM_COLUMNS.EXPENSE is exactly the search-enabled columns in declaration order', () => { + const expectedSearchColumns = Object.values(CONST.SEARCH.COLUMN_AVAILABILITY) + .filter((availability) => availability.search) + .map((availability) => availability.column); + expect(Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE)).toEqual(expectedSearchColumns); + }); + + test('REPORT_DETAILS_CUSTOM_COLUMNS is exactly the reportView-enabled columns in declaration order', () => { + const expectedReportColumns = Object.values(CONST.SEARCH.COLUMN_AVAILABILITY) + .filter((availability) => availability.reportView) + .map((availability) => availability.column); + expect(Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS)).toEqual(expectedReportColumns); + }); + + test('every derived picker column is a valid TABLE_COLUMNS value', () => { + const expenseColumns = Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE); + const reportColumns = Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS); + expect(expenseColumns.filter((column) => !allTableColumns.has(column))).toEqual([]); + expect(reportColumns.filter((column) => !allTableColumns.has(column))).toEqual([]); + }); + + test('the four transaction-level columns are aligned into the single-report picker', () => { + const reportColumns = Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS); + expect(reportColumns).toContain(TABLE_COLUMNS.POSTED); + expect(reportColumns).toContain(TABLE_COLUMNS.ATTENDEES); + expect(reportColumns).toContain(TABLE_COLUMNS.TOTAL_PER_ATTENDEE); + expect(reportColumns).toContain(TABLE_COLUMNS.ORIGINAL_AMOUNT); + }); + + test('report-level columns stay out of the single-report picker', () => { + const reportColumns = Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS); + const reportLevelColumns = [ + TABLE_COLUMNS.STATUS, + TABLE_COLUMNS.SUBMITTED, + TABLE_COLUMNS.APPROVED, + TABLE_COLUMNS.EXPORTED, + TABLE_COLUMNS.EXPORTED_TO, + TABLE_COLUMNS.FROM, + TABLE_COLUMNS.TO, + TABLE_COLUMNS.POLICY_NAME, + TABLE_COLUMNS.REPORT_ID, + TABLE_COLUMNS.BASE_62_REPORT_ID, + TABLE_COLUMNS.TITLE, + TABLE_COLUMNS.ACTION, + ]; + for (const column of reportLevelColumns) { + expect(reportColumns).not.toContain(column); + } + }); + + test('the derived picker lists match their snapshots', () => { + // Snapshots make any future change to either picker list a deliberate, reviewed update. + expect(Object.values(CONST.SEARCH.TYPE_CUSTOM_COLUMNS.EXPENSE)).toMatchSnapshot('TYPE_CUSTOM_COLUMNS.EXPENSE'); + expect(Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS)).toMatchSnapshot('REPORT_DETAILS_CUSTOM_COLUMNS'); + }); +}); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index f6b37c1597a8..eac296bb8cff 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -8891,6 +8891,92 @@ describe('SearchUIUtils', () => { expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.CARD); }); + test('Should hide empty POSTED column in expense report view with custom columns', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + const testTransaction = { + ...baseTransaction, + transactionID: 'test', + merchant: 'Test Merchant', + posted: undefined, + }; + + 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); + }); + + test('Should show POSTED column when a transaction has a posting date', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + const testTransaction = { + ...baseTransaction, + transactionID: 'test', + merchant: 'Test Merchant', + posted: '20240115', + }; + + 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}); + + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.POSTED); + }); + + test('Should hide empty ORIGINAL_AMOUNT column in expense report view with custom columns', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + const testTransaction = { + ...baseTransaction, + transactionID: 'test', + merchant: 'Test Merchant', + groupExchangeRate: undefined, + convertedAmount: undefined, + }; + + 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); + }); + + test('Should show ORIGINAL_AMOUNT column when a transaction has a currency conversion', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + const testTransaction = { + ...baseTransaction, + transactionID: 'test', + merchant: 'Test Merchant', + groupExchangeRate: 1.25, + groupCurrency: 'EUR', + currency: 'USD', + }; + + const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; + const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data: [testTransaction], visibleColumns, isExpenseReportView: true}); + + expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT); + }); + + test('Should not show POSTED or ORIGINAL_AMOUNT by default in expense report view when not explicitly selected', () => { + const baseTransaction = searchResults.data[`transactions_${transactionID}`]; + // Transaction has BOTH a posting date and a currency conversion, but no custom column + // selection is provided (visibleColumns is empty). POSTED and ORIGINAL_AMOUNT must only + // appear when the user explicitly picks them, not in the default report view. + const testTransaction = { + ...baseTransaction, + transactionID: 'test', + merchant: 'Test Merchant', + posted: '20240115', + groupExchangeRate: 1.25, + groupCurrency: 'EUR', + currency: 'USD', + }; + + const columns = SearchUIUtils.getColumnsToShow({currentAccountID: submitterAccountID, data: [testTransaction], visibleColumns: [], isExpenseReportView: true}); + + expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.POSTED); + expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT); + }); + test('Should hide empty TAX columns in expense report view with custom columns', () => { const baseTransaction = searchResults.data[`transactions_${transactionID}`]; const testTransaction = { diff --git a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap new file mode 100644 index 000000000000..097902238eb0 --- /dev/null +++ b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Column availability single source of truth the derived picker lists match their snapshots: REPORT_DETAILS_CUSTOM_COLUMNS 1`] = ` +[ + "receipt", + "date", + "posted", + "merchant", + "description", + "card", + "category", + "categoryGLCode", + "attendees", + "totalPerAttendee", + "tag", + "exchangeRate", + "originalamount", + "reimbursable", + "billable", + "mcc", + "taxCode", + "taxrate", + "taxAmount", + "amount", + "total", + "withdrawalID", +] +`; + +exports[`Column availability single source of truth the derived picker lists match their snapshots: TYPE_CUSTOM_COLUMNS.EXPENSE 1`] = ` +[ + "receipt", + "date", + "status", + "submitted", + "approved", + "posted", + "exported", + "merchant", + "description", + "from", + "to", + "policyname", + "card", + "category", + "categoryGLCode", + "attendees", + "totalPerAttendee", + "tag", + "exchangeRate", + "originalamount", + "reportID", + "base62ReportID", + "reimbursable", + "billable", + "mcc", + "taxCode", + "taxrate", + "taxAmount", + "title", + "amount", + "exportedto", + "action", + "withdrawalID", +] +`;