From 9504bc0b7bef0ebe697d9ed7fc61f8eb6961fac6 Mon Sep 17 00:00:00 2001 From: brunovjk Date: Mon, 28 Sep 2026 13:59:37 -0300 Subject: [PATCH 01/23] Show configured report columns --- src/CONST/index.ts | 2 + src/ROUTES.ts | 2 +- src/components/ColumnsSettingsList.tsx | 9 +- .../MoneyReportHeaderSecondaryActions.tsx | 15 +- .../MoneyRequestHeaderSecondaryActions.tsx | 12 + .../ReportActionItem/MoneyRequestView.tsx | 785 ++++++++++-------- .../MoneyRequestViewAdditionalField.tsx | 108 +++ src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 1 + src/libs/PopoverMenuSections.ts | 4 +- src/libs/ReportSecondaryActionUtils.ts | 8 + src/libs/getMoneyRequestViewFields.ts | 35 + .../Report/ReportDetailsColumnsPage.tsx | 16 +- tests/ui/MoneyRequestViewTest.tsx | 50 ++ tests/unit/ReportSecondaryActionUtilsTest.ts | 50 ++ tests/unit/getMoneyRequestViewFieldsTest.ts | 49 ++ .../settings/ReportDetailsColumnsPageTest.tsx | 15 + 28 files changed, 814 insertions(+), 359 deletions(-) create mode 100644 src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx create mode 100644 src/libs/getMoneyRequestViewFields.ts create mode 100644 tests/unit/getMoneyRequestViewFieldsTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 280587dd84c9..b3981ac10bab 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1630,6 +1630,7 @@ const CONST = { PRINT: 'print', CHANGE_WORKSPACE: 'changeWorkspace', CHANGE_APPROVER: 'changeApprover', + CUSTOMIZE_FIELDS: 'customizeFields', VIEW_DETAILS: 'viewDetails', DELETE: 'delete', RETRACT: 'retract', @@ -1690,6 +1691,7 @@ const CONST = { HOLD: 'hold', REMOVE_HOLD: 'removeHold', SPLIT: 'split', + CUSTOMIZE_FIELDS: 'customizeFields', VIEW_DETAILS: 'viewDetails', DELETE: 'delete', REJECT: 'reject', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cf72b54057d5..5c7107a19d3d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2732,7 +2732,7 @@ const ROUTES = { }, REPORT_SETTINGS_COLUMNS: { route: 'r/:reportID/settings/columns', - getRoute: (reportID: string) => `r/${reportID}/settings/columns` as const, + getRoute: (reportID: string, isExpenseView = false) => `r/${reportID}/settings/columns${isExpenseView ? '?isExpenseView=true' : ''}` as const, }, CHRONOS_SCHEDULE_OOO: { route: 'r/:reportID/chronos/schedule-ooo', diff --git a/src/components/ColumnsSettingsList.tsx b/src/components/ColumnsSettingsList.tsx index a065dd38942d..9e5438bab856 100644 --- a/src/components/ColumnsSettingsList.tsx +++ b/src/components/ColumnsSettingsList.tsx @@ -7,6 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getSearchColumnTranslationKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type {ComponentRef} from 'react'; @@ -48,6 +49,9 @@ type ColumnItem = { }; type ColumnsSettingsListProps = { + /** Heading for the surface sharing the column preferences */ + titleKey?: TranslationPaths; + allColumns: SearchCustomColumnIds[]; /** The default set of selected columns when no customization has been applied */ @@ -76,6 +80,7 @@ type ColumnsSettingsListProps = { }; function ColumnsSettingsList({ + titleKey = 'search.columns', allColumns, defaultSelectedColumns, currentColumns, @@ -250,9 +255,7 @@ function ColumnsSettingsList({ offlineIndicatorStyle={styles.mtAuto} includeSafeAreaPaddingBottom > - - {!isDefaultState && {translate('search.resetColumns')}} - + {!isDefaultState && {translate('search.resetColumns')}} = { + [CONST.REPORT.SECONDARY_ACTIONS.CUSTOMIZE_FIELDS]: { + value: CONST.REPORT.SECONDARY_ACTIONS.CUSTOMIZE_FIELDS, + text: translate('search.customizeFields'), + icon: expensifyIcons.Table, + onSelected: () => { + if (!reportID) { + return; + } + Navigation.navigate(ROUTES.REPORT_SETTINGS_COLUMNS.getRoute(reportID, true)); + }, + }, [CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS]: { value: CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS, text: translate('iou.viewDetails'), diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 4926724f2a0d..b1117da87d00 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -135,6 +135,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money 'DocumentMerge', 'ExpenseCopy', 'Info', + 'Table', 'Send', 'Stopwatch', 'ThumbsDown', @@ -536,6 +537,17 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money }, shouldCloseModalOnSelect: shouldDuplicateCloseModalOnSelect, }, + [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.CUSTOMIZE_FIELDS]: { + value: CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.CUSTOMIZE_FIELDS, + text: translate('search.customizeFields'), + icon: expensifyIcons.Table, + onSelected: () => { + if (!parentReport?.reportID) { + return; + } + Navigation.navigate(ROUTES.REPORT_SETTINGS_COLUMNS.getRoute(parentReport.reportID, true)); + }, + }, [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS]: { value: CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS, text: translate('iou.viewDetails'), diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 7d9d397b71e8..7fc74ba09282 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -10,6 +10,7 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import {useSearchResultsContext} from '@components/Search/SearchContext'; +import type {SearchColumnType} from '@components/Search/types'; import Switch from '@components/Switch'; import Text from '@components/Text'; import UserPills from '@components/UserPills'; @@ -55,6 +56,7 @@ import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/Atte import {getBrokenConnectionUrlToFixPersonalCard, getCommercialFeedCardDescription, getCompanyCardDescription} from '@libs/CardUtils'; import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; +import getMoneyRequestViewFields from '@libs/getMoneyRequestViewFields'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils'; import {getRateFromMerchant} from '@libs/MergeTransactionUtils'; @@ -77,6 +79,7 @@ import { isPerDiemEnabled, isPolicyAccessible, isTaxTrackingEnabled, + isPolicyTaxEnabled, resolveCurrentTaxCode, isXeroActiveMatchingSource, } from '@libs/PolicyUtils'; @@ -97,6 +100,7 @@ import { isTrackExpenseReportNew, shouldEnableNegative, } from '@libs/ReportUtils'; +import {getColumnsToShow, isReportDetailsCustomColumn} from '@libs/SearchUIUtils'; import {getDependentTagVisibility, hasEnabledTags} from '@libs/TagsOptionsListUtils'; import { getAttendeesListDisplayString, @@ -164,6 +168,7 @@ import {View} from 'react-native'; import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; import MoneyRequestReceiptView from './MoneyRequestReceiptView'; +import MoneyRequestViewAdditionalField from './MoneyRequestViewAdditionalField'; type MoneyRequestViewProps = { transactionThreadReport?: OnyxEntry; @@ -219,6 +224,7 @@ function MoneyRequestView({ const {convertToDisplayString, getCurrencySymbol, getCurrencyDecimals} = useCurrencyListActions(); const {getReportRHPActiveRoute} = useActiveRoute(); const {showConfirmModal} = useConfirmModal(); + const [reportDetailsColumns] = useOnyx(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS); const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); const {currentSearchResults} = useSearchResultsContext(); @@ -1207,108 +1213,113 @@ function MoneyRequestView({ return ; } - return ( - - {shouldShowAnimatedBackground && } - <> - {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( - - )} - {isCustomUnitOutOfPolicy && isPerDiemRequest && ( - - - - {translate('violations.customUnitOutOfPolicy')} - - - )} - - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (shouldShowSplitIndicator && isSplitAvailable) { - initSplitExpense( - transaction, - transactionThreadReport, - splitEffectivePolicy, - selfDMReportID, - restrictedActionPolicyID, - personalPolicy?.outputCurrency, - getCurrencyDecimals, - getCurrencySymbol, - ); - return; - } - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - '', - '', - getReportRHPActiveRoute(), - ), - ); - }} - brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('amount')} - copyValue={amountCopyValue} - copyable={!!amountCopyValue} - /> - - {!shouldHideEmptyDescription && ( - + // Merge/duplicate previews and personal requests retain their existing editing layout. + const shouldUseReportFields = isExpenseReport(parentReport) && !isFromMergeTransaction && !isFromReviewDuplicates; + const savedColumns = shouldUseReportFields ? (reportDetailsColumns ?? []).filter(isReportDetailsCustomColumn) : []; + const tableColumns = shouldUseReportFields + ? getColumnsToShow({ + currentAccountID: currentUserAccountIDParam, + data: visibleParentReportTransactions, + report: parentReport, + visibleColumns: savedColumns, + isExpenseReportView: true, + shouldShowBillableColumn: isBillableEnabledOnPolicy(policy), + shouldShowReimbursableColumn: visibleParentReportTransactions.some((item) => !getReimbursable(item)), + reportCurrency: parentReport?.currency, + isPolicyTaxEnabled: isPolicyTaxEnabled(policy), + isVendorColumnAvailable: hasVendorFeature(policy, isBetaEnabled(CONST.BETAS.VENDOR_MATCHING)), + }) + : []; + const fieldsToShow = getMoneyRequestViewFields(tableColumns, savedColumns.length > 0); + + const renderField = (column: SearchColumnType) => { + switch (column) { + case CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT: + return ( + { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (shouldShowSplitIndicator && isSplitAvailable) { + initSplitExpense( + transaction, + transactionThreadReport, + splitEffectivePolicy, + selfDMReportID, + restrictedActionPolicyID, + personalPolicy?.outputCurrency, + getCurrencyDecimals, + getCurrencySymbol, + ); + return; + } + Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), + ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport.reportID, + '', + '', + getReportRHPActiveRoute(), ), ); }} - wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} - brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('comment')} - numberOfLinesTitle={0} - copyValue={descriptionCopyValue} - copyable={!!descriptionCopyValue} + brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('amount')} + copyValue={amountCopyValue} + copyable={!!amountCopyValue} /> - )} - {isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( + ); + case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: + return ( + !shouldHideEmptyDescription && ( + + { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport?.reportID, + ), + ), + ); + }} + wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} + brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('comment')} + numberOfLinesTitle={0} + copyValue={descriptionCopyValue} + copyable={!!descriptionCopyValue} + /> + + ) + ); + case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: + return isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( distanceRequestFields ) : ( @@ -1334,53 +1345,72 @@ function MoneyRequestView({ copyable={!!merchantCopyValue} /> - )} - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('date')} - copyValue={dateCopyValue} - copyable={!!dateCopyValue} - /> - - {!!shouldShowCategory && ( - + ); + case CONST.SEARCH.TABLE_COLUMNS.DATE: + return ( + { - if (shouldShowCategoryDisabledAlert) { - showCategoryDisabledAlert(); - return; - } - - if (shouldNavigateToUpgradePath && transactionThreadReport) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, - upgradeBackTo: createDynamicRoute( + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), + ), + ); + }} + brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('date')} + copyValue={dateCopyValue} + copyable={!!dateCopyValue} + /> + + ); + case CONST.SEARCH.TABLE_COLUMNS.CATEGORY: + return ( + !!shouldShowCategory && ( + + { + if (shouldShowCategoryDisabledAlert) { + showCategoryDisabledAlert(); + return; + } + + if (shouldNavigateToUpgradePath && transactionThreadReport) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, + upgradeBackTo: createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + }), + ), + ); + } else if (!policy && shouldSelectPolicy) { + Navigation.navigate( + ROUTES.SET_DEFAULT_WORKSPACE.getRoute( + createDynamicRoute( DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ action: CONST.IOU.ACTION.EDIT, iouType, @@ -1388,12 +1418,10 @@ function MoneyRequestView({ reportID: transactionThreadReport?.reportID, }), ), - }), - ), - ); - } else if (!policy && shouldSelectPolicy) { - Navigation.navigate( - ROUTES.SET_DEFAULT_WORKSPACE.getRoute( + ), + ); + } else { + Navigation.navigate( createDynamicRoute( DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ action: CONST.IOU.ACTION.EDIT, @@ -1402,222 +1430,285 @@ function MoneyRequestView({ reportID: transactionThreadReport?.reportID, }), ), + ); + } + }} + brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('category')} + copyValue={categoryCopyValue} + copyable={!!categoryCopyValue} + /> + + ) + ); + case CONST.SEARCH.TABLE_COLUMNS.VENDOR: + return ( + shouldShowVendor && ( + + { + if (!transactionThreadReport?.reportID) { + return; + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport.reportID, + getReportRHPActiveRoute(), ), ); - } else { + }} + brickRoadIndicator={getErrorForField('vendor') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('vendor')} + /> + + ) + ); + case CONST.SEARCH.TABLE_COLUMNS.TAG: + return shouldShowTag && tagList; + case CONST.SEARCH.TABLE_COLUMNS.CARD: + return ( + !!shouldShowCard && ( + + + + ) + ); + case CONST.SEARCH.TABLE_COLUMNS.TAX_RATE: + return ( + shouldShowTax && ( + + { + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } + Navigation.navigate( createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), ), ); - } - }} - brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('category')} - copyValue={categoryCopyValue} - copyable={!!categoryCopyValue} - /> - - )} - {shouldShowVendor && ( - - { - if (!transactionThreadReport?.reportID) { - return; - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - getReportRHPActiveRoute(), - ), - ); - }} - brickRoadIndicator={getErrorForField('vendor') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('vendor')} - /> - - )} - {shouldShowTag && tagList} - {!!shouldShowCard && ( - - - - )} - {shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; - } + }} + brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('tax')} + copyValue={taxRateCopyValue} + copyable={!!taxRateCopyValue} + /> + + ) + ); + case CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT: + return ( + shouldShowTax && ( + + { + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('tax')} - copyValue={taxRateCopyValue} - copyable={!!taxRateCopyValue} - /> - - )} - {shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport?.reportID, + ), + ), + ); + }} + copyValue={taxAmountCopyValue} + copyable={!!taxAmountCopyValue} + /> + + ) + ); + case CONST.SEARCH.TABLE_COLUMNS.ATTENDEES: + return ( + shouldShowAttendees && ( + + 1 && formattedPerAttendeeAmount + ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` + : '' + }`} + descriptionTextStyle={styles.textLabelSupportingNormal} + titleComponent={ + Array.isArray(actualAttendees) ? ( + ({ + avatar: a?.avatarUrl, + displayName: a?.displayName ?? a?.email ?? '', + accountID: a?.accountID, + email: a?.email, + }))} + maxVisible={canEdit ? undefined : actualAttendees.length} + /> + ) : undefined } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - copyValue={taxAmountCopyValue} - copyable={!!taxAmountCopyValue} - /> - - )} - {shouldShowAttendees && ( - - 1 && formattedPerAttendeeAmount - ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` - : '' - }`} - descriptionTextStyle={styles.textLabelSupportingNormal} - titleComponent={ - Array.isArray(actualAttendees) ? ( - ({ - avatar: a?.avatarUrl, - displayName: a?.displayName ?? a?.email ?? '', - accountID: a?.accountID, - email: a?.email, - }))} - maxVisible={canEdit ? undefined : actualAttendees.length} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + onPress={() => { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), + ), + ); + }} + brickRoadIndicator={getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('attendees')} + interactive={canEdit} + shouldShowRightIcon={canEdit} + copyValue={attendeesCopyValue} + copyable={!!attendeesCopyValue} + /> + + ) + ); + case CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE: + return ( + shouldShowReimbursable && ( + + + + {Str.UCFirst(translate('iou.reimbursable'))} + + + + + ) + ); + case CONST.SEARCH.TABLE_COLUMNS.BILLABLE: + return ( + shouldShowBillable && ( + + + + {translate('common.billable')} + + {!!getErrorForField('billable') && ( + - ) : undefined - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - onPress={() => { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('attendees')} - interactive={canEdit} - shouldShowRightIcon={canEdit} - copyValue={attendeesCopyValue} - copyable={!!attendeesCopyValue} - /> - - )} - {shouldShowReimbursable && ( - - - - {Str.UCFirst(translate('iou.reimbursable'))} - - - - + )} + + + + ) + ); + default: + return ( + + ); + } + }; + + return ( + + {shouldShowAnimatedBackground && } + <> + {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( + )} - {shouldShowBillable && ( - - - - {translate('common.billable')} - - {!!getErrorForField('billable') && ( - - )} - - + - + + {translate('violations.customUnitOutOfPolicy')} + + )} + {fieldsToShow.map((column) => ( + {renderField(column)} + ))} + {isDistanceRequest && !fieldsToShow.includes(CONST.SEARCH.TABLE_COLUMNS.MERCHANT) && distanceRequestFields} {shouldShowReport && ( ; + policy: OnyxEntry; + policyCategories: OnyxEntry; + policyTagLists: OnyxEntry; + attendeeCount: number; +}; + +/** Read-only report columns reuse the table's value and formatting helpers. */ +function MoneyRequestViewAdditionalField({column, transaction, report, policy, policyCategories, policyTagLists, attendeeCount}: MoneyRequestViewAdditionalFieldProps) { + const {translate, dateFnsLocale} = useLocalize(); + const {convertToDisplayString} = useCurrencyListActions(); + const isFromExpenseReport = isExpenseReport(report); + let value: string | undefined; + + switch (column) { + case CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE: + value = getCategoryGLCode(policyCategories, getCategory(transaction)); + break; + case CONST.SEARCH.TABLE_COLUMNS.TAG_GL_CODE: + value = getTagGLCode(policyTagLists, getTag(transaction)); + break; + case CONST.SEARCH.TABLE_COLUMNS.MCC: + value = getMCCForDisplay(transaction.mcc); + break; + case CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE: + value = getExchangeRate(transaction, report?.currency ?? policy?.outputCurrency, true); + break; + case CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT: + value = convertToDisplayString(getOriginalAmountForDisplay(transaction, isFromExpenseReport), getOriginalCurrencyForDisplay(transaction)); + break; + case CONST.SEARCH.TABLE_COLUMNS.TOTAL: { + const hasConvertedAmount = transaction.convertedAmount != null; + const amount = hasConvertedAmount ? getConvertedAmount(transaction, isFromExpenseReport) : getAmount(transaction, isFromExpenseReport); + const currency = hasConvertedAmount ? (report?.currency ?? policy?.outputCurrency ?? getCurrency(transaction)) : getCurrency(transaction); + value = convertToDisplayString(amount, currency); + break; + } + case CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE: + value = attendeeCount ? convertToDisplayString(getAmount(transaction, isFromExpenseReport) / attendeeCount, getCurrency(transaction)) : ''; + break; + case CONST.SEARCH.TABLE_COLUMNS.POSTED: + value = transaction.posted ? DateUtils.formatWithUTCTimeZone(getFormattedPostedDate(transaction.posted), CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, dateFnsLocale) : ''; + break; + case CONST.SEARCH.TABLE_COLUMNS.TAX_CODE: + value = isPerDiemRequest(transaction) || isTimeRequest(transaction) || !isTaxCodeCustomized(transaction.taxCode, policy) ? '' : transaction.taxCode; + break; + case CONST.SEARCH.TABLE_COLUMNS.WITHDRAWAL_ID: + value = transaction.withdrawalID; + break; + case CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_USER_ID: + case CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_PAYROLL_ID: + case CONST.SEARCH.TABLE_COLUMNS.ORDER_DEAL_NUMBERS: + value = getReportCustomColumnValue(column, report); + break; + default: + return null; + } + + return ( + + ); +} + +export default MoneyRequestViewAdditionalField; diff --git a/src/languages/de.ts b/src/languages/de.ts index a7033d666628..8033d0639fa0 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -9713,6 +9713,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc staleResults: {title: 'Aktualisierung erforderlich', subtitle: 'Diese Seite ist veraltet, aktualisieren Sie sie, um die neuesten Inhalte zu sehen', buttonText: 'Aktualisieren'}, }, columns: 'Spalten', + customizeFields: 'Felder anpassen', editColumns: 'Spalten bearbeiten', resetColumns: 'Spalten zurücksetzen', groupColumns: 'Spalten gruppieren', diff --git a/src/languages/el.ts b/src/languages/el.ts index 31db4439f98d..cdef0ab78b49 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -9923,6 +9923,7 @@ ${reportName}`, staleResults: {title: 'Απαιτείται ανανέωση', subtitle: 'Αυτή η σελίδα είναι παρωχημένη, κάντε ανανέωση για να δείτε τα πιο πρόσφατα', buttonText: 'Ανανέωση'}, }, columns: 'Στήλες', + customizeFields: 'Προσαρμογή πεδίων', editColumns: 'Επεξεργασία στηλών', resetColumns: 'Επαναφορά στηλών', groupColumns: 'Ομαδοποίηση στηλών', diff --git a/src/languages/en.ts b/src/languages/en.ts index a904b54e3244..c559e5efbb04 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9902,6 +9902,7 @@ const translations = { }, }, columns: 'Columns', + customizeFields: 'Customize fields', editColumns: 'Edit columns', resetColumns: 'Reset columns', groupColumns: 'Group columns', diff --git a/src/languages/es.ts b/src/languages/es.ts index 411efd800947..f8f9ed1aa932 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9559,6 +9559,7 @@ ${reportName}`, staleResults: {title: 'Es necesario actualizar', subtitle: 'Esta página está desactualizada, actualiza para ver lo más reciente', buttonText: 'Actualizar'}, }, columns: 'Columnas', + customizeFields: 'Personalizar campos', editColumns: 'Editar columnas', resetColumns: 'Restablecer columnas', groupColumns: 'Columnas de grupo', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 5fc7786790e5..6bed7d70b24a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -9738,6 +9738,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e staleResults: {title: 'Actualisation requise', subtitle: 'Cette page n’est plus à jour, actualisez-la pour voir les dernières informations', buttonText: 'Actualiser'}, }, columns: 'Colonnes', + customizeFields: 'Personnaliser les champs', editColumns: 'Modifier les colonnes', resetColumns: 'Réinitialiser les colonnes', groupColumns: 'Regrouper les colonnes', diff --git a/src/languages/it.ts b/src/languages/it.ts index d3d5ceb9177f..4955a6d41d12 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -9667,6 +9667,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, staleResults: {title: 'Aggiornamento necessario', subtitle: 'Questa pagina non è aggiornata, ricarica per vedere le ultime novità', buttonText: 'Aggiorna'}, }, columns: 'Colonne', + customizeFields: 'Personalizza campi', editColumns: 'Modifica colonne', resetColumns: 'Reimposta colonne', groupColumns: 'Raggruppa colonne', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1ed714c2757a..f8e5ac50fa99 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -9529,6 +9529,7 @@ ${reportName}`, staleResults: {title: '再読み込みが必要です', subtitle: 'このページは古くなっています。最新の情報を見るには再読み込みしてください。', buttonText: '更新'}, }, columns: '列', + customizeFields: 'フィールドをカスタマイズ', editColumns: '列の編集', resetColumns: '列をリセット', groupColumns: '列をグループ化', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a917877ad5cd..8823f7355eb2 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -9634,6 +9634,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, staleResults: {title: 'Vernieuwen nodig', subtitle: 'Deze pagina is verouderd, vernieuw om de laatste versie te zien', buttonText: 'Verversen'}, }, columns: 'Kolommen', + customizeFields: 'Velden aanpassen', editColumns: 'Kolommen bewerken', resetColumns: 'Kolommen opnieuw instellen', groupColumns: 'Kolommen groeperen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 27231ec8369f..e7b43d858345 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9645,6 +9645,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, staleResults: {title: 'Wymagane odświeżenie', subtitle: 'Ta strona jest nieaktualna, odśwież ją, żeby zobaczyć najnowszą wersję', buttonText: 'Odśwież'}, }, columns: 'Kolumny', + customizeFields: 'Dostosuj pola', editColumns: 'Edytuj kolumny', resetColumns: 'Resetuj kolumny', groupColumns: 'Grupuj kolumny', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 85fd2cf02040..69ae76006eb4 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -9633,6 +9633,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, staleResults: {title: 'É preciso atualizar', subtitle: 'Esta página está desatualizada, atualize para ver a versão mais recente', buttonText: 'Atualizar'}, }, columns: 'Colunas', + customizeFields: 'Personalizar campos', editColumns: 'Editar colunas', resetColumns: 'Redefinir colunas', groupColumns: 'Agrupar colunas', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 28765fda48aa..86fc11546a69 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9267,6 +9267,7 @@ ${reportName}`, staleResults: {title: '需要刷新', subtitle: '此页面已过期,刷新以查看最新内容', buttonText: '刷新'}, }, columns: '列', + customizeFields: '自定义字段', editColumns: '编辑列', resetColumns: '重置列', groupColumns: '分组列', diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 5c2c9bd6e689..d6fba6027301 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1660,6 +1660,7 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_SETTINGS.DYNAMIC_SETTINGS_VISIBILITY]: DYNAMIC_ROUTES.REPORT_SETTINGS_VISIBILITY.path, [SCREENS.REPORT_SETTINGS.COLUMNS]: { path: ROUTES.REPORT_SETTINGS_COLUMNS.route, + parse: {isExpenseView: (value: string) => value === 'true'}, }, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ac61038a8875..f813c8e94d79 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2235,6 +2235,7 @@ type ReportSettingsNavigatorParamList = { }; [SCREENS.REPORT_SETTINGS.COLUMNS]: { reportID: string; + isExpenseView?: boolean; }; }; diff --git a/src/libs/PopoverMenuSections.ts b/src/libs/PopoverMenuSections.ts index 1baddf312db4..9cc127533b6b 100644 --- a/src/libs/PopoverMenuSections.ts +++ b/src/libs/PopoverMenuSections.ts @@ -24,7 +24,7 @@ const REPORT_MORE_MENU_SECTIONS = [ [REPORT_SECONDARY_ACTIONS.SPLIT, REPORT_SECONDARY_ACTIONS.MERGE, REPORT_SECONDARY_ACTIONS.DUPLICATE_EXPENSE, REPORT_SECONDARY_ACTIONS.MOVE_EXPENSE], [REPORT_SECONDARY_ACTIONS.DUPLICATE_REPORT, REPORT_SECONDARY_ACTIONS.CHANGE_WORKSPACE, REPORT_SECONDARY_ACTIONS.CHANGE_APPROVER], [REPORT_SECONDARY_ACTIONS.EXPORT, REPORT_SECONDARY_ACTIONS.DOWNLOAD_PDF, REPORT_SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS, REPORT_SECONDARY_ACTIONS.PRINT], - [REPORT_SECONDARY_ACTIONS.VIEW_DETAILS, REPORT_SECONDARY_ACTIONS.DELETE], + [REPORT_SECONDARY_ACTIONS.CUSTOMIZE_FIELDS, REPORT_SECONDARY_ACTIONS.VIEW_DETAILS, REPORT_SECONDARY_ACTIONS.DELETE], ]; const TRANSACTION_MORE_MENU_SECTIONS = [ @@ -38,7 +38,7 @@ const TRANSACTION_MORE_MENU_SECTIONS = [ TRANSACTION_SECONDARY_ACTIONS.MERGE, ], [TRANSACTION_SECONDARY_ACTIONS.DUPLICATE, TRANSACTION_SECONDARY_ACTIONS.MOVE_EXPENSE], - [TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS, TRANSACTION_SECONDARY_ACTIONS.DELETE], + [TRANSACTION_SECONDARY_ACTIONS.CUSTOMIZE_FIELDS, TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS, TRANSACTION_SECONDARY_ACTIONS.DELETE], ]; function buildSectionOrder(sectionDefinitions: ReadonlyArray) { diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index edd7340a415e..bcf518e6f0c8 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -1197,6 +1197,10 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.CHANGE_APPROVER); } + if (isExpenseReportUtils(report) && reportTransactions.length === 1) { + options.push(CONST.REPORT.SECONDARY_ACTIONS.CUSTOMIZE_FIELDS); + } + options.push(CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS); if (isDeleteAction(report, reportTransactions, currentUserAccountID, rules, reportActions ?? [], policy, true)) { @@ -1326,6 +1330,10 @@ function getSecondaryTransactionThreadActions({ } } + if (isExpenseReportUtils(parentReport)) { + options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.CUSTOMIZE_FIELDS); + } + options.push(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS); if (isDeleteAction(parentReport, [reportTransaction], currentUserAccountID, rules, reportAction ? [reportAction] : [], policy)) { diff --git a/src/libs/getMoneyRequestViewFields.ts b/src/libs/getMoneyRequestViewFields.ts new file mode 100644 index 000000000000..f6892429c853 --- /dev/null +++ b/src/libs/getMoneyRequestViewFields.ts @@ -0,0 +1,35 @@ +import type {SearchColumnType} from '@components/Search/types'; + +import CONST from '@src/CONST'; + +import {isReportDetailsCustomColumn} from './SearchUIUtils'; + +// Preserve the expense editor's established order until the user customizes the shared report columns. +const DEFAULT_FIELDS: SearchColumnType[] = [ + CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, + CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION, + CONST.SEARCH.TABLE_COLUMNS.MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.DATE, + CONST.SEARCH.TABLE_COLUMNS.CATEGORY, + CONST.SEARCH.TABLE_COLUMNS.VENDOR, + CONST.SEARCH.TABLE_COLUMNS.TAG, + CONST.SEARCH.TABLE_COLUMNS.CARD, + CONST.SEARCH.TABLE_COLUMNS.TAX_RATE, + CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT, + CONST.SEARCH.TABLE_COLUMNS.ATTENDEES, + CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, + CONST.SEARCH.TABLE_COLUMNS.BILLABLE, +]; + +/** Receipts keep their own panel; table controls do not become expense fields. */ +function getMoneyRequestViewFields(tableColumns: SearchColumnType[] = [], hasCustomColumns = false): SearchColumnType[] { + const fields = tableColumns.filter((column) => isReportDetailsCustomColumn(column) && column !== CONST.SEARCH.TABLE_COLUMNS.RECEIPT); + if (!hasCustomColumns) { + return [...new Set([...DEFAULT_FIELDS, ...fields])]; + } + + // Amount is required even if an older or malformed preference omits it. + return fields.includes(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT) ? [...new Set(fields)] : [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, ...new Set(fields)]; +} + +export default getMoneyRequestViewFields; diff --git a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx index 44cc662cf3b1..6251150a8364 100644 --- a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx +++ b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx @@ -6,6 +6,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import {setReportDetailsColumns} from '@libs/actions/ReportLayout'; +import getMoneyRequestViewFields from '@libs/getMoneyRequestViewFields'; import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -42,10 +43,12 @@ const REPORT_DETAILS_DEFAULT_COLUMNS: SearchCustomColumnIds[] = [ ]; const ALL_REPORT_DETAILS_CUSTOM_COLUMNS = Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS).filter(isReportDetailsCustomColumn); +const EXPENSE_DEFAULT_FIELDS = [CONST.SEARCH.TABLE_COLUMNS.RECEIPT, ...getMoneyRequestViewFields()].filter(isReportDetailsCustomColumn); function ReportDetailsColumnsPage() { const route = useRoute>(); const reportID = route.params.reportID; + const isExpenseView = route.params.isExpenseView ?? false; const [reportDetailsColumns] = useOnyx(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); @@ -81,7 +84,7 @@ function ReportDetailsColumnsPage() { } if (!reportTransactions?.length) { - return REPORT_DETAILS_DEFAULT_COLUMNS; + return isExpenseView ? EXPENSE_DEFAULT_FIELDS : REPORT_DETAILS_DEFAULT_COLUMNS; } const visibleColumns = getColumnsToShow({ @@ -96,9 +99,11 @@ function ReportDetailsColumnsPage() { isPolicyTaxEnabled: isPolicyTaxEnabled(policy), }); - // Filter to only columns available in the custom columns list (drops RECEIPT/TYPE/COMMENTS etc.) - return visibleColumns.filter(isReportDetailsCustomColumn); - }, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy]); + // The untouched expense editor has its own field order. Opening and saving the picker + // without changes must not hide its editors or overwrite the shared table preference. + const columns = isExpenseView ? [CONST.SEARCH.TABLE_COLUMNS.RECEIPT, ...getMoneyRequestViewFields(visibleColumns)] : visibleColumns; + return columns.filter(isReportDetailsCustomColumn); + }, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy, isExpenseView]); const allColumns = ALL_REPORT_DETAILS_CUSTOM_COLUMNS.filter(isColumnAvailable); const currentColumns = effectiveColumns.filter(isColumnAvailable); @@ -119,8 +124,9 @@ function ReportDetailsColumnsPage() { return ( { return transaction; }; + it('renders the shared report fields in order and reacts to preference changes', async () => { + // Given an expense whose report columns include accounting data absent from the default editor. + const threadReport = {...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID}; + await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, { + Travel: {name: 'Travel', enabled: true, 'GL Code': '6100'}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + category: 'Travel', + mcc: 5812, + currency: 'EUR', + currencyConversionRate: 1.25, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, {currency: 'USD'}); + await Onyx.set(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS, [ + CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE, + CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE, + CONST.SEARCH.TABLE_COLUMNS.MCC, + CONST.SEARCH.TABLE_COLUMNS.MERCHANT, + CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, + ]); + }); + + // When the same expense is opened, it uses the report's selection and keeps existing editors. + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then the extra values are read-only, selected fields keep their order, and unchecked fields are absent. + expect(screen.getByTestId('menu-item-title-common.categoryGLCode')).toHaveTextContent('6100'); + expect(screen.getByTestId('menu-item-title-common.exchangeRate')).toHaveTextContent('1.2500 EUR/USD'); + expect(screen.getByTestId('menu-item-title-common.mcc')).toHaveTextContent('5812'); + expect(screen.getByTestId('menu-item-common.categoryGLCode')).toHaveTextContent('readonly'); + expect(screen.getByTestId('menu-item-common.merchant')).toHaveTextContent('editable'); + expect(screen.queryByTestId('menu-item-common.category')).not.toBeOnTheScreen(); + const fieldIDs = screen.getAllByTestId(/^menu-item-(?!title)/).map((item) => item.props.testID as string); + expect(fieldIDs.slice(0, 4)).toEqual(['menu-item-common.categoryGLCode', 'menu-item-common.exchangeRate', 'menu-item-common.mcc', 'menu-item-common.merchant']); + + // When another view updates the shared preference, the mounted expense must update too. + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS, [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.MCC]); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the old selection is removed without reopening the expense. + expect(screen.queryByTestId('menu-item-common.categoryGLCode')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('menu-item-common.merchant')).not.toBeOnTheScreen(); + expect(screen.getByTestId('menu-item-common.mcc')).toBeOnTheScreen(); + }); + it('should show amount and merchant as editable when report is open', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index e15da76f3606..5586e05137ce 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -139,6 +139,35 @@ describe('getSecondaryAction', () => { Onyx.clear(); }); + it.each([ + [CONST.REPORT.TYPE.EXPENSE, 1, true], + [CONST.REPORT.TYPE.EXPENSE, 2, false], + [CONST.REPORT.TYPE.EXPENSE, 0, false], + [CONST.REPORT.TYPE.IOU, 1, false], + ] as const)('offers Customize fields for report type %s with %s transactions: %s', (type, count, expected) => { + // Given a report that may render either a single expense or a transaction table. + const report = createMock({reportID: REPORT_ID, type}); + const reportTransactions = Array.from({length: count}, (_, index) => createMock({transactionID: String(index), reportID: REPORT_ID})); + + // When the More menu is built for that report. + const actions = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions, + originalTransaction: undefined, + violations: {}, + bankAccountList: {}, + policy: createMock({}), + rules: undefined, + }); + + // Then only a single-expense report offers field customization from this menu. + expect(actions.includes(CONST.REPORT.SECONDARY_ACTIONS.CUSTOMIZE_FIELDS)).toBe(expected); + }); + it('should always return default options', () => { const report = createMock({}); const policy = createMock({}); @@ -5183,6 +5212,27 @@ describe('getSecondaryTransactionThreadActions', () => { await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {[EMPLOYEE_ACCOUNT_ID]: PERSONAL_DETAILS}); }); + it.each([CONST.REPORT.TYPE.EXPENSE, CONST.REPORT.TYPE.IOU, CONST.REPORT.TYPE.CHAT])('scopes Customize fields to an expense in a report (%s)', (type) => { + // Given an expense opened from a workspace report, a personal request, or a self chat. + const parentReport = createMock({reportID: REPORT_ID, type}); + + // When the individual expense More menu is built. + const actions = getSecondaryTransactionThreadActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + parentReport, + reportTransaction: createMock({}), + reportAction: undefined, + originalTransaction: undefined, + policy: createMock({}), + isChatReportArchived: false, + rules: undefined, + }); + + // Then customization is available only for the report-backed expense. + expect(actions.includes(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.CUSTOMIZE_FIELDS)).toBe(type === CONST.REPORT.TYPE.EXPENSE); + }); + it('should always return VIEW_DETAILS', () => { const report = createMock({}); const policy = createMock({}); diff --git a/tests/unit/getMoneyRequestViewFieldsTest.ts b/tests/unit/getMoneyRequestViewFieldsTest.ts new file mode 100644 index 000000000000..f4a9a79b7e82 --- /dev/null +++ b/tests/unit/getMoneyRequestViewFieldsTest.ts @@ -0,0 +1,49 @@ +import getMoneyRequestViewFields from '@libs/getMoneyRequestViewFields'; + +import CONST from '@src/CONST'; + +describe('getMoneyRequestViewFields', () => { + it('keeps the default editor and adds automatically visible report data without duplicates', () => { + // Given a report with conversion data but no saved column preference. + const columns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; + + // When its table columns are shown as expense fields. + const fields = getMoneyRequestViewFields(columns); + + // Then existing editors remain accessible and the missing conversion information appears once. + expect(fields).toContain(CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION); + expect(fields).toContain(CONST.SEARCH.TABLE_COLUMNS.CATEGORY); + expect(fields.at(0)).toBe(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); + expect(fields.at(-1)).toBe(CONST.SEARCH.TABLE_COLUMNS.EXCHANGE_RATE); + expect(fields.filter((column) => column === CONST.SEARCH.TABLE_COLUMNS.DATE)).toHaveLength(1); + }); + + it('honors custom order without treating the receipt panel and table controls as fields', () => { + // Given a customized table with receipt and comments alongside expense data. + const columns = [ + CONST.SEARCH.TABLE_COLUMNS.RECEIPT, + CONST.SEARCH.TABLE_COLUMNS.TYPE, + CONST.SEARCH.TABLE_COLUMNS.MCC, + CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, + CONST.SEARCH.TABLE_COLUMNS.COMMENTS, + CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE, + ]; + + // When the shared selection is rendered in the expense editor. + const fields = getMoneyRequestViewFields(columns, true); + + // Then only selectable data fields render, in the saved order. + expect(fields).toEqual([CONST.SEARCH.TABLE_COLUMNS.MCC, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]); + }); + + it('keeps amount accessible when an old preference omits the required column', () => { + // Given a preference saved before amount was mandatory. + const columns = [CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]; + + // When it is loaded by the expense editor. + const fields = getMoneyRequestViewFields(columns, true); + + // Then the expense still has its amount editor and the requested accounting field. + expect(fields).toEqual([CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]); + }); +}); diff --git a/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx b/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx index 5282ebb121da..c1dc851d1eec 100644 --- a/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx +++ b/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx @@ -58,3 +58,18 @@ it('filters unsupported saved columns, defaults invalid-only storage, and preser expect(jest.mocked(setReportDetailsColumns)).toHaveBeenCalledWith([CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT], mockSavedColumns); expect(jest.mocked(Navigation.goBack)).toHaveBeenCalledTimes(2); }); + +it('uses the expense heading while saving the same report column preference', () => { + // Given the picker opened through an expense's Customize fields action. + jest.mocked(useRoute).mockReturnValue({key: 'columns', name: 'columns', params: {reportID: 'report-1', isExpenseView: true}}); + const savedColumns = [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.MERCHANT]; + + // When the user adds an accounting field and saves. + const props = renderColumns(savedColumns); + const selectedColumns = [...savedColumns, CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]; + props.onSave(selectedColumns); + + // Then the heading matches the entry point and the existing NVP action receives the new selection and rollback value. + expect(props.titleKey).toBe('search.customizeFields'); + expect(setReportDetailsColumns).toHaveBeenLastCalledWith(selectedColumns, savedColumns); +}); From 7eca905e101da10506f706a94fa7ed9e5ce1aea3 Mon Sep 17 00:00:00 2001 From: brunovjk Date: Mon, 28 Sep 2026 18:09:40 -0300 Subject: [PATCH 02/23] add tests --- src/ROUTES.ts | 6 +- src/SCREENS.ts | 1 + .../MoneyReportHeaderSecondaryActions.tsx | 2 +- .../MoneyRequestHeaderSecondaryActions.tsx | 2 +- .../ReportActionItem/MoneyRequestView.tsx | 2 +- .../MoneyRequestViewAdditionalField.tsx | 2 +- .../ModalStackNavigators/index.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 4 +- src/libs/Navigation/types.ts | 4 +- .../Report/ReportDetailsColumnsPage.tsx | 28 +++++-- .../navigation/linkingConfigColdStartTests.ts | 19 +++++ tests/ui/MoneyRequestViewTest.tsx | 7 +- .../MoneyRequestViewAdditionalFieldTest.tsx | 82 +++++++++++++++++++ .../settings/ReportDetailsColumnsPageTest.tsx | 50 ++++++++++- 14 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 tests/unit/MoneyRequestViewAdditionalFieldTest.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5c7107a19d3d..6900b1823006 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2730,9 +2730,13 @@ const ROUTES = { route: 'r/:reportID/change-approver/reassign', getRoute: (reportID: string) => `r/${reportID}/change-approver/reassign` as const, }, + REPORT_SETTINGS_FIELDS: { + route: 'r/:reportID/settings/fields', + getRoute: (reportID: string) => `r/${reportID}/settings/fields` as const, + }, REPORT_SETTINGS_COLUMNS: { route: 'r/:reportID/settings/columns', - getRoute: (reportID: string, isExpenseView = false) => `r/${reportID}/settings/columns${isExpenseView ? '?isExpenseView=true' : ''}` as const, + getRoute: (reportID: string) => `r/${reportID}/settings/columns` as const, }, CHRONOS_SCHEDULE_OOO: { route: 'r/:reportID/chronos/schedule-ooo', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 5a4cca177f23..b3eea4a5fd3b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -464,6 +464,7 @@ const SCREENS = { VISIBILITY: 'Report_Settings_Visibility', DYNAMIC_SETTINGS_VISIBILITY: 'Dynamic_Report_Settings_Visibility', COLUMNS: 'Report_Settings_Columns', + FIELDS: 'Report_Settings_Fields', }, NEW_TASK: { diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 1dee0cbc9c3d..4a690feadac8 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -408,7 +408,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo if (!reportID) { return; } - Navigation.navigate(ROUTES.REPORT_SETTINGS_COLUMNS.getRoute(reportID, true)); + Navigation.navigate(ROUTES.REPORT_SETTINGS_FIELDS.getRoute(reportID)); }, }, [CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS]: { diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index b1117da87d00..72532a352e8f 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -545,7 +545,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money if (!parentReport?.reportID) { return; } - Navigation.navigate(ROUTES.REPORT_SETTINGS_COLUMNS.getRoute(parentReport.reportID, true)); + Navigation.navigate(ROUTES.REPORT_SETTINGS_FIELDS.getRoute(parentReport.reportID)); }, }, [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.VIEW_DETAILS]: { diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 7fc74ba09282..76fec0321735 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1232,7 +1232,7 @@ function MoneyRequestView({ : []; const fieldsToShow = getMoneyRequestViewFields(tableColumns, savedColumns.length > 0); - const renderField = (column: SearchColumnType) => { + const renderField = (column: SearchColumnType): React.ReactNode => { switch (column) { case CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT: return ( diff --git a/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx b/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx index 18fb5b2df337..ccf28ee1e29d 100644 --- a/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx @@ -1,3 +1,4 @@ +/** Read-only report columns reuse the table's value and formatting helpers. */ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import type {SearchColumnType} from '@components/Search/types'; @@ -41,7 +42,6 @@ type MoneyRequestViewAdditionalFieldProps = { attendeeCount: number; }; -/** Read-only report columns reuse the table's value and formatting helpers. */ function MoneyRequestViewAdditionalField({column, transaction, report, policy, policyCategories, policyTagLists, attendeeCount}: MoneyRequestViewAdditionalFieldProps) { const {translate, dateFnsLocale} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index f078e0571a0e..4ef97dc64d3b 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -299,6 +299,7 @@ const ReportSettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Report/DynamicNamePage').default, [SCREENS.REPORT_SETTINGS.DYNAMIC_SETTINGS_WRITE_CAPABILITY]: () => require('../../../../pages/settings/Report/DynamicWriteCapabilityPage').default, [SCREENS.REPORT_SETTINGS.DYNAMIC_SETTINGS_VISIBILITY]: () => require('../../../../pages/settings/Report/DynamicVisibilityPage').default, + [SCREENS.REPORT_SETTINGS.FIELDS]: () => require('../../../../pages/settings/Report/ReportDetailsColumnsPage').default, [SCREENS.REPORT_SETTINGS.COLUMNS]: () => require('../../../../pages/settings/Report/ReportDetailsColumnsPage').default, }); diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index d6fba6027301..a234d0c97fd3 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1658,9 +1658,11 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_SETTINGS.DYNAMIC_SETTINGS_NAME]: DYNAMIC_ROUTES.REPORT_SETTINGS_NAME.path, [SCREENS.REPORT_SETTINGS.DYNAMIC_SETTINGS_WRITE_CAPABILITY]: DYNAMIC_ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.path, [SCREENS.REPORT_SETTINGS.DYNAMIC_SETTINGS_VISIBILITY]: DYNAMIC_ROUTES.REPORT_SETTINGS_VISIBILITY.path, + [SCREENS.REPORT_SETTINGS.FIELDS]: { + path: ROUTES.REPORT_SETTINGS_FIELDS.route, + }, [SCREENS.REPORT_SETTINGS.COLUMNS]: { path: ROUTES.REPORT_SETTINGS_COLUMNS.route, - parse: {isExpenseView: (value: string) => value === 'true'}, }, }, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index f813c8e94d79..597bbba81cad 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2235,7 +2235,9 @@ type ReportSettingsNavigatorParamList = { }; [SCREENS.REPORT_SETTINGS.COLUMNS]: { reportID: string; - isExpenseView?: boolean; + }; + [SCREENS.REPORT_SETTINGS.FIELDS]: { + reportID: string; }; }; diff --git a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx index 6251150a8364..5334b05fc955 100644 --- a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx +++ b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx @@ -19,7 +19,7 @@ import type {ReportSettingsNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; +import SCREENS from '@src/SCREENS'; import type {Transaction} from '@src/types/onyx'; import arraysEqual from '@src/utils/arraysEqual'; @@ -43,12 +43,13 @@ const REPORT_DETAILS_DEFAULT_COLUMNS: SearchCustomColumnIds[] = [ ]; const ALL_REPORT_DETAILS_CUSTOM_COLUMNS = Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS).filter(isReportDetailsCustomColumn); -const EXPENSE_DEFAULT_FIELDS = [CONST.SEARCH.TABLE_COLUMNS.RECEIPT, ...getMoneyRequestViewFields()].filter(isReportDetailsCustomColumn); +const EXPENSE_DEFAULT_FIELDS = getMoneyRequestViewFields().filter(isReportDetailsCustomColumn); +const ALL_EXPENSE_FIELDS = [...new Set([...EXPENSE_DEFAULT_FIELDS, ...ALL_REPORT_DETAILS_CUSTOM_COLUMNS])].filter((column) => column !== CONST.SEARCH.TABLE_COLUMNS.RECEIPT); function ReportDetailsColumnsPage() { - const route = useRoute>(); + const route = useRoute>(); const reportID = route.params.reportID; - const isExpenseView = route.params.isExpenseView ?? false; + const isExpenseView = route.name === SCREENS.REPORT_SETTINGS.FIELDS; const [reportDetailsColumns] = useOnyx(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`); @@ -67,7 +68,8 @@ function ReportDetailsColumnsPage() { // 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; + const isColumnAvailable = (column: SearchCustomColumnIds) => + (!isExpenseView || column !== CONST.SEARCH.TABLE_COLUMNS.RECEIPT) && (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 @@ -101,11 +103,11 @@ function ReportDetailsColumnsPage() { // The untouched expense editor has its own field order. Opening and saving the picker // without changes must not hide its editors or overwrite the shared table preference. - const columns = isExpenseView ? [CONST.SEARCH.TABLE_COLUMNS.RECEIPT, ...getMoneyRequestViewFields(visibleColumns)] : visibleColumns; + const columns = isExpenseView ? getMoneyRequestViewFields(visibleColumns) : visibleColumns; return columns.filter(isReportDetailsCustomColumn); }, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy, isExpenseView]); - const allColumns = ALL_REPORT_DETAILS_CUSTOM_COLUMNS.filter(isColumnAvailable); + const allColumns = (isExpenseView ? ALL_EXPENSE_FIELDS : ALL_REPORT_DETAILS_CUSTOM_COLUMNS).filter(isColumnAvailable); const currentColumns = effectiveColumns.filter(isColumnAvailable); const requiredColumns = new Set([CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]); @@ -113,7 +115,17 @@ function ReportDetailsColumnsPage() { // 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, currentColumns)) { - setReportDetailsColumns(selectedColumnIds, reportDetailsColumns); + const columnsToSave = [...selectedColumnIds]; + if (isExpenseView) { + // Receipts have a separate panel in the expense view. Editing fields must not + // change whether or where the receipt column appears in the report table. + const savedColumns = (reportDetailsColumns ?? []).filter(isReportDetailsCustomColumn); + const receiptIndex = savedColumns.length ? savedColumns.indexOf(CONST.SEARCH.TABLE_COLUMNS.RECEIPT) : 0; + if (receiptIndex !== -1) { + columnsToSave.splice(Math.min(receiptIndex, columnsToSave.length), 0, CONST.SEARCH.TABLE_COLUMNS.RECEIPT); + } + } + setReportDetailsColumns(columnsToSave, reportDetailsColumns); } Navigation.goBack(); }; diff --git a/tests/navigation/linkingConfigColdStartTests.ts b/tests/navigation/linkingConfigColdStartTests.ts index 80ce8ebecd89..92d87ba8e217 100644 --- a/tests/navigation/linkingConfigColdStartTests.ts +++ b/tests/navigation/linkingConfigColdStartTests.ts @@ -2,11 +2,30 @@ import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFro import {linkingConfig} from '@libs/Navigation/linkingConfig'; import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; describe('linking configuration on a cold start', () => { + it.each([ + [ROUTES.REPORT_SETTINGS_FIELDS.getRoute('123'), SCREENS.REPORT_SETTINGS.FIELDS], + [ROUTES.REPORT_SETTINGS_COLUMNS.getRoute('123'), SCREENS.REPORT_SETTINGS.COLUMNS], + ])('restores the correct report configuration screen for %s', (path, screen) => { + // Given separate links to expense fields and report table columns. + const parsePath = linkingConfig.getStateFromPath ?? getStateFromPath; + + // When a saved link is opened or the browser is refreshed. + const state = parsePath(path, linkingConfig.config); + + // Then the shared picker can determine its mode without a query parameter. + expect(state).toBeDefined(); + if (!state) { + throw new Error('The report configuration route must resolve'); + } + expect(findFocusedRoute(state)).toMatchObject({name: screen, params: {reportID: '123'}}); + }); + it.each(['/workspaces/123/overview/name', '/settings/workspaces/123/overview/name'])('restores the workspace overview underneath %s', (path) => { // React Navigation falls back to its own parser if the configured callback is undefined. const parsePath = linkingConfig.getStateFromPath ?? getStateFromPath; diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 3d5926c09ec2..ddcdc97f8b00 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -203,13 +203,14 @@ describe('MoneyRequestView edit fields', () => { await setupTestData(); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, { + // eslint-disable-next-line @typescript-eslint/naming-convention -- GL Code is the backend's category property. Travel: {name: 'Travel', enabled: true, 'GL Code': '6100'}, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { category: 'Travel', - mcc: 5812, + mcc: '5812', currency: 'EUR', - currencyConversionRate: 1.25, + currencyConversionRate: '1.25', }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, {currency: 'USD'}); await Onyx.set(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS, [ @@ -232,7 +233,7 @@ describe('MoneyRequestView edit fields', () => { expect(screen.getByTestId('menu-item-common.categoryGLCode')).toHaveTextContent('readonly'); expect(screen.getByTestId('menu-item-common.merchant')).toHaveTextContent('editable'); expect(screen.queryByTestId('menu-item-common.category')).not.toBeOnTheScreen(); - const fieldIDs = screen.getAllByTestId(/^menu-item-(?!title)/).map((item) => item.props.testID as string); + const fieldIDs = screen.getAllByTestId(/^menu-item-(?!title)/).map((item) => String(item.props.testID)); expect(fieldIDs.slice(0, 4)).toEqual(['menu-item-common.categoryGLCode', 'menu-item-common.exchangeRate', 'menu-item-common.mcc', 'menu-item-common.merchant']); // When another view updates the shared preference, the mounted expense must update too. diff --git a/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx b/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx new file mode 100644 index 000000000000..1c58e7107e6f --- /dev/null +++ b/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx @@ -0,0 +1,82 @@ +import {render} from '@testing-library/react-native'; + +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MoneyRequestViewAdditionalField from '@components/ReportActionItem/MoneyRequestViewAdditionalField'; +import type {SearchColumnType} from '@components/Search/types'; + +import CONST from '@src/CONST'; +import type {Transaction} from '@src/types/onyx'; + +import React from 'react'; + +jest.mock('@components/MenuItemWithTopDescription', () => jest.fn(() => null)); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListActions: () => ({convertToDisplayString: (amount: number, currency: string) => `${currency}:${amount}`}), +})); + +function renderField(column: SearchColumnType, overrides: Partial = {}) { + render( + , + ); + return jest.mocked(MenuItemWithTopDescription).mock.calls.at(-1)?.at(0); +} + +describe('additional expense field values', () => { + it.each([ + [undefined, 'EUR:1000'], + [-1250, 'USD:1250'], + [0, 'USD:0'], + [1250, 'USD:-1250'], + ])('shows the correct total and currency for converted amount %s', (convertedAmount, expected) => { + // Given an offline expense, a converted expense, a zero total, or a refund. + const transaction = {convertedAmount}; + + // When its Total column is displayed as a field. + const field = renderField(CONST.SEARCH.TABLE_COLUMNS.TOTAL, transaction); + + // Then missing conversion falls back to the transaction currency while zero and refund signs are preserved. + expect(field).toEqual(expect.objectContaining({title: expected, copyValue: expected, interactive: false, shouldShowRightIcon: false})); + }); + + it('displays the original purchase currency separately from the report currency', () => { + // Given a card purchase in GBP that was converted to EUR and belongs to a USD report. + const transaction = {originalAmount: -800, originalCurrency: 'GBP', convertedAmount: -1250}; + + // When the original purchase amount is selected. + const field = renderField(CONST.SEARCH.TABLE_COLUMNS.ORIGINAL_AMOUNT, transaction); + + // Then the field retains the actual purchase amount and currency. + expect(field?.title).toBe('GBP:800'); + }); + + it('uses the modified expense amount when dividing the total among attendees', () => { + // Given an expense whose amount was edited optimistically before the server responds. + const transaction = {modifiedAmount: -1800}; + + // When total per attendee is displayed for two attendees. + const field = renderField(CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE, transaction); + + // Then the field agrees with the updated expense amount. + expect(field?.title).toBe('EUR:900'); + }); + + it('reads payroll information from the parent report', () => { + // Given a selected report-level field while viewing an individual expense. + const column = CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_PAYROLL_ID; + + // When the expense field is rendered. + const field = renderField(column); + + // Then the value comes from the parent report and remains read-only. + expect(field).toEqual(expect.objectContaining({title: 'payroll-123', interactive: false})); + }); +}); diff --git a/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx b/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx index c1dc851d1eec..df8ba22e8513 100644 --- a/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx +++ b/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx @@ -11,6 +11,7 @@ import ReportDetailsColumnsPage from '@pages/settings/Report/ReportDetailsColumn import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; import type * as ReactNavigation from '@react-navigation/native'; @@ -61,7 +62,7 @@ it('filters unsupported saved columns, defaults invalid-only storage, and preser it('uses the expense heading while saving the same report column preference', () => { // Given the picker opened through an expense's Customize fields action. - jest.mocked(useRoute).mockReturnValue({key: 'columns', name: 'columns', params: {reportID: 'report-1', isExpenseView: true}}); + jest.mocked(useRoute).mockReturnValue({key: 'columns', name: SCREENS.REPORT_SETTINGS.FIELDS, params: {reportID: 'report-1'}}); const savedColumns = [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.MERCHANT]; // When the user adds an accounting field and saves. @@ -73,3 +74,50 @@ it('uses the expense heading while saving the same report column preference', () expect(props.titleKey).toBe('search.customizeFields'); expect(setReportDetailsColumns).toHaveBeenLastCalledWith(selectedColumns, savedColumns); }); + +it.each([ + [ + [CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.RECEIPT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT], + [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.RECEIPT, CONST.SEARCH.TABLE_COLUMNS.MCC], + ], + [ + [CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT], + [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.MCC], + ], +])('preserves the table receipt preference when saving fields (%j)', (savedColumns, expectedColumns) => { + // Given a table with its receipt column either enabled in a custom position or disabled. + jest.mocked(useRoute).mockReturnValue({key: 'fields', name: SCREENS.REPORT_SETTINGS.FIELDS, params: {reportID: 'report-1'}}); + const props = renderColumns(savedColumns); + + // When only the expense fields are customized. + props.onSave([CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.MCC]); + + // Then the receipt stays outside the field picker and its table preference survives. + expect(props.allColumns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.RECEIPT); + expect(props.currentColumns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.RECEIPT); + expect(setReportDetailsColumns).toHaveBeenLastCalledWith(expectedColumns, savedColumns); +}); + +it('preserves untouched expense defaults and keeps the default receipt column on the first save', () => { + // Given an account that has never customized report columns. + jest.mocked(useRoute).mockReturnValue({key: 'fields', name: SCREENS.REPORT_SETTINGS.FIELDS, params: {reportID: 'report-1'}}); + const props = renderColumns(undefined); + const previousCallCount = jest.mocked(setReportDetailsColumns).mock.calls.length; + + // When the picker is saved without any change. + props.onSave(props.currentColumns); + + // Then it leaves the default rendering intact, with amount first even when resetting fields. + expect(setReportDetailsColumns).toHaveBeenCalledTimes(previousCallCount); + expect(props.currentColumns.at(0)).toBe(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT); + expect(props.allColumns.filter((column) => props.defaultSelectedColumns.includes(column))).toEqual(props.currentColumns); + + // When the first actual customization is saved. + props.onSave([CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE]); + + // Then the table retains its default receipt column. + expect(setReportDetailsColumns).toHaveBeenLastCalledWith( + [CONST.SEARCH.TABLE_COLUMNS.RECEIPT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE], + undefined, + ); +}); From 3ba39199287733bdcd5edc3b49c1c3864ba0182c Mon Sep 17 00:00:00 2001 From: brunovjk Date: Mon, 5 Oct 2026 09:53:55 -0300 Subject: [PATCH 03/23] Show billable and reimbursable values as read-only when controls are hidden. --- .../ReportActionItem/MoneyRequestView.tsx | 30 +++++++---- .../MoneyRequestViewAdditionalField.tsx | 7 +++ tests/ui/MoneyRequestViewTest.tsx | 54 +++++++++++++++++++ 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3ba15418632b..5366636058d9 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1704,6 +1704,9 @@ function MoneyRequestView({ ) ); case CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE: + if (!shouldShowReimbursable && savedColumns.includes(column)) { + break; + } return ( shouldShowReimbursable && ( - ); + break; } + + return ( + + ); }; return ( diff --git a/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx b/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx index ccf28ee1e29d..734b0db07337 100644 --- a/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx @@ -19,6 +19,7 @@ import { getMCCForDisplay, getOriginalAmountForDisplay, getOriginalCurrencyForDisplay, + getReimbursable, getTag, isPerDiemRequest, isTimeRequest, @@ -49,6 +50,12 @@ function MoneyRequestViewAdditionalField({column, transaction, report, policy, p let value: string | undefined; switch (column) { + case CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE: + value = translate(getReimbursable(transaction) ? 'common.yes' : 'common.no'); + break; + case CONST.SEARCH.TABLE_COLUMNS.BILLABLE: + value = translate(transaction.billable ? 'common.yes' : 'common.no'); + break; case CONST.SEARCH.TABLE_COLUMNS.CATEGORY_GL_CODE: value = getCategoryGLCode(policyCategories, getCategory(transaction)); break; diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 6bb3e8de056d..f629a8ce406e 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -202,6 +202,60 @@ describe('MoneyRequestView edit fields', () => { return transaction; }; + it.each([ + [CONST.SEARCH.TABLE_COLUMNS.BILLABLE, 'common.billable', false, {disabledFields: {defaultBillable: true}}], + [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, 'common.reimbursable', false, {disabledFields: {reimbursable: true}, defaultReimbursable: false}], + [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, 'common.reimbursable', true, {disabledFields: {reimbursable: true}, defaultReimbursable: true}], + ] as const)('shows selected %s as read-only when its control is hidden (%s, value %s)', async (column, label, value, policy) => { + // Given an explicitly selected column whose control is hidden by the workspace settings. + const threadReport = {...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID}; + await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {[column]: value}); + await Onyx.set(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS, [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, column]); + }); + + // When the expense is opened with the shared report preference. + renderMoneyRequestView(threadReport, policy); + await waitForBatchedUpdatesWithAct(); + + // Then the value remains visible without exposing a control prohibited by the workspace. + expect(screen.getByTestId(`menu-item-${label}`)).toHaveTextContent('readonly'); + expect(screen.getByTestId(`menu-item-title-${label}`)).toHaveTextContent(value ? 'common.yes' : 'common.no'); + expect(screen.queryAllByRole('switch')).toHaveLength(0); + + // When the field is deselected, or the account returns to its untouched preferences. + for (const columns of [[CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT], null]) { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS, columns); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the fallback row does not change the unselected or default expense layout. + expect(screen.queryByTestId(`menu-item-${label}`)).not.toBeOnTheScreen(); + } + }); + + it.each([ + [CONST.SEARCH.TABLE_COLUMNS.BILLABLE, 'common.billable', 'common.billable'], + [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, 'common.reimbursable', 'Iou.reimbursable'], + ] as const)('keeps the existing %s control when it is available', async (column, label, controlLabel) => { + // Given a selected boolean field whose control is available on this workspace. + const threadReport = {...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID}; + await setupTestData(); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS, [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, column]); + }); + + // When the expense is opened. + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then customization preserves the existing switch instead of replacing it with a read-only row. + expect(screen.getByRole('switch', {name: controlLabel})).toBeOnTheScreen(); + expect(screen.queryByTestId(`menu-item-${label}`)).not.toBeOnTheScreen(); + }); + it('renders the shared report fields in order and reacts to preference changes', async () => { // Given an expense whose report columns include accounting data absent from the default editor. const threadReport = {...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID}; From abb4be3539ac5583fc4e5b225735f83966cbda76 Mon Sep 17 00:00:00 2001 From: brunovjk Date: Mon, 5 Oct 2026 10:06:49 -0300 Subject: [PATCH 04/23] fix: improve JSDoc comment formatting per CONSISTENCY-15 --- src/libs/getMoneyRequestViewFields.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/getMoneyRequestViewFields.ts b/src/libs/getMoneyRequestViewFields.ts index f6892429c853..5bf2ab5e91b0 100644 --- a/src/libs/getMoneyRequestViewFields.ts +++ b/src/libs/getMoneyRequestViewFields.ts @@ -21,7 +21,10 @@ const DEFAULT_FIELDS: SearchColumnType[] = [ CONST.SEARCH.TABLE_COLUMNS.BILLABLE, ]; -/** Receipts keep their own panel; table controls do not become expense fields. */ +/** + * Receipts keep their own panel. + * Table controls do not become expense fields. + */ function getMoneyRequestViewFields(tableColumns: SearchColumnType[] = [], hasCustomColumns = false): SearchColumnType[] { const fields = tableColumns.filter((column) => isReportDetailsCustomColumn(column) && column !== CONST.SEARCH.TABLE_COLUMNS.RECEIPT); if (!hasCustomColumns) { From cf1bd13b169fb611b84108fa9a1fae40a801d7c4 Mon Sep 17 00:00:00 2001 From: brunovjk Date: Mon, 5 Oct 2026 19:43:11 -0300 Subject: [PATCH 05/23] Refactor money request fields into focused components and simplify field rendering. --- .../ReportActionItem/MoneyRequestView.tsx | 694 +++++++----------- .../MoneyRequestViewFields/AttendeesField.tsx | 65 ++ .../MoneyRequestViewFields/EditableField.tsx | 47 ++ .../MoneyRequestViewFields/TextField.tsx | 19 + .../MoneyRequestViewFields/ToggleField.tsx | 54 ++ 5 files changed, 452 insertions(+), 427 deletions(-) create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/TextField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index c335f5603909..7a52e7934455 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -12,10 +12,7 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import {useSearchResultsContext} from '@components/Search/SearchContext'; import type {SearchColumnType} from '@components/Search/types'; -import Switch from '@components/Switch'; import Text from '@components/Text'; -import UserPills from '@components/UserPills'; -import ViolationMessages from '@components/ViolationMessages'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useActiveRoute from '@hooks/useActiveRoute'; @@ -173,6 +170,10 @@ import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; import MoneyRequestReceiptView from './MoneyRequestReceiptView'; import MoneyRequestViewAdditionalField from './MoneyRequestViewAdditionalField'; +import MoneyRequestAttendeesField from './MoneyRequestViewFields/AttendeesField'; +import MoneyRequestEditableField from './MoneyRequestViewFields/EditableField'; +import MoneyRequestTextField from './MoneyRequestViewFields/TextField'; +import MoneyRequestToggleField from './MoneyRequestViewFields/ToggleField'; type MoneyRequestViewProps = { transactionThreadReport?: OnyxEntry; @@ -1287,280 +1288,237 @@ function MoneyRequestView({ : []; const fieldsToShow = getMoneyRequestViewFields(tableColumns, savedColumns.length > 0); + const handleAmountPress = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (shouldShowSplitIndicator && isSplitAvailable) { + initSplitExpense( + transaction, + transactionThreadReport, + splitEffectivePolicy, + selfDMReportID, + restrictedActionPolicyID, + personalPolicy?.outputCurrency, + getCurrencyDecimals, + getCurrencySymbol, + ); + return; + } + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID, '', '', getReportRHPActiveRoute()), + ); + }; + + const handleDescriptionPress = () => { + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const handleMerchantPress = () => { + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const handleDatePress = callFunctionIfActionIsAllowed(() => { + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }); + + const handleCategoryPress = callFunctionIfActionIsAllowed(() => { + if (shouldShowCategoryDisabledAlert) { + showCategoryDisabledAlert(); + return; + } + + if (shouldNavigateToUpgradePath && transactionThreadReport) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, + upgradeBackTo: createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + }), + ), + ); + } else if (!policy && shouldSelectPolicy) { + Navigation.navigate( + ROUTES.SET_DEFAULT_WORKSPACE.getRoute( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + ), + ); + } else { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ), + ); + } + }); + + const handleVendorPress = callFunctionIfActionIsAllowed(() => { + if (!transactionThreadReport?.reportID) { + return; + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID, getReportRHPActiveRoute()), + ); + }); + + const handleTaxRatePress = callFunctionIfActionIsAllowed(() => { + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }); + + const handleTaxAmountPress = callFunctionIfActionIsAllowed(() => { + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }); + + const handleAttendeesPress = () => { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID))); + }; + const renderField = (column: SearchColumnType): React.ReactNode => { switch (column) { case CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT: return ( - - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (shouldShowSplitIndicator && isSplitAvailable) { - initSplitExpense( - transaction, - transactionThreadReport, - splitEffectivePolicy, - selfDMReportID, - restrictedActionPolicyID, - personalPolicy?.outputCurrency, - getCurrencyDecimals, - getCurrencySymbol, - ); - return; - } - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - '', - '', - getReportRHPActiveRoute(), - ), - ); - }} - brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('amount')} - copyValue={amountCopyValue} - copyable={!!amountCopyValue} - /> - + ); case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: return ( !shouldHideEmptyDescription && ( - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ); - }} - wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} - brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('comment')} - numberOfLinesTitle={0} - copyValue={descriptionCopyValue} - copyable={!!descriptionCopyValue} - /> - + ) ); case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: return isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( distanceRequestFields ) : ( - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - wrapperStyle={[styles.taskDescriptionMenuItem]} - furtherDetailsComponent={shouldShowGoogleMerchantSearchLink ? renderGoogleMerchantSearchLink() : undefined} - brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('merchant')} - numberOfLinesTitle={0} - copyValue={merchantCopyValue} - copyable={!!merchantCopyValue} - /> - + ); case CONST.SEARCH.TABLE_COLUMNS.DATE: return ( - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ); - }) - : undefined - } - > - - {!!dateError && } - {canEditDate && } - {!!dateCopyValue && } - - {!!dateError && ( - - )} - - + ); case CONST.SEARCH.TABLE_COLUMNS.CATEGORY: return ( !!shouldShowCategory && ( - - { - if (shouldShowCategoryDisabledAlert) { - showCategoryDisabledAlert(); - return; - } - - if (shouldNavigateToUpgradePath && transactionThreadReport) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, - upgradeBackTo: createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - }), - ), - ); - } else if (!policy && shouldSelectPolicy) { - Navigation.navigate( - ROUTES.SET_DEFAULT_WORKSPACE.getRoute( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - ), - ); - } else { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - ); - } - }) - : undefined - } - > - - {!!getErrorForField('category') && } - {canEdit && } - {!!categoryCopyValue && } - - {!!getErrorForField('category') && ( - - )} - - + ) ); case CONST.SEARCH.TABLE_COLUMNS.VENDOR: return ( shouldShowVendor && ( - - { - if (!transactionThreadReport?.reportID) { - return; - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - getReportRHPActiveRoute(), - ), - ); - }) - : undefined - } - > - - {!!getErrorForField('vendor') && } - {canEdit && } - - {!!getErrorForField('vendor') && ( - - )} - - + ) ); case CONST.SEARCH.TABLE_COLUMNS.TAG: @@ -1568,147 +1526,54 @@ function MoneyRequestView({ case CONST.SEARCH.TABLE_COLUMNS.CARD: return ( !!shouldShowCard && ( - - - - {!!cardCopyValue && } - - - + ) ); case CONST.SEARCH.TABLE_COLUMNS.TAX_RATE: return ( shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ); - }) - : undefined - } - > - - {!!getErrorForField('tax') && } - {canEditTaxFields && } - {!!taxRateCopyValue && } - - {!!getErrorForField('tax') && ( - - )} - - + ) ); case CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT: return ( shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ); - }) - : undefined - } - > - - {canEditTaxFields && } - {!!taxAmountCopyValue && } - - - + ) ); case CONST.SEARCH.TABLE_COLUMNS.ATTENDEES: return ( shouldShowAttendees && ( - - 1 && formattedPerAttendeeAmount - ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` - : '' - }`} - descriptionTextStyle={styles.textLabelSupportingNormal} - titleComponent={ - Array.isArray(actualAttendees) ? ( - ({ - avatar: a?.avatarUrl, - displayName: a?.displayName ?? a?.email ?? '', - accountID: a?.accountID, - email: a?.email, - }))} - maxVisible={canEdit ? undefined : actualAttendees.length} - /> - ) : undefined - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - onPress={() => { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('attendees')} - interactive={canEdit} - shouldShowRightIcon={canEdit} - copyValue={attendeesCopyValue} - copyable={!!attendeesCopyValue} - /> - + ) ); case CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE: @@ -1717,25 +1582,13 @@ function MoneyRequestView({ } return ( shouldShowReimbursable && ( - - - - {Str.UCFirst(translate('iou.reimbursable'))} - - - - + accessibilityLabel={Str.UCFirst(translate('iou.reimbursable'))} + isOn={updatedTransaction?.reimbursable ?? !!transactionReimbursable} + onToggle={saveReimbursable} + disabled={!canEditReimbursable} + /> ) ); case CONST.SEARCH.TABLE_COLUMNS.BILLABLE: @@ -1744,39 +1597,26 @@ function MoneyRequestView({ } return ( shouldShowBillable && ( - - - - {translate('common.billable')} - - {!!getErrorForField('billable') && ( - - )} - - - + accessibilityLabel={translate('common.billable')} + isOn={updatedTransaction?.billable ?? !!transactionBillable} + onToggle={saveBillable} + disabled={!canEdit} + violationProps={ + getErrorForField('billable') + ? { + violations: getViolationsForField('billable'), + isMarkAsCash, + canEdit, + companyCardPageURL, + connectionLink, + routeDistanceMeters: transaction?.comment?.customUnit?.routeDistanceMeters, + distanceUnit: transaction?.comment?.customUnit?.distanceUnit, + } + : undefined + } + /> ) ); default: diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx new file mode 100644 index 000000000000..fe99f1cb4738 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx @@ -0,0 +1,65 @@ +import UserPills from '@components/UserPills'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {enrichAndSortAttendees} from '@libs/AttendeeUtils'; + +import CONST from '@src/CONST'; + +import type {ComponentProps} from 'react'; + +import React from 'react'; + +import TextField from './TextField'; + +type AttendeesFieldProps = Pick, 'pendingAction' | 'onPress' | 'errorText' | 'copyValue'> & { + /** Attendees enriched with their personal details and sorted for display. */ + attendees: ReturnType; + /** Plain text summary used by assistive technology. */ + title: string; + /** Formatted expense amount per attendee. */ + formattedPerAttendeeAmount: string; + /** Whether the attendees can be edited. */ + canEdit: boolean; +}; + +function AttendeesField({attendees, title, formattedPerAttendeeAmount, canEdit, errorText, copyValue, pendingAction, onPress}: AttendeesFieldProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + 1 && formattedPerAttendeeAmount ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` : '' + }`} + descriptionTextStyle={styles.textLabelSupportingNormal} + titleComponent={ + Array.isArray(attendees) ? ( + ({ + avatar: attendee?.avatarUrl, + displayName: attendee?.displayName ?? attendee?.email ?? '', + accountID: attendee?.accountID, + email: attendee?.email, + }))} + maxVisible={canEdit ? undefined : attendees.length} + /> + ) : undefined + } + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + onPress={onPress} + brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={errorText} + interactive={canEdit} + shouldShowRightIcon={canEdit} + copyValue={copyValue} + copyable={!!copyValue} + /> + ); +} + +export default AttendeesField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx new file mode 100644 index 000000000000..1277b80c6bcc --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx @@ -0,0 +1,47 @@ +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; + +import CONST from '@src/CONST'; + +import type {ComponentProps} from 'react'; + +import React from 'react'; + +type EditableFieldProps = Pick & + Pick, 'name' | 'value'> & + Pick, 'onPress'> & { + /** Whether to show the chevron for the field's editor. */ + canEdit?: boolean; + /** Validation or policy violation message to display below the field. */ + error?: string; + /** Value available through the copy action. */ + copyValue?: string; + }; + +function EditableField({pendingAction, name, value, onPress, canEdit = false, error, copyValue}: EditableFieldProps) { + return ( + + + + {!!error && } + {canEdit && } + {!!copyValue && } + + {!!error && ( + + )} + + + ); +} + +export default EditableField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/TextField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/TextField.tsx new file mode 100644 index 000000000000..656472a37284 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/TextField.tsx @@ -0,0 +1,19 @@ +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; + +import type {ComponentProps} from 'react'; + +import React from 'react'; + +type TextFieldProps = ComponentProps & Pick; + +function TextField({pendingAction, ...props}: TextFieldProps) { + return ( + + + + ); +} + +export default TextField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx new file mode 100644 index 000000000000..b3d00c47f827 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx @@ -0,0 +1,54 @@ +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import ViolationMessages from '@components/ViolationMessages'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ComponentProps} from 'react'; + +import React from 'react'; +import {View} from 'react-native'; + +type ToggleFieldProps = Pick & + Pick, 'isOn' | 'onToggle' | 'disabled' | 'accessibilityLabel'> & { + /** Policy violations to display alongside the toggle. */ + violationProps?: Omit, 'containerStyle' | 'textStyle' | 'isLast'>; + }; + +function ToggleField({pendingAction, accessibilityLabel, isOn, onToggle, disabled, violationProps}: ToggleFieldProps) { + const styles = useThemeStyles(); + + return ( + + + + {accessibilityLabel} + + {!!violationProps && ( + + )} + + + + ); +} + +export default ToggleField; From 510fed76731a7db7480da60c862a31c6fca74440 Mon Sep 17 00:00:00 2001 From: brunovjk Date: Mon, 5 Oct 2026 20:35:51 -0300 Subject: [PATCH 06/23] ReturnType --- .../MoneyRequestViewFields/AttendeesField.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx index fe99f1cb4738..aef4768c67c8 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx @@ -3,10 +3,15 @@ import UserPills from '@components/UserPills'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {enrichAndSortAttendees} from '@libs/AttendeeUtils'; - import CONST from '@src/CONST'; +type AttendeeWithAccountID = { + accountID?: string; + avatarUrl?: string | null; + displayName?: string | null; + email?: string | null; +}; + import type {ComponentProps} from 'react'; import React from 'react'; @@ -15,7 +20,7 @@ import TextField from './TextField'; type AttendeesFieldProps = Pick, 'pendingAction' | 'onPress' | 'errorText' | 'copyValue'> & { /** Attendees enriched with their personal details and sorted for display. */ - attendees: ReturnType; + attendees: AttendeeWithAccountID[] | undefined; /** Plain text summary used by assistive technology. */ title: string; /** Formatted expense amount per attendee. */ From 496043cbab873eb3f3039f6d120b0985f74403ac Mon Sep 17 00:00:00 2001 From: brunovjk Date: Tue, 6 Oct 2026 00:28:32 -0300 Subject: [PATCH 07/23] Simplify money request field components with composition and action-based callback names. --- .../ReportActionItem/MoneyRequestView.tsx | 193 +++++++++--------- .../MoneyRequestViewFields/AttendeesField.tsx | 97 ++++----- .../MoneyRequestViewFields/EditableField.tsx | 29 +-- .../MoneyRequestViewFields/TextField.tsx | 19 -- .../MoneyRequestViewFields/ToggleField.tsx | 21 +- 5 files changed, 170 insertions(+), 189 deletions(-) delete mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/TextField.tsx diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 7a52e7934455..2d53380cc668 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -13,6 +13,7 @@ import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import {useSearchResultsContext} from '@components/Search/SearchContext'; import type {SearchColumnType} from '@components/Search/types'; import Text from '@components/Text'; +import ViolationMessages from '@components/ViolationMessages'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useActiveRoute from '@hooks/useActiveRoute'; @@ -172,7 +173,6 @@ import MoneyRequestReceiptView from './MoneyRequestReceiptView'; import MoneyRequestViewAdditionalField from './MoneyRequestViewAdditionalField'; import MoneyRequestAttendeesField from './MoneyRequestViewFields/AttendeesField'; import MoneyRequestEditableField from './MoneyRequestViewFields/EditableField'; -import MoneyRequestTextField from './MoneyRequestViewFields/TextField'; import MoneyRequestToggleField from './MoneyRequestViewFields/ToggleField'; type MoneyRequestViewProps = { @@ -1288,7 +1288,7 @@ function MoneyRequestView({ : []; const fieldsToShow = getMoneyRequestViewFields(tableColumns, savedColumns.length > 0); - const handleAmountPress = () => { + const editOrSplitAmount = () => { if (!transaction?.transactionID || !transactionThreadReport?.reportID) { return; } @@ -1312,25 +1312,25 @@ function MoneyRequestView({ ); }; - const handleDescriptionPress = () => { + const editDescription = () => { Navigation.navigate( createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), ); }; - const handleMerchantPress = () => { + const editMerchant = () => { Navigation.navigate( createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), ); }; - const handleDatePress = callFunctionIfActionIsAllowed(() => { + const editDate = callFunctionIfActionIsAllowed(() => { Navigation.navigate( createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), ); }); - const handleCategoryPress = callFunctionIfActionIsAllowed(() => { + const editCategory = callFunctionIfActionIsAllowed(() => { if (shouldShowCategoryDisabledAlert) { showCategoryDisabledAlert(); return; @@ -1383,7 +1383,7 @@ function MoneyRequestView({ } }); - const handleVendorPress = callFunctionIfActionIsAllowed(() => { + const editVendor = callFunctionIfActionIsAllowed(() => { if (!transactionThreadReport?.reportID) { return; } @@ -1392,7 +1392,7 @@ function MoneyRequestView({ ); }); - const handleTaxRatePress = callFunctionIfActionIsAllowed(() => { + const editTaxRate = callFunctionIfActionIsAllowed(() => { if (shouldShowTaxDisabledAlert) { showTaxDisabledAlert(); return; @@ -1403,7 +1403,7 @@ function MoneyRequestView({ ); }); - const handleTaxAmountPress = callFunctionIfActionIsAllowed(() => { + const editTaxAmount = callFunctionIfActionIsAllowed(() => { if (shouldShowTaxDisabledAlert) { showTaxDisabledAlert(); return; @@ -1414,7 +1414,7 @@ function MoneyRequestView({ ); }); - const handleAttendeesPress = () => { + const editAttendees = () => { Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID))); }; @@ -1422,90 +1422,93 @@ function MoneyRequestView({ switch (column) { case CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT: return ( - + + + ); case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: return ( !shouldHideEmptyDescription && ( - + + + ) ); case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: return isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( distanceRequestFields ) : ( - + + + ); case CONST.SEARCH.TABLE_COLUMNS.DATE: return ( + > + {!!dateCopyValue && } + ); case CONST.SEARCH.TABLE_COLUMNS.CATEGORY: return ( !!shouldShowCategory && ( + > + {!!categoryCopyValue && } + ) ); case CONST.SEARCH.TABLE_COLUMNS.VENDOR: @@ -1513,10 +1516,9 @@ function MoneyRequestView({ shouldShowVendor && ( ) @@ -1530,8 +1532,9 @@ function MoneyRequestView({ pendingAction={getPendingFieldAction('cardID')} name={translate('iou.card')} value={cardCopyValue} - copyValue={cardCopyValue} - /> + > + {!!cardCopyValue && } + ) ); case CONST.SEARCH.TABLE_COLUMNS.TAX_RATE: @@ -1539,13 +1542,13 @@ function MoneyRequestView({ shouldShowTax && ( + > + {!!taxRateCopyValue && } + ) ); case CONST.SEARCH.TABLE_COLUMNS.TAX_AMOUNT: @@ -1553,12 +1556,12 @@ function MoneyRequestView({ shouldShowTax && ( + > + {!!taxAmountCopyValue && } + ) ); case CONST.SEARCH.TABLE_COLUMNS.ATTENDEES: @@ -1570,7 +1573,7 @@ function MoneyRequestView({ title={getAttendeesTitle} formattedPerAttendeeAmount={formattedPerAttendeeAmount} canEdit={canEdit} - onPress={handleAttendeesPress} + onPress={editAttendees} errorText={getErrorForField('attendees')} copyValue={attendeesCopyValue} /> @@ -1603,20 +1606,22 @@ function MoneyRequestView({ isOn={updatedTransaction?.billable ?? !!transactionBillable} onToggle={saveBillable} disabled={!canEdit} - violationProps={ - getErrorForField('billable') - ? { - violations: getViolationsForField('billable'), - isMarkAsCash, - canEdit, - companyCardPageURL, - connectionLink, - routeDistanceMeters: transaction?.comment?.customUnit?.routeDistanceMeters, - distanceUnit: transaction?.comment?.customUnit?.distanceUnit, - } - : undefined - } - /> + > + {!!getErrorForField('billable') && ( + + )} + ) ); default: diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx index aef4768c67c8..a545121f7c8f 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx @@ -1,3 +1,8 @@ +// Displays expense attendees and their per-person amount. +import type {MenuItemProps} from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; import UserPills from '@components/UserPills'; import useLocalize from '@hooks/useLocalize'; @@ -5,6 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import React from 'react'; + type AttendeeWithAccountID = { accountID?: string; avatarUrl?: string | null; @@ -12,58 +19,56 @@ type AttendeeWithAccountID = { email?: string | null; }; -import type {ComponentProps} from 'react'; - -import React from 'react'; - -import TextField from './TextField'; - -type AttendeesFieldProps = Pick, 'pendingAction' | 'onPress' | 'errorText' | 'copyValue'> & { - /** Attendees enriched with their personal details and sorted for display. */ - attendees: AttendeeWithAccountID[] | undefined; - /** Plain text summary used by assistive technology. */ - title: string; - /** Formatted expense amount per attendee. */ - formattedPerAttendeeAmount: string; - /** Whether the attendees can be edited. */ - canEdit: boolean; -}; +type AttendeesFieldProps = Pick & + Pick & { + /** Attendees enriched with their personal details and sorted for display. */ + attendees: AttendeeWithAccountID[] | undefined; + /** Plain text summary used by assistive technology. */ + title: string; + /** Formatted expense amount per attendee. */ + formattedPerAttendeeAmount: string; + /** Whether the attendees can be edited. */ + canEdit: boolean; + }; function AttendeesField({attendees, title, formattedPerAttendeeAmount, canEdit, errorText, copyValue, pendingAction, onPress}: AttendeesFieldProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); return ( - 1 && formattedPerAttendeeAmount ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` : '' - }`} - descriptionTextStyle={styles.textLabelSupportingNormal} - titleComponent={ - Array.isArray(attendees) ? ( - ({ - avatar: attendee?.avatarUrl, - displayName: attendee?.displayName ?? attendee?.email ?? '', - accountID: attendee?.accountID, - email: attendee?.email, - }))} - maxVisible={canEdit ? undefined : attendees.length} - /> - ) : undefined - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - onPress={onPress} - brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={errorText} - interactive={canEdit} - shouldShowRightIcon={canEdit} - copyValue={copyValue} - copyable={!!copyValue} - /> + + 1 && formattedPerAttendeeAmount + ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` + : '' + }`} + descriptionTextStyle={styles.textLabelSupportingNormal} + titleComponent={ + Array.isArray(attendees) ? ( + ({ + avatar: attendee?.avatarUrl, + displayName: attendee?.displayName ?? attendee?.email ?? '', + accountID: attendee?.accountID, + email: attendee?.email, + }))} + maxVisible={canEdit ? undefined : attendees.length} + /> + ) : undefined + } + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + onPress={onPress} + brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={errorText} + interactive={canEdit} + shouldShowRightIcon={canEdit} + copyValue={copyValue} + copyable={!!copyValue} + /> + ); } diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx index 1277b80c6bcc..b968940bb355 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx @@ -1,26 +1,27 @@ +// Keeps an expense field's offline state and validation feedback together. import MenuItem from '@components/MenuItem'; +import type {MenuItemRootProps} from '@components/MenuItem/layout/MenuItemRoot'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; import CONST from '@src/CONST'; -import type {ComponentProps} from 'react'; +import type {PropsWithChildren} from 'react'; import React from 'react'; -type EditableFieldProps = Pick & - Pick, 'name' | 'value'> & - Pick, 'onPress'> & { - /** Whether to show the chevron for the field's editor. */ - canEdit?: boolean; - /** Validation or policy violation message to display below the field. */ - error?: string; - /** Value available through the copy action. */ - copyValue?: string; - }; +type EditableFieldProps = PropsWithChildren< + Pick & + Pick & { + name: string; + value?: string; + /** Validation or policy violation message to display below the field. */ + error?: string; + } +>; -function EditableField({pendingAction, name, value, onPress, canEdit = false, error, copyValue}: EditableFieldProps) { +function EditableField({pendingAction, name, value, onPress, error, children}: EditableFieldProps) { return ( @@ -30,8 +31,8 @@ function EditableField({pendingAction, name, value, onPress, canEdit = false, er numberOfLinesValue={2} > {!!error && } - {canEdit && } - {!!copyValue && } + {!!onPress && } + {children} {!!error && ( & Pick; - -function TextField({pendingAction, ...props}: TextFieldProps) { - return ( - - - - ); -} - -export default TextField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx index b3d00c47f827..b9aa043b1e65 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx @@ -1,23 +1,19 @@ +// Shares the offline layout of expense toggles while callers compose any supporting content. import OfflineWithFeedback from '@components/OfflineWithFeedback'; import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; import Switch from '@components/Switch'; import Text from '@components/Text'; -import ViolationMessages from '@components/ViolationMessages'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ComponentProps} from 'react'; +import type {ComponentProps, PropsWithChildren} from 'react'; import React from 'react'; import {View} from 'react-native'; -type ToggleFieldProps = Pick & - Pick, 'isOn' | 'onToggle' | 'disabled' | 'accessibilityLabel'> & { - /** Policy violations to display alongside the toggle. */ - violationProps?: Omit, 'containerStyle' | 'textStyle' | 'isLast'>; - }; +type ToggleFieldProps = PropsWithChildren & Pick, 'isOn' | 'onToggle' | 'disabled' | 'accessibilityLabel'>>; -function ToggleField({pendingAction, accessibilityLabel, isOn, onToggle, disabled, violationProps}: ToggleFieldProps) { +function ToggleField({pendingAction, accessibilityLabel, isOn, onToggle, disabled, children}: ToggleFieldProps) { const styles = useThemeStyles(); return ( @@ -32,14 +28,7 @@ function ToggleField({pendingAction, accessibilityLabel, isOn, onToggle, disable > {accessibilityLabel} - {!!violationProps && ( - - )} + {children} Date: Tue, 6 Oct 2026 00:35:02 -0300 Subject: [PATCH 08/23] Fix attendee field types and missing cardList arguments in report action tests. --- .../MoneyRequestViewFields/AttendeesField.tsx | 9 ++------- src/libs/AttendeeUtils.ts | 1 + tests/unit/ReportSecondaryActionUtilsTest.ts | 2 ++ 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx index a545121f7c8f..ce15241292ed 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx @@ -8,17 +8,12 @@ import UserPills from '@components/UserPills'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {AttendeeWithAccountID} from '@libs/AttendeeUtils'; + import CONST from '@src/CONST'; import React from 'react'; -type AttendeeWithAccountID = { - accountID?: string; - avatarUrl?: string | null; - displayName?: string | null; - email?: string | null; -}; - type AttendeesFieldProps = Pick & Pick & { /** Attendees enriched with their personal details and sorted for display. */ diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index 517b6ebb723f..c98ece575093 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -161,3 +161,4 @@ function enrichAndSortAttendees( } export {enrichAndSortAttendees, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation, convertAttendeesToArray}; +export type {AttendeeWithAccountID}; diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 93254557f966..2ad7935a7258 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -162,6 +162,7 @@ describe('getSecondaryAction', () => { bankAccountList: {}, policy: createMock({}), rules: undefined, + cardList: undefined, }); // Then only a single-expense report offers field customization from this menu. @@ -5340,6 +5341,7 @@ describe('getSecondaryTransactionThreadActions', () => { policy: createMock({}), isChatReportArchived: false, rules: undefined, + cardList: undefined, }); // Then customization is available only for the report-backed expense. From 176ba3a02185ef721c3c5cb8d4434fac876fab39 Mon Sep 17 00:00:00 2001 From: brunovjk Date: Tue, 6 Oct 2026 09:35:31 -0300 Subject: [PATCH 09/23] TypeScript, React Compiler verification --- .../ReportActionItem/MoneyRequestView.tsx | 148 +++++------------- .../MoneyRequestViewAdditionalField.tsx | 5 + .../MoneyRequestViewFields/AmountField.tsx | 46 ++++++ .../MoneyRequestViewFields/BillableField.tsx | 47 ++++++ .../DescriptionField.tsx | 44 ++++++ .../MoneyRequestViewFields/MerchantField.tsx | 84 ++++++++++ .../MoneyRequestViewFields/ToggleField.tsx | 6 +- src/components/Switch.tsx | 1 + src/components/ViolationMessages.tsx | 2 + .../MoneyRequestViewAdditionalFieldTest.tsx | 24 +++ 10 files changed, 300 insertions(+), 107 deletions(-) create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/AmountField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/BillableField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/DescriptionField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/MerchantField.tsx diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 2d53380cc668..00573672b019 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -8,12 +8,10 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import {useSearchResultsContext} from '@components/Search/SearchContext'; import type {SearchColumnType} from '@components/Search/types'; import Text from '@components/Text'; -import ViolationMessages from '@components/ViolationMessages'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useActiveRoute from '@hooks/useActiveRoute'; @@ -49,7 +47,6 @@ import type {ViolationField} from '@hooks/useViolations'; import useViolations from '@hooks/useViolations'; import {updateMoneyRequestBillable, updateMoneyRequestCategory, updateMoneyRequestReimbursable, updateMoneyRequestTag, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest'; -import {openExternalLink} from '@libs/actions/Link'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {getBrokenConnectionUrlToFixPersonalCard, getCommercialFeedCardDescription, getCompanyCardDescription} from '@libs/CardUtils'; @@ -143,8 +140,6 @@ import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/inbox/report/AnimatedEmptyStateBackground'; -import variables from '@styles/variables'; - import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; @@ -171,8 +166,12 @@ import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; import MoneyRequestReceiptView from './MoneyRequestReceiptView'; import MoneyRequestViewAdditionalField from './MoneyRequestViewAdditionalField'; +import MoneyRequestAmountField from './MoneyRequestViewFields/AmountField'; import MoneyRequestAttendeesField from './MoneyRequestViewFields/AttendeesField'; +import MoneyRequestBillableField from './MoneyRequestViewFields/BillableField'; +import MoneyRequestDescriptionField from './MoneyRequestViewFields/DescriptionField'; import MoneyRequestEditableField from './MoneyRequestViewFields/EditableField'; +import MoneyRequestMerchantField from './MoneyRequestViewFields/MerchantField'; import MoneyRequestToggleField from './MoneyRequestViewFields/ToggleField'; type MoneyRequestViewProps = { @@ -219,7 +218,7 @@ function MoneyRequestView({ isFromReviewDuplicates = false, mergeTransactionID, }: MoneyRequestViewProps) { - const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase', 'NewWindow']); + const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Suitcase']); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -689,34 +688,6 @@ function MoneyRequestView({ const originalMerchantForGoogleSearch = isInvalidMerchantValue(onyxTransaction?.merchant) ? '' : Str.recapitalize(onyxTransaction?.merchant ?? ''); const shouldShowGoogleMerchantSearchLink = !!originalMerchantForGoogleSearch && !isTransactionScanning && isFromCardImport; - const renderGoogleMerchantSearchLink = () => ( - { - event?.stopPropagation(); - openExternalLink(`${CONST.GOOGLE_SEARCH_URL}${encodeURIComponent(originalMerchantForGoogleSearch)}`); - }} - style={[styles.flexRow, styles.alignItemsCenter, styles.mt1, styles.alignSelfStart]} - > - - {translate('common.googleThisMerchant', { - merchant: originalMerchantForGoogleSearch, - })} - - - - ); - const shouldShowConvertedAmount = transactionConvertedAmount && currency !== moneyRequestReport?.currency && @@ -898,6 +869,7 @@ function MoneyRequestView({ }; const dateError = getErrorForField('date'); + const billableViolations = getErrorForField('billable') ? getViolationsForField('billable') : []; const showTaxDisabledAlert = () => { showConfirmModal({ @@ -1422,68 +1394,44 @@ function MoneyRequestView({ switch (column) { case CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT: return ( - - - + ); case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION: return ( !shouldHideEmptyDescription && ( - - - + ) ); case CONST.SEARCH.TABLE_COLUMNS.MERCHANT: return isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( distanceRequestFields ) : ( - - - + ); case CONST.SEARCH.TABLE_COLUMNS.DATE: return ( @@ -1600,28 +1548,18 @@ function MoneyRequestView({ } return ( shouldShowBillable && ( - - {!!getErrorForField('billable') && ( - - )} - + violations={billableViolations} + isMarkAsCash={isMarkAsCash} + canEdit={canEdit} + companyCardPageURL={companyCardPageURL} + connectionLink={connectionLink} + routeDistanceMeters={transaction?.comment?.customUnit?.routeDistanceMeters} + distanceUnit={transaction?.comment?.customUnit?.distanceUnit} + /> ) ); default: diff --git a/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx b/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx index 734b0db07337..3685e99b8553 100644 --- a/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx @@ -22,6 +22,7 @@ import { getReimbursable, getTag, isPerDiemRequest, + isScanning, isTimeRequest, } from '@libs/TransactionUtils'; import getFormattedPostedDate from '@libs/TransactionUtils/getFormattedPostedDate'; @@ -79,6 +80,10 @@ function MoneyRequestViewAdditionalField({column, transaction, report, policy, p break; } case CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE: + if (attendeeCount && isScanning(transaction)) { + value = translate('iou.receiptStatusTitle'); + break; + } value = attendeeCount ? convertToDisplayString(getAmount(transaction, isFromExpenseReport) / attendeeCount, getCurrency(transaction)) : ''; break; case CONST.SEARCH.TABLE_COLUMNS.POSTED: diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/AmountField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AmountField.tsx new file mode 100644 index 000000000000..8ab7630d0d23 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AmountField.tsx @@ -0,0 +1,46 @@ +// Displays the expense amount with its payment status and editing feedback. +import type {MenuItemProps} from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type AmountFieldProps = Pick & + Pick & { + canEdit?: boolean; + shouldShowPaid?: boolean; + }; + +function AmountField({pendingAction, title, description, hintText, errorText, copyValue, onPress, canEdit, shouldShowPaid}: AmountFieldProps) { + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['Checkmark']); + + return ( + + + + ); +} + +export default AmountField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/BillableField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/BillableField.tsx new file mode 100644 index 000000000000..876c20013784 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/BillableField.tsx @@ -0,0 +1,47 @@ +// Keeps billable policy violations next to the expense's billable control. +import ViolationMessages from '@components/ViolationMessages'; +import type {ViolationMessagesProps} from '@components/ViolationMessages'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {ToggleFieldProps} from './ToggleField'; + +import ToggleField from './ToggleField'; + +type BillableFieldProps = Pick & + Pick; + +function BillableField({pendingAction, isOn, onToggle, violations, canEdit, isMarkAsCash, companyCardPageURL, connectionLink, routeDistanceMeters, distanceUnit}: BillableFieldProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + {violations.length > 0 && ( + + )} + + ); +} + +export default BillableField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionField.tsx new file mode 100644 index 000000000000..3939dfb52853 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionField.tsx @@ -0,0 +1,44 @@ +// Preserves rich expense descriptions alongside their offline and validation feedback. +import type {MenuItemProps} from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type DescriptionFieldProps = Pick & + Pick & { + canEdit: boolean; + }; + +function DescriptionField({pendingAction, title, errorText, copyValue, onPress, canEdit}: DescriptionFieldProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + + ); +} + +export default DescriptionField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantField.tsx new file mode 100644 index 000000000000..80594e89c8ba --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantField.tsx @@ -0,0 +1,84 @@ +// Displays the merchant and lets users look up the original card merchant when available. +import Icon from '@components/Icon'; +import type {MenuItemProps} from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import Text from '@components/Text'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {openExternalLink} from '@libs/actions/Link'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type MerchantFieldProps = Pick & + Pick & { + canEdit: boolean; + /** Original card merchant to search for. Omit while scanning or for non-card expenses. */ + googleSearchMerchant?: string; + }; + +function GoogleMerchantSearchLink({merchant}: {merchant: string}) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['NewWindow']); + + return ( + { + event?.stopPropagation(); + openExternalLink(`${CONST.GOOGLE_SEARCH_URL}${encodeURIComponent(merchant)}`); + }} + style={[styles.flexRow, styles.alignItemsCenter, styles.mt1, styles.alignSelfStart]} + > + {translate('common.googleThisMerchant', {merchant})} + + + ); +} + +function MerchantField({pendingAction, title, errorText, copyValue, onPress, canEdit, googleSearchMerchant}: MerchantFieldProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + : undefined} + brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={errorText} + numberOfLinesTitle={0} + copyValue={copyValue} + copyable={!!copyValue} + /> + + ); +} + +export default MerchantField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx index b9aa043b1e65..8d20bbdd0298 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx @@ -2,16 +2,17 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback'; import Switch from '@components/Switch'; +import type {SwitchProps} from '@components/Switch'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ComponentProps, PropsWithChildren} from 'react'; +import type {PropsWithChildren} from 'react'; import React from 'react'; import {View} from 'react-native'; -type ToggleFieldProps = PropsWithChildren & Pick, 'isOn' | 'onToggle' | 'disabled' | 'accessibilityLabel'>>; +type ToggleFieldProps = PropsWithChildren & Pick>; function ToggleField({pendingAction, accessibilityLabel, isOn, onToggle, disabled, children}: ToggleFieldProps) { const styles = useThemeStyles(); @@ -41,3 +42,4 @@ function ToggleField({pendingAction, accessibilityLabel, isOn, onToggle, disable } export default ToggleField; +export type {ToggleFieldProps}; diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx index 4f45b6fae04c..81c7ab55fff6 100644 --- a/src/components/Switch.tsx +++ b/src/components/Switch.tsx @@ -158,3 +158,4 @@ function Switch({isOn, onToggle, accessibilityLabel, disabled, pending = false, } export default Switch; +export type {SwitchProps}; diff --git a/src/components/ViolationMessages.tsx b/src/components/ViolationMessages.tsx index 5587ba4b65c0..ff00cc8d4c30 100644 --- a/src/components/ViolationMessages.tsx +++ b/src/components/ViolationMessages.tsx @@ -29,6 +29,8 @@ type ViolationMessagesProps = { distanceUnit?: Unit; }; +export type {ViolationMessagesProps}; + export default function ViolationMessages({ violations, isLast, diff --git a/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx b/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx index 1c58e7107e6f..9e77a4241d4a 100644 --- a/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx +++ b/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx @@ -69,6 +69,30 @@ describe('additional expense field values', () => { expect(field?.title).toBe('EUR:900'); }); + it.each([CONST.IOU.RECEIPT_STATE.SCAN_READY, CONST.IOU.RECEIPT_STATE.SCANNING])('shows the scanning status instead of a per-attendee amount for %s receipts', (state) => { + // Given a receipt with no extracted merchant or amount yet. + const transaction = {amount: 0, merchant: '', receipt: {receiptID: 123, state}}; + + // When total per attendee is selected while SmartScan is processing the receipt. + const field = renderField(CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE, transaction); + + // Then the field matches the table's scanning status instead of presenting a zero expense. + expect(field?.title).toBe('iou.receiptStatusTitle'); + }); + + it.each([ + [{amount: -1800, merchant: 'Merchant', receipt: {receiptID: 123, state: CONST.IOU.RECEIPT_STATE.OPEN}}, 'EUR:900'], + [{amount: 0, merchant: '', modifiedAmount: -2400, receipt: {receiptID: 123, state: CONST.IOU.RECEIPT_STATE.SCANNING}}, 'EUR:1200'], + [{amount: 0}, 'EUR:0'], + ])('displays the per-attendee amount once it is known (%j)', (transaction, expected) => { + // Given a completed scan, a manual correction during scanning, or a legitimate zero amount. + // When total per attendee is displayed. + const field = renderField(CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE, transaction); + + // Then the scanning placeholder does not hide a known amount. + expect(field?.title).toBe(expected); + }); + it('reads payroll information from the parent report', () => { // Given a selected report-level field while viewing an individual expense. const column = CONST.SEARCH.TABLE_COLUMNS.SUBMITTER_PAYROLL_ID; From 66b9feb8c608a2cc70f8852c817a3ef1b8733834 Mon Sep 17 00:00:00 2001 From: brunovjk Date: Tue, 6 Oct 2026 09:57:32 -0300 Subject: [PATCH 10/23] Total per attendee --- .../MoneyRequestViewAdditionalField.tsx | 9 +++-- tests/ui/MoneyRequestViewTest.tsx | 16 +++++++++ .../MoneyRequestViewAdditionalFieldTest.tsx | 34 ++++++++++++++++--- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx b/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx index 3685e99b8553..3f31f1dc6f93 100644 --- a/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewAdditionalField.tsx @@ -24,6 +24,7 @@ import { isPerDiemRequest, isScanning, isTimeRequest, + shouldShowAttendees, } from '@libs/TransactionUtils'; import getFormattedPostedDate from '@libs/TransactionUtils/getFormattedPostedDate'; @@ -80,11 +81,15 @@ function MoneyRequestViewAdditionalField({column, transaction, report, policy, p break; } case CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE: - if (attendeeCount && isScanning(transaction)) { + if (!shouldShowAttendees(CONST.IOU.TYPE.SUBMIT, policy) || !attendeeCount) { + value = ''; + break; + } + if (isScanning(transaction)) { value = translate('iou.receiptStatusTitle'); break; } - value = attendeeCount ? convertToDisplayString(getAmount(transaction, isFromExpenseReport) / attendeeCount, getCurrency(transaction)) : ''; + value = convertToDisplayString(getAmount(transaction, isFromExpenseReport) / attendeeCount, getCurrency(transaction)); break; case CONST.SEARCH.TABLE_COLUMNS.POSTED: value = transaction.posted ? DateUtils.formatWithUTCTimeZone(getFormattedPostedDate(transaction.posted), CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, dateFnsLocale) : ''; diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 8f0c03df2b0f..32e5be68fb00 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -202,6 +202,22 @@ describe('MoneyRequestView edit fields', () => { return transaction; }; + it('does not treat the fallback report owner as an enabled attendee on Collect', async () => { + // Given a Collect workspace with no explicit attendees and a selected total-per-attendee column. + const threadReport = {...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID}; + await setupTestData(); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS, [CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE]); + }); + + // When the expense derives the report owner as its fallback attendee. + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then the selected field stays empty, matching the report table's policy restriction. + expect(screen.getByTestId('menu-item-title-iou.totalPerAttendee')).toHaveTextContent(/^$/); + }); + it.each([ [CONST.SEARCH.TABLE_COLUMNS.BILLABLE, 'common.billable', false, {disabledFields: {defaultBillable: true}}], [CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE, 'common.reimbursable', false, {disabledFields: {reimbursable: true}, defaultReimbursable: false}], diff --git a/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx b/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx index 9e77a4241d4a..2c32c3ae64ac 100644 --- a/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx +++ b/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx @@ -5,32 +5,58 @@ import MoneyRequestViewAdditionalField from '@components/ReportActionItem/MoneyR import type {SearchColumnType} from '@components/Search/types'; import CONST from '@src/CONST'; -import type {Transaction} from '@src/types/onyx'; +import type {Policy, Transaction} from '@src/types/onyx'; import React from 'react'; +import createMock from '../utils/createMock'; + jest.mock('@components/MenuItemWithTopDescription', () => jest.fn(() => null)); jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); jest.mock('@hooks/useCurrencyList', () => ({ useCurrencyListActions: () => ({convertToDisplayString: (amount: number, currency: string) => `${currency}:${amount}`}), })); -function renderField(column: SearchColumnType, overrides: Partial = {}) { +const attendeesEnabledPolicy = createMock({id: 'policy-1', type: CONST.POLICY.TYPE.CORPORATE, isAttendeeTrackingEnabled: true}); + +function renderField( + column: SearchColumnType, + overrides: Partial = {}, + {policy, attendeeCount}: {policy: Policy | undefined; attendeeCount: number} = {policy: attendeesEnabledPolicy, attendeeCount: 2}, +) { render( , ); return jest.mocked(MenuItemWithTopDescription).mock.calls.at(-1)?.at(0); } describe('additional expense field values', () => { + it.each([ + ['Collect', createMock({id: 'policy-1', type: CONST.POLICY.TYPE.TEAM}), 1], + ['Control with tracking disabled', {...attendeesEnabledPolicy, isAttendeeTrackingEnabled: false}, 2], + ['missing policy', undefined, 1], + ['no attendees', attendeesEnabledPolicy, 0], + ] as const)('leaves total per attendee empty for %s', (_scenario, policy, attendeeCount) => { + // Given a policy or attendee count for which the report table leaves the cell empty. + const options = {policy, attendeeCount}; + + // When the selected field is displayed for a known amount or a receipt still scanning. + for (const transaction of [{}, {amount: 0, merchant: '', receipt: {receiptID: 123, state: CONST.IOU.RECEIPT_STATE.SCANNING}}]) { + const field = renderField(CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE, transaction, options); + + // Then neither an amount nor a scanning placeholder is shown or offered for copying. + expect(field).toEqual(expect.objectContaining({title: '', copyable: false, copyValue: ''})); + } + }); + it.each([ [undefined, 'EUR:1000'], [-1250, 'USD:1250'], From 89d70869fa5418cf534b6d5208f10113c46afcb4 Mon Sep 17 00:00:00 2001 From: brunovjk Date: Tue, 6 Oct 2026 10:45:00 -0300 Subject: [PATCH 11/23] fix lint --- .../MoneyReportHeaderSecondaryActions.tsx | 4 ++-- src/components/MoneyRequestHeaderSecondaryActions.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index f03a1ca58507..97d2adda4728 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -313,7 +313,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo ? sortPoliciesByName(activeAdminPolicies, localeCompare) : []; - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Info', 'Cash', 'ArrowRight', 'Building', 'Table']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Info', 'Cash', 'ArrowRight', 'Building', 'Columns']); // Build PAY action sub-items. Workspace-policy entries carry the policy as data and have no onSelected; // MoneyReportHeaderKYCDropdown picks them up via onSubItemSelected where triggerKYCFlow is in scope. @@ -419,7 +419,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo [CONST.REPORT.SECONDARY_ACTIONS.CUSTOMIZE_FIELDS]: { value: CONST.REPORT.SECONDARY_ACTIONS.CUSTOMIZE_FIELDS, text: translate('search.customizeFields'), - icon: expensifyIcons.Table, + icon: expensifyIcons.Columns, onSelected: () => { if (!reportID) { return; diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 3c0f61410e70..f6c6f80901d0 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -135,7 +135,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money 'DocumentMerge', 'ExpenseCopy', 'Info', - 'Table', + 'Columns', 'Send', 'Stopwatch', 'ThumbsDown', @@ -543,7 +543,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.CUSTOMIZE_FIELDS]: { value: CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.CUSTOMIZE_FIELDS, text: translate('search.customizeFields'), - icon: expensifyIcons.Table, + icon: expensifyIcons.Columns, onSelected: () => { if (!parentReport?.reportID) { return; From 3470ce6af2cb3a1a1608aba34472755b1d0fe2b3 Mon Sep 17 00:00:00 2001 From: brunovjk Date: Thu, 15 Oct 2026 12:25:26 -0300 Subject: [PATCH 12/23] Fix column reordering on native platforms --- src/components/ColumnsSettingsList.tsx | 80 +++---- .../ColumnsSettingsListItem.native.tsx | 56 +++++ src/components/ColumnsSettingsListItem.tsx | 43 ++++ src/components/DraggableList/index.native.tsx | 37 +++- src/components/DraggableList/types.ts | 5 +- .../MoneyRequestViewFields/AmountField.tsx | 2 +- .../MoneyRequestViewFields/AttendeesField.tsx | 2 - .../MoneyRequestViewFields/EditableField.tsx | 51 +++-- .../MoneyRequestViewFields/MerchantField.tsx | 1 + .../ListItem/MultiSelectListItem.tsx | 2 + .../ListItem/SelectableListItem.tsx | 2 + .../ListItem/SingleSelectListItem.tsx | 2 + .../SelectionList/ListItem/types.ts | 6 + .../Report/ReportDetailsColumnsPage.tsx | 2 +- tests/ui/ColumnsSettingsListTest.tsx | 205 ++++++++++++++++++ .../settings/ReportDetailsColumnsPageTest.tsx | 56 ++++- 16 files changed, 471 insertions(+), 81 deletions(-) create mode 100644 src/components/ColumnsSettingsListItem.native.tsx create mode 100644 src/components/ColumnsSettingsListItem.tsx create mode 100644 tests/ui/ColumnsSettingsListTest.tsx diff --git a/src/components/ColumnsSettingsList.tsx b/src/components/ColumnsSettingsList.tsx index 9e5438bab856..1edb6b298232 100644 --- a/src/components/ColumnsSettingsList.tsx +++ b/src/components/ColumnsSettingsList.tsx @@ -15,39 +15,21 @@ import type {ComponentRef} from 'react'; import React, {useRef, useState} from 'react'; import {View} from 'react-native'; +import type {ColumnItem} from './ColumnsSettingsListItem'; +import type {DraggableListRenderItemParams} from './DraggableList/types'; import type {SearchCustomColumnIds} from './Search/types'; import type {ListItem} from './SelectionList/types'; import Button from './Button'; +import ColumnsSettingsListItem from './ColumnsSettingsListItem'; import DraggableList from './DraggableList'; import HeaderWithBackButton from './HeaderWithBackButton'; import Icon from './Icon'; import ScreenWrapper from './ScreenWrapper'; import ScrollView from './ScrollView'; -import MultiSelectListItem from './SelectionList/ListItem/MultiSelectListItem'; import Text from './Text'; import TextLink from './TextLink'; -type ColumnItem = { - /** Display label for the column */ - text: string; - - value: SearchCustomColumnIds; - keyForList: SearchCustomColumnIds; - - /** Whether the column is currently enabled/visible */ - isSelected: boolean; - - /** Whether the column toggle is disabled (e.g. for required columns) */ - isDisabled: boolean; - - /** Whether the column cannot be reordered via drag */ - isDragDisabled: boolean; - - /** Element rendered on the left side of the list item (e.g. drag handle) */ - leftElement: React.JSX.Element; -}; - type ColumnsSettingsListProps = { /** Heading for the surface sharing the column preferences */ titleKey?: TranslationPaths; @@ -236,17 +218,15 @@ function ColumnsSettingsList({ onSave(selectedColumnIds); }; - const renderItem = ({item, isFocused}: {item: ListItem; isFocused?: boolean}) => { - return ( - - ); - }; + const renderItem = ({item, isFocused, drag, isActive}: DraggableListRenderItemParams) => ( + + ); return ( {!isDefaultState && {translate('search.resetColumns')}} - - {isGrouped ? ( + {isGrouped ? ( + {translate('search.groupColumns')} @@ -297,19 +277,19 @@ function ColumnsSettingsList({ focusedIndex={typeFocusedIndex} /> - ) : ( - item.value} - onDragEnd={onTypeDragEnd} - onSelectRow={onSelectItem} - isItemDragDisabled={(item) => item.isDragDisabled} - isItemDisabled={(item) => item.isDisabled} - renderItem={renderItem} - /> - )} - + + ) : ( + item.value} + onDragEnd={onTypeDragEnd} + onSelectRow={onSelectItem} + shouldRestrictDragToEnabledItems + isItemDragDisabled={(item) => item.isDragDisabled} + isItemDisabled={(item) => item.isDisabled} + renderItem={renderItem} + /> + )}