diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 26e5196599a9..fbf6ee212564 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1711,6 +1711,7 @@ const CONST = {
PRINT: 'print',
CHANGE_WORKSPACE: 'changeWorkspace',
CHANGE_APPROVER: 'changeApprover',
+ CUSTOMIZE_FIELDS: 'customizeFields',
VIEW_DETAILS: 'viewDetails',
DELETE: 'delete',
RETRACT: 'retract',
@@ -1771,6 +1772,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 a76d8c3ba05d..d8db43f4e8f5 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -2875,6 +2875,10 @@ 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) => `r/${reportID}/settings/columns` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 5663dac47604..450e5bedecbf 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -476,6 +476,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/ColumnsSettingsList.tsx b/src/components/ColumnsSettingsList.tsx
index a065dd38942d..1edb6b298232 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';
@@ -14,40 +15,25 @@ 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;
+
allColumns: SearchCustomColumnIds[];
/** The default set of selected columns when no customization has been applied */
@@ -76,6 +62,7 @@ type ColumnsSettingsListProps = {
};
function ColumnsSettingsList({
+ titleKey = 'search.columns',
allColumns,
defaultSelectedColumns,
currentColumns,
@@ -231,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')}}
-
+ {!isDefaultState && {translate('search.resetColumns')}}
-
- {isGrouped ? (
+ {isGrouped ? (
+
{translate('search.groupColumns')}
@@ -294,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}
+ />
+ )}
+ ) : undefined
+ }
+ >
+ {!canEdit && !!copyValue && }
+
+ );
+}
+
+export default AmountField;
diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx
new file mode 100644
index 000000000000..212809dc9404
--- /dev/null
+++ b/src/components/ReportActionItem/MoneyRequestViewFields/AttendeesField.tsx
@@ -0,0 +1,61 @@
+// Displays expense attendees and their per-person amount.
+import type {MenuItemProps} from '@components/MenuItem';
+import MenuItem from '@components/MenuItem';
+import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback';
+import UserPills from '@components/UserPills';
+
+import useLocalize from '@hooks/useLocalize';
+
+import type {AttendeeWithAccountID} from '@libs/AttendeeUtils';
+
+import CONST from '@src/CONST';
+
+import React from 'react';
+
+import EditableField from './EditableField';
+
+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;
+ formattedPerAttendeeAmount: string;
+ canEdit: boolean;
+ };
+
+function AttendeesField({attendees, title, formattedPerAttendeeAmount, canEdit, errorText, copyValue, pendingAction, onPress}: AttendeesFieldProps) {
+ const {translate} = useLocalize();
+ const description = `${translate('iou.attendees')} ${
+ Array.isArray(attendees) && attendees.length > 1 && formattedPerAttendeeAmount ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` : ''
+ }`;
+
+ return (
+ 0 ? (
+ ({
+ avatar: attendee?.avatarUrl,
+ displayName: attendee?.displayName ?? attendee?.email ?? '',
+ accountID: attendee?.accountID,
+ email: attendee?.email,
+ }))}
+ maxVisible={canEdit ? undefined : attendees.length}
+ isSelectable={!!copyValue}
+ />
+ ) : undefined
+ }
+ >
+ {!canEdit && !!copyValue && }
+
+ );
+}
+
+export default AttendeesField;
diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/BillableField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/BillableField.tsx
new file mode 100644
index 000000000000..457dc3c157e4
--- /dev/null
+++ b/src/components/ReportActionItem/MoneyRequestViewFields/BillableField.tsx
@@ -0,0 +1,70 @@
+// 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 CONST from '@src/CONST';
+
+import React from 'react';
+import {View} from 'react-native';
+
+import type {ToggleFieldProps} from './ToggleField';
+
+import ToggleField from './ToggleField';
+
+type BillableFieldProps = Pick &
+ Pick<
+ ViolationMessagesProps,
+ 'violations' | 'canEdit' | 'isMarkAsCash' | 'companyCardPageURL' | 'connectionLink' | 'routeDistanceMeters' | 'distanceUnit' | 'policyVendors' | 'transactionCurrency'
+ >;
+
+function BillableField({
+ pendingAction,
+ isOn,
+ onToggle,
+ violations,
+ canEdit,
+ isMarkAsCash,
+ companyCardPageURL,
+ connectionLink,
+ routeDistanceMeters,
+ distanceUnit,
+ policyVendors,
+ transactionCurrency,
+}: 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..6d728e952081
--- /dev/null
+++ b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionField.tsx
@@ -0,0 +1,43 @@
+// Preserves rich expense descriptions alongside their offline and validation feedback.
+import type {MenuItemProps} from '@components/MenuItem';
+import MenuItem from '@components/MenuItem';
+import type {OfflineWithFeedbackProps} from '@components/OfflineWithFeedback';
+
+import useLocalize from '@hooks/useLocalize';
+
+import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper';
+
+import React from 'react';
+import {View} from 'react-native';
+
+import EditableField from './EditableField';
+
+type DescriptionFieldProps = Pick &
+ Pick & {
+ canEdit: boolean;
+ };
+
+function DescriptionField({pendingAction, title, errorText, copyValue, onPress, canEdit}: DescriptionFieldProps) {
+ const {translate} = useLocalize();
+
+ return (
+
+ {title}
+
+ ) : undefined
+ }
+ >
+ {!canEdit && !!copyValue && }
+
+ );
+}
+
+export default DescriptionField;
diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx
new file mode 100644
index 000000000000..bb143ca70959
--- /dev/null
+++ b/src/components/ReportActionItem/MoneyRequestViewFields/EditableField.tsx
@@ -0,0 +1,85 @@
+// 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 hasRenderableChildren from '@libs/hasRenderableChildren';
+
+import CONST from '@src/CONST';
+
+import type {PropsWithChildren, ReactNode} from 'react';
+
+import React from 'react';
+
+type EditableFieldProps = PropsWithChildren<
+ Pick &
+ Pick & {
+ name: string;
+ value?: string;
+ isValueSelectable?: boolean;
+ /** Defaults to 2; use 0 to display the full value. */
+ numberOfLinesValue?: number;
+ error?: ReactNode;
+ valueComponent?: ReactNode;
+ hintText?: ReactNode;
+ footer?: ReactNode;
+ }
+>;
+
+function EditableField({
+ pendingAction,
+ name,
+ value,
+ onPress,
+ error,
+ children,
+ isValueSelectable,
+ numberOfLinesValue = 2,
+ valueComponent,
+ accessibilityLabel,
+ hintText,
+ footer,
+}: EditableFieldProps) {
+ return (
+
+
+
+
+ {valueComponent ??
+ (value ? (
+
+ {value}
+
+ ) : undefined)}
+
+ {(!!error || !!onPress || hasRenderableChildren(children)) && (
+
+ {!!error && }
+ {!!onPress && }
+ {children}
+
+ )}
+
+ {!!hintText && }
+ {footer}
+ {!!error && (
+
+ )}
+
+
+ );
+}
+
+export default EditableField;
diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantField.tsx
new file mode 100644
index 000000000000..d8ec085a2111
--- /dev/null
+++ b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantField.tsx
@@ -0,0 +1,79 @@
+// 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 MenuItem from '@components/MenuItem';
+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';
+
+import EditableField from './EditableField';
+
+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 {translate} = useLocalize();
+
+ return (
+ : undefined}
+ >
+ {!canEdit && !!copyValue && }
+
+ );
+}
+
+export default MerchantField;
diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx
new file mode 100644
index 000000000000..30a07796be11
--- /dev/null
+++ b/src/components/ReportActionItem/MoneyRequestViewFields/ToggleField.tsx
@@ -0,0 +1,48 @@
+// 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 type {SwitchProps} from '@components/Switch';
+import Text from '@components/Text';
+
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import {COPYABLE_TEXT_DATA_SET} from '@libs/SelectionScraper';
+
+import type {PropsWithChildren} from 'react';
+
+import React from 'react';
+import {View} from 'react-native';
+
+type ToggleFieldProps = PropsWithChildren & Pick>;
+
+function ToggleField({pendingAction, accessibilityLabel, isOn, onToggle, disabled, children}: ToggleFieldProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+
+
+ {accessibilityLabel}
+
+ {children}
+
+
+
+ );
+}
+
+export default ToggleField;
+export type {ToggleFieldProps};
diff --git a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx
index 97533bc25ada..9e008da2d062 100644
--- a/src/components/SelectionList/ListItem/MultiSelectListItem.tsx
+++ b/src/components/SelectionList/ListItem/MultiSelectListItem.tsx
@@ -19,6 +19,7 @@ function MultiSelectListItem({
showTooltip,
isDisabled,
onSelectRow,
+ onLongPressRow,
onDismissError,
shouldPreventEnterKeySubmit,
onFocus,
@@ -39,6 +40,7 @@ function MultiSelectListItem({
canSelectMultiple
accessibilityRole={CONST.ROLE.CHECKBOX}
onSelectRow={onSelectRow}
+ onLongPressRow={onLongPressRow}
onDismissError={onDismissError}
shouldPreventEnterKeySubmit={shouldPreventEnterKeySubmit}
onFocus={onFocus}
diff --git a/src/components/SelectionList/ListItem/SelectableListItem.tsx b/src/components/SelectionList/ListItem/SelectableListItem.tsx
index 168860b0c2d4..17493c73d72f 100644
--- a/src/components/SelectionList/ListItem/SelectableListItem.tsx
+++ b/src/components/SelectionList/ListItem/SelectableListItem.tsx
@@ -19,6 +19,7 @@ function SelectableListItem({
selectionButtonPosition = CONST.SELECTION_BUTTON_POSITION.RIGHT,
onSelectionButtonPress,
onSelectRow,
+ onLongPressRow,
isDisabled = false,
children,
isFocused,
@@ -45,6 +46,7 @@ function SelectableListItem({
item={item}
shouldShowTooltip={showTooltip}
onSelectRow={onSelectRow}
+ onLongPressRow={onLongPressRow}
isDisabled={isDisabled}
canSelectMultiple={canSelectMultiple}
isFocused={isFocused}
diff --git a/src/components/SelectionList/ListItem/SingleSelectListItem.tsx b/src/components/SelectionList/ListItem/SingleSelectListItem.tsx
index 4d3932a69e9b..3dd3862c7932 100644
--- a/src/components/SelectionList/ListItem/SingleSelectListItem.tsx
+++ b/src/components/SelectionList/ListItem/SingleSelectListItem.tsx
@@ -26,6 +26,7 @@ function SingleSelectListItem({
canSelectMultiple,
accessibilityRole,
onSelectRow,
+ onLongPressRow,
onDismissError,
shouldPreventEnterKeySubmit,
onFocus,
@@ -63,6 +64,7 @@ function SingleSelectListItem({
isDisabled={isDisabled}
showTooltip={showTooltip}
onSelectRow={onSelectRow}
+ onLongPressRow={onLongPressRow}
onDismissError={onDismissError}
shouldPreventEnterKeySubmit={shouldPreventEnterKeySubmit}
canSelectMultiple={canSelectMultiple}
diff --git a/src/components/SelectionList/ListItem/types.ts b/src/components/SelectionList/ListItem/types.ts
index b6362fe09f4c..5ba13b426645 100644
--- a/src/components/SelectionList/ListItem/types.ts
+++ b/src/components/SelectionList/ListItem/types.ts
@@ -133,6 +133,9 @@ type ListItemProps = {
onSelectRow: (item: TItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void;
onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[], shiftKey?: boolean) => void;
+
+ /** Optional long-press action, such as starting a native drag without changing selection. */
+ onLongPressRow?: ListItemPressableProps['onLongPressRow'];
onDismissError?: (item: TItem) => void;
onFocus?: ListItemFocusEventHandler;
@@ -225,6 +228,9 @@ type SelectableListItemProps = PropsWithChildren<{
/** Callback to fire when the selection button is pressed */
onSelectionButtonPress?: (item: TItem, itemTransactions?: TransactionListItemType[], shiftKey?: boolean) => void;
+
+ /** Optional long-press action, such as starting a native drag without changing selection. */
+ onLongPressRow?: ListItemPressableProps['onLongPressRow'];
onDismissError?: (item: TItem) => void;
onFocus?: ListItemFocusEventHandler;
isDisabled?: boolean | null;
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 159852f7f7b0..ccaa7f0c77be 100644
--- a/src/components/ViolationMessages.tsx
+++ b/src/components/ViolationMessages.tsx
@@ -31,6 +31,8 @@ type ViolationMessagesProps = {
transactionCurrency?: string;
};
+export type {ViolationMessagesProps};
+
export default function ViolationMessages({
violations,
isLast,
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 1ceb13182684..284ee561f905 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -10132,6 +10132,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 1f86df0c0de8..a38a57a780f3 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -10338,6 +10338,7 @@ ${reportName}`,
staleResults: {title: 'Απαιτείται ανανέωση', subtitle: 'Αυτή η σελίδα είναι παρωχημένη, κάντε ανανέωση για να δείτε τα πιο πρόσφατα', buttonText: 'Ανανέωση'},
},
columns: 'Στήλες',
+ customizeFields: 'Προσαρμογή πεδίων',
editColumns: 'Επεξεργασία στηλών',
resetColumns: 'Επαναφορά στηλών',
groupColumns: 'Ομαδοποίηση στηλών',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index de93968c5084..967ab97c1551 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -10358,6 +10358,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 b56cb7d9df99..89a8db87afe1 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -9969,6 +9969,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 00f4b752c3b5..45fad286704b 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -10158,6 +10158,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 0389bd7d30c8..0fec9cba81db 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -10084,6 +10084,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 28799a24d53c..f4d33dcec6f6 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -9941,6 +9941,7 @@ ${reportName}`,
staleResults: {title: '再読み込みが必要です', subtitle: 'このページは古くなっています。最新の情報を見るには再読み込みしてください。', buttonText: '更新'},
},
columns: '列',
+ customizeFields: 'フィールドをカスタマイズ',
editColumns: '列の編集',
resetColumns: '列をリセット',
groupColumns: '列をグループ化',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 53e6d88d4abe..ec4dfe1feb48 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -10050,6 +10050,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 459bbc792671..0b6857ec9649 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -10061,6 +10061,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 ca1dca14cc0d..bc1fdb949666 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -10048,6 +10048,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 ce7ae7a72f3b..4800335241f8 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -9672,6 +9672,7 @@ ${reportName}`,
staleResults: {title: '需要刷新', subtitle: '此页面已过期,刷新以查看最新内容', buttonText: '刷新'},
},
columns: '列',
+ customizeFields: '自定义字段',
editColumns: '编辑列',
resetColumns: '重置列',
groupColumns: '分组列',
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/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 201a11234ea6..d98cbd316221 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -308,6 +308,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 ba4687eab1a4..87138b3adbd5 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -1732,6 +1732,9 @@ 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,
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 1df742c2cc79..c9c5747cc72f 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -2375,6 +2375,7 @@ type ReportSettingsNavigatorParamList = {
[SCREENS.REPORT_SETTINGS.COLUMNS]: {
reportID: string;
};
+ [SCREENS.REPORT_SETTINGS.FIELDS]: {reportID: string};
};
type ReportDescriptionNavigatorParamList = {
diff --git a/src/libs/PopoverMenuSections.ts b/src/libs/PopoverMenuSections.ts
index 41d4ec38e1f4..ccdb1d4bd7b0 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.SHARE, 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 11baada8cb50..3d724ba76b76 100644
--- a/src/libs/ReportSecondaryActionUtils.ts
+++ b/src/libs/ReportSecondaryActionUtils.ts
@@ -1207,6 +1207,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, policy, cardList, reportActions ?? [], true)) {
@@ -1350,6 +1354,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, policy, cardList, reportAction ? [reportAction] : [])) {
diff --git a/src/libs/getMoneyRequestViewFields.ts b/src/libs/getMoneyRequestViewFields.ts
new file mode 100644
index 000000000000..5bf2ab5e91b0
--- /dev/null
+++ b/src/libs/getMoneyRequestViewFields.ts
@@ -0,0 +1,38 @@
+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..129f4fdf0a1a 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';
@@ -18,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';
@@ -42,10 +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 = 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.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}`);
@@ -64,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
@@ -77,11 +82,11 @@ function ReportDetailsColumnsPage() {
const effectiveColumns = useMemo(() => {
const savedColumns = (reportDetailsColumns ?? []).filter(isReportDetailsCustomColumn);
if (savedColumns.length > 0) {
- return savedColumns;
+ return isExpenseView ? getMoneyRequestViewFields(savedColumns, true).filter(isReportDetailsCustomColumn) : savedColumns;
}
if (!reportTransactions?.length) {
- return REPORT_DETAILS_DEFAULT_COLUMNS;
+ return isExpenseView ? EXPENSE_DEFAULT_FIELDS : REPORT_DETAILS_DEFAULT_COLUMNS;
}
const visibleColumns = getColumnsToShow({
@@ -96,11 +101,13 @@ 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 ? 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]);
@@ -108,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();
};
@@ -119,8 +136,9 @@ function ReportDetailsColumnsPage() {
return (
{
+ 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/ColumnsSettingsListTest.tsx b/tests/ui/ColumnsSettingsListTest.tsx
new file mode 100644
index 000000000000..c57b8cf51d37
--- /dev/null
+++ b/tests/ui/ColumnsSettingsListTest.tsx
@@ -0,0 +1,205 @@
+import {act, fireEvent, render, screen} from '@testing-library/react-native';
+
+import ColumnsSettingsList from '@components/ColumnsSettingsList';
+import type {ColumnItem, ColumnsSettingsListItemProps} from '@components/ColumnsSettingsListItem';
+import type NativeColumnsSettingsListItem from '@components/ColumnsSettingsListItem.native';
+import type NativeDraggableList from '@components/DraggableList/index.native';
+import type DraggableListProps from '@components/DraggableList/types';
+import type {SearchCustomColumnIds} from '@components/Search/types';
+
+import getPlatform from '@libs/getPlatform';
+
+import CONST from '@src/CONST';
+
+import type {PropsWithChildren} from 'react';
+import type {DraggableFlatListProps} from 'react-native-draggable-flatlist';
+
+import React from 'react';
+import {View as MockView} from 'react-native';
+
+// Exercise the native drag boundary, where starting a drag is the row's responsibility.
+jest.mock('@components/DraggableList', () => jest.requireActual<{default: typeof NativeDraggableList}>('@components/DraggableList/index.native'));
+jest.mock('@components/ColumnsSettingsListItem', () => {
+ const {default: NativeItem} = jest.requireActual<{
+ default: typeof NativeColumnsSettingsListItem;
+ }>('@components/ColumnsSettingsListItem.native');
+ const {default: WebItem} = jest.requireActual<{
+ default: typeof NativeColumnsSettingsListItem;
+ }>('@components/ColumnsSettingsListItem.tsx');
+ const platform = jest.requireMock('@libs/getPlatform');
+ const {default: constants} = jest.requireActual<{default: typeof CONST}>('@src/CONST');
+ return (props: ColumnsSettingsListItemProps) => (platform() === constants.PLATFORM.WEB ? : );
+});
+jest.mock('@components/ScrollView', () => ({children}: PropsWithChildren) => {children});
+jest.mock('@libs/getPlatform', () => jest.fn(() => 'ios'));
+jest.mock('@hooks/useListKeyboardNav', () => () => ({
+ focusedIndex: -1,
+ setFocusedIndex: jest.fn(),
+}));
+jest.mock('@hooks/useLocalize', () => () => ({
+ translate: (key: string) => key,
+ localeCompare: (a: string, b: string) => a.localeCompare(b),
+}));
+jest.mock('@components/ScreenWrapper', () => ({children}: PropsWithChildren) => {children});
+jest.mock('@components/HeaderWithBackButton', () => ({children}: PropsWithChildren) => {children});
+
+const mockDrag = jest.fn();
+type NativeListProps = DraggableListProps & Pick, 'dragItemOverflow'>;
+const mockNativeList = jest.fn(({data, renderItem, ListFooterComponent}: NativeListProps) => (
+
+ {data.map((item, index) => (
+
+ {renderItem({
+ item,
+ getIndex: () => index,
+ drag: () => mockDrag(item.value),
+ isActive: false,
+ })}
+
+ ))}
+ {ListFooterComponent}
+
+));
+
+jest.mock('react-native-draggable-flatlist', () => ({
+ __esModule: true,
+ default: (props: NativeListProps) => mockNativeList(props),
+}));
+
+const {TOTAL_AMOUNT, MERCHANT, DATE, CATEGORY} = CONST.SEARCH.TABLE_COLUMNS;
+
+function renderFields() {
+ const onSave = jest.fn();
+ render(
+ ,
+ );
+ return onSave;
+}
+
+describe('customizing expense fields on native', () => {
+ beforeEach(() => {
+ jest.mocked(getPlatform).mockReturnValue(CONST.PLATFORM.IOS);
+ mockDrag.mockClear();
+ mockNativeList.mockClear();
+ });
+
+ it('starts a drag on long press for selected fields, including the required amount, without toggling them', () => {
+ // Given the expense field picker with an amount that cannot be deselected and an unchecked category.
+ const onSave = renderFields();
+
+ // When the user holds each selected row and then an unchecked row.
+ for (const column of [TOTAL_AMOUNT, MERCHANT, DATE, CATEGORY]) {
+ fireEvent(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${column}`), 'longPress');
+ }
+ fireEvent.press(screen.getByText('common.save'));
+
+ // Then native dragging starts only for the selected fields, and a long press does not change their selection.
+ expect(mockDrag.mock.calls.map(([column]) => column)).toEqual([TOTAL_AMOUNT, MERCHANT, DATE]);
+ expect(onSave).toHaveBeenCalledWith([TOTAL_AMOUNT, MERCHANT, DATE]);
+ expect(screen.queryByTestId('column-outer-scroll')).not.toBeOnTheScreen();
+ });
+
+ it('starts dragging immediately from the handle without toggling the field or nesting scroll containers', () => {
+ // Given the native expense picker, where an outer scroll gesture must not capture the drag.
+ const onSave = renderFields();
+ const stopPropagation = jest.fn();
+
+ // When the handle is touched and released without waiting for a row long press.
+ for (const column of [TOTAL_AMOUNT, MERCHANT, CATEGORY]) {
+ const handle = screen.getByTestId(`column-drag-handle-${column}`);
+ fireEvent(handle, 'pressIn', {stopPropagation});
+ fireEvent.press(handle, {stopPropagation});
+ }
+ fireEvent.press(screen.getByText('common.save'));
+
+ // Then only enabled handles activate the drag, selection is unchanged, and the list owns its scroll.
+ expect(mockDrag.mock.calls.map(([column]) => column)).toEqual([TOTAL_AMOUNT, MERCHANT]);
+ expect(onSave).toHaveBeenCalledWith([TOTAL_AMOUNT, MERCHANT, DATE]);
+ expect(screen.queryByTestId('column-outer-scroll')).not.toBeOnTheScreen();
+ });
+
+ it('leaves pointer and keyboard dragging to the web sortable wrapper', () => {
+ // Given the desktop field picker, whose sortable wrapper already handles pointer and keyboard gestures.
+ jest.mocked(getPlatform).mockReturnValue(CONST.PLATFORM.WEB);
+
+ // When the fields render with their usual non-pressable drag icons.
+ renderFields();
+
+ // Then no native touch handle intercepts the web drag gesture.
+ expect(screen.queryByTestId(`column-drag-handle-${MERCHANT}`)).not.toBeOnTheScreen();
+ expect(mockDrag).not.toHaveBeenCalled();
+ });
+
+ it('allows hovering over unchecked fields and returns the dropped field to the end of the selected group', () => {
+ // Given selected fields followed by an unchecked category, as in the reported drag regression.
+ const onSave = renderFields();
+ const list = mockNativeList.mock.calls.at(-1)?.at(0);
+ if (!list) {
+ throw new Error('Expected native expense fields');
+ }
+ expect(list.data.map((item) => item.value)).toEqual([TOTAL_AMOUNT, MERCHANT, DATE]);
+ expect(list.dragItemOverflow).toBe(true);
+ expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${CATEGORY}`)).toBeOnTheScreen();
+
+ // When a selected field is dropped at the last available native position.
+ const reordered = [list.data.at(1), list.data.at(2), list.data.at(0)].filter((item): item is ColumnItem => !!item);
+ act(() => list.onDragEnd?.({data: reordered}));
+ fireEvent.press(screen.getByText('common.save'));
+
+ // Then the native drop order already matches the selected section, with no selected row stranded below it.
+ expect(
+ mockNativeList.mock.calls
+ .at(-1)
+ ?.at(0)
+ ?.data.map((item) => item.value),
+ ).toEqual([MERCHANT, DATE, TOTAL_AMOUNT]);
+ expect(onSave).toHaveBeenCalledWith([MERCHANT, DATE, TOTAL_AMOUNT]);
+ const rows = screen.getAllByTestId(new RegExp(`^${CONST.BASE_LIST_ITEM_TEST_ID}`));
+ for (const [index, column] of [MERCHANT, DATE, TOTAL_AMOUNT, CATEGORY].entries()) {
+ expect(rows.at(index)).toBe(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${column}`));
+ }
+ });
+
+ it('moves a newly selected field into the draggable area and returns a deselected field to the fixed area', () => {
+ // Given an unchecked category outside the native drag area.
+ renderFields();
+
+ // When it is selected and the merchant field is then deselected.
+ fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${CATEGORY}`));
+ fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${MERCHANT}`));
+
+ // Then only the currently selected fields are native drop targets, while merchant remains available to select again.
+ expect(
+ mockNativeList.mock.calls
+ .at(-1)
+ ?.at(0)
+ ?.data.map((item) => item.value),
+ ).toEqual([TOTAL_AMOUNT, DATE, CATEGORY]);
+ expect(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${MERCHANT}`)).toBeOnTheScreen();
+ });
+
+ it('saves the reordered fields and keeps the required amount selected', () => {
+ // Given the native picker after a user starts reordering its selected fields.
+ const onSave = renderFields();
+ const list = mockNativeList.mock.calls.at(-1)?.at(0);
+ if (!list) {
+ throw new Error('Expected native expense fields');
+ }
+ const reordered = [list.data.at(2), list.data.at(0), list.data.at(1), list.data.at(3)].filter((item): item is ColumnItem => !!item);
+
+ // When the drag finishes with date above amount and the required amount row is tapped before saving.
+ act(() => list.onDragEnd?.({data: reordered}));
+ fireEvent.press(screen.getByTestId(`${CONST.BASE_LIST_ITEM_TEST_ID}${TOTAL_AMOUNT}`));
+ fireEvent.press(screen.getByText('common.save'));
+
+ // Then the chosen order reaches the shared preferences and the required field cannot be turned off.
+ expect(onSave).toHaveBeenCalledWith([DATE, TOTAL_AMOUNT, MERCHANT]);
+ });
+});
diff --git a/tests/ui/MoneyRequestViewReceiptTest.tsx b/tests/ui/MoneyRequestViewReceiptTest.tsx
index 8b38f1dc49c0..4711c215a9eb 100644
--- a/tests/ui/MoneyRequestViewReceiptTest.tsx
+++ b/tests/ui/MoneyRequestViewReceiptTest.tsx
@@ -243,7 +243,7 @@ describe('MoneyRequestView receipt visibility with a wide RHP open', () => {
await waitFor(() => {
// Anchor on an always-rendered field so the absence below reflects the hidden receipt, not an unrendered view.
- expect(screen.getByTestId('menu-item-common.merchant')).toBeOnTheScreen();
+ expect(screen.getByLabelText(/^common\.merchant(,|$)/)).toBeOnTheScreen();
expect(screen.queryByTestId('money-request-receipt-view')).not.toBeOnTheScreen();
});
});
diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx
index 14d62db5a01c..8e9110c182a2 100644
--- a/tests/ui/MoneyRequestViewTest.tsx
+++ b/tests/ui/MoneyRequestViewTest.tsx
@@ -3,6 +3,7 @@ import {act, render, screen, waitFor} from '@testing-library/react-native';
import ComposeProviders from '@components/ComposeProviders';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
import MoneyRequestView from '@components/ReportActionItem/MoneyRequestView';
+import type EditableField from '@components/ReportActionItem/MoneyRequestViewFields/EditableField';
import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -28,7 +29,7 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'
type MockMenuItemWithTopDescriptionProps = {
description?: string;
- title?: string;
+ title?: React.ReactNode;
interactive?: boolean;
descriptionTextStyle?: StyleProp;
errorText?: string;
@@ -104,6 +105,26 @@ jest.mock('@components/MenuItemWithTopDescription', () => {
return (props: MockMenuItemWithTopDescriptionProps) => mockMenuItemWithTopDescription(props);
});
+// Keep these tests focused on field visibility and edit permissions across the compound field boundary.
+jest.mock('@components/ReportActionItem/MoneyRequestViewFields/EditableField', () => {
+ const {default: ActualField} = jest.requireActual<{default: typeof EditableField}>('@components/ReportActionItem/MoneyRequestViewFields/EditableField');
+ return (props: React.ComponentProps) => {
+ const {name, value, valueComponent, onPress, error} = props;
+ if (!name.startsWith('iou.amount') && !name.startsWith('iou.attendees') && name !== 'common.description' && name !== 'common.merchant') {
+ return ;
+ }
+ return mockMenuItemWithTopDescription({description: name, title: valueComponent ?? value ?? '', interactive: !!onPress, errorText: typeof error === 'string' ? error : undefined});
+ };
+});
+
+jest.mock('@components/MenuItem/presets/MenuItemField', () => {
+ const {default: actualField} = jest.requireActual<{default: Record}>('@components/MenuItem/presets/MenuItemField');
+ return Object.assign(
+ ({name, value, onPress}: {name: string; value?: string; onPress?: unknown}) => mockMenuItemWithTopDescription({description: name, title: value ?? '', interactive: !!onPress}),
+ {...actualField},
+ );
+});
+
// Mock the legacy MenuItem (used for some fields like billable), but keep the real compound parts the migrated rows render with
jest.mock('@components/MenuItem', () => {
const RN = jest.requireActual>>('react-native');
@@ -247,6 +268,127 @@ 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}],
+ [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};
+ 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',
+ 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) => 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.
+ 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(),
@@ -318,16 +460,10 @@ describe('MoneyRequestView edit fields', () => {
renderMoneyRequestView(threadReport, {areTagsEnabled: true});
await waitForBatchedUpdatesWithAct();
- // Then each affected description keeps both breakWord and userSelectNone
+ // Then the remaining legacy tag and report descriptions keep both wrapping and selection styles.
await waitFor(() => {
const renderedRows = mockMenuItemWithTopDescription.mock.calls.map(([props]) => props);
- const affectedRows = [
- renderedRows.find(({description}) => description === longTagName),
- renderedRows.find(({description}) => description?.startsWith('iou.amount')),
- renderedRows.find(({description}) => description === 'common.description'),
- renderedRows.find(({description}) => description === 'common.merchant'),
- renderedRows.find(({description}) => description === 'common.report'),
- ];
+ const affectedRows = [renderedRows.find(({description}) => description === longTagName), renderedRows.find(({description}) => description === 'common.report')];
expect(affectedRows).not.toContain(undefined);
for (const row of affectedRows) {
diff --git a/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx b/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx
new file mode 100644
index 000000000000..8cb442173c76
--- /dev/null
+++ b/tests/unit/MoneyRequestViewAdditionalFieldTest.tsx
@@ -0,0 +1,132 @@
+import {render} from '@testing-library/react-native';
+
+import MenuItemField from '@components/MenuItem/presets/MenuItemField';
+import MoneyRequestViewAdditionalField from '@components/ReportActionItem/MoneyRequestViewAdditionalField';
+import type {SearchColumnType} from '@components/Search/types';
+
+import CONST from '@src/CONST';
+import type {Policy, Transaction} from '@src/types/onyx';
+
+import React from 'react';
+
+import createMock from '../utils/createMock';
+
+jest.mock('@components/MenuItem/presets/MenuItemField', () => 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}`}),
+}));
+
+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(MenuItemField).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({value: '', isValueSelectable: false}));
+ }
+ });
+
+ 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({value: expected, isValueSelectable: true}));
+ });
+
+ 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?.value).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?.value).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?.value).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?.value).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;
+
+ // 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({value: 'payroll-123', isValueSelectable: true}));
+ });
+});
diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts
index dc7f6aedf7dc..6a5f7a2082de 100644
--- a/tests/unit/ReportSecondaryActionUtilsTest.ts
+++ b/tests/unit/ReportSecondaryActionUtilsTest.ts
@@ -146,6 +146,36 @@ 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,
+ cardList: 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({});
@@ -5304,6 +5334,28 @@ 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,
+ cardList: 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..93bd31274375 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';
@@ -20,10 +21,18 @@ import React from 'react';
jest.mock('@components/ColumnsSettingsList', () => jest.fn(() => null));
jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({accountID: 1})));
jest.mock('@hooks/useOnyx', () => jest.fn());
-jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}}));
-jest.mock('@libs/actions/ReportLayout', () => ({setReportDetailsColumns: jest.fn()}));
+jest.mock('@expensify/react-native-hybrid-app', () => ({
+ __esModule: true,
+ default: {isHybridApp: jest.fn(() => false)},
+}));
+jest.mock('@libs/actions/ReportLayout', () => ({
+ setReportDetailsColumns: jest.fn(),
+}));
jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()}));
-jest.mock('@react-navigation/native', () => ({...jest.requireActual('@react-navigation/native'), useRoute: jest.fn()}));
+jest.mock('@react-navigation/native', () => ({
+ ...jest.requireActual('@react-navigation/native'),
+ useRoute: jest.fn(),
+}));
let mockSavedColumns: string[] | undefined;
function renderColumns(savedColumns: string[] | undefined) {
mockSavedColumns = savedColumns;
@@ -34,7 +43,11 @@ function renderColumns(savedColumns: string[] | undefined) {
}
return props;
}
-jest.mocked(useRoute).mockReturnValue({key: 'columns', name: 'columns', params: {reportID: 'report-1'}});
+jest.mocked(useRoute).mockReturnValue({
+ key: 'columns',
+ name: 'columns',
+ params: {reportID: 'report-1'},
+});
jest.mocked(useOnyx).mockImplementation((key) => {
switch (key) {
case ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS:
@@ -58,3 +71,95 @@ 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: 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.
+ 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);
+});
+
+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,
+ );
+});
+
+it('includes the required amount when an old expense preference omits it', () => {
+ // Given saved fields from before amount was mandatory.
+ jest.mocked(useRoute).mockReturnValue({
+ key: 'fields',
+ name: SCREENS.REPORT_SETTINGS.FIELDS,
+ params: {reportID: 'report-1'},
+ });
+ const props = renderColumns([CONST.SEARCH.TABLE_COLUMNS.MERCHANT]);
+ const previousCallCount = jest.mocked(setReportDetailsColumns).mock.calls.length;
+
+ // When the picker is opened and saved without editing its selection.
+ props.onSave(props.currentColumns);
+
+ // Then amount is already selected in the picker, matching the editor, without rewriting the preference.
+ expect(props.currentColumns).toEqual([CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT, CONST.SEARCH.TABLE_COLUMNS.MERCHANT]);
+ expect(setReportDetailsColumns).toHaveBeenCalledTimes(previousCallCount);
+});