diff --git a/src/App.tsx b/src/App.tsx
index e4772e4d3e42..89eaf171a773 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -31,6 +31,7 @@ import SafeArea from './components/SafeArea';
import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider';
import SidePanelContextProvider from './components/SidePanel/SidePanelContextProvider';
import SVGDefinitionsProvider from './components/SVGDefinitionsProvider';
+import {EditingCellProvider} from './components/Table/EditableCell';
import ThemeIllustrationsProvider from './components/ThemeIllustrationsProvider';
import ThemeProvider from './components/ThemeProvider';
import ThemeStylesProvider from './components/ThemeStylesContextProvider';
@@ -115,6 +116,7 @@ function App() {
FullScreenLoaderContextProvider,
ModalProvider,
SidePanelContextProvider,
+ EditingCellProvider,
]}
>
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 25207e1c2584..2391576fbe57 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -9035,6 +9035,9 @@ const CONST = {
SORTABLE_HEADER: 'Search-SortableHeader',
UNREPORTED_EXPENSE_LIST_ITEM: 'UnreportedExpenseListItem',
},
+ TABLE: {
+ EDITABLE_CELL: 'Table-EditableCell',
+ },
REPORT: {
FLOATING_MESSAGE_COUNTER: 'Report-FloatingMessageCounter',
LIST_BOUNDARY_LOADER_RETRY: 'Report-ListBoundaryLoaderRetry',
diff --git a/src/components/CategoryPicker/CategoryPickerModal.tsx b/src/components/CategoryPicker/CategoryPickerModal.tsx
new file mode 100644
index 000000000000..8c8e12a5ef9c
--- /dev/null
+++ b/src/components/CategoryPicker/CategoryPickerModal.tsx
@@ -0,0 +1,86 @@
+import React, {useRef} from 'react';
+import {View} from 'react-native';
+import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent';
+import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasuredContent/types';
+import type {ListItem} from '@components/SelectionList/types';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import CategoryPicker from '.';
+
+const popoverDimensions = {
+ width: CONST.POPOVER_DROPDOWN_WIDTH,
+ height: CONST.POPOVER_DROPDOWN_MAX_HEIGHT,
+};
+
+const DEFAULT_ANCHOR_ALIGNMENT = {
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
+};
+
+type CategoryPickerModalProps = {
+ /** Callback to close the modal */
+ onClose: () => void;
+
+ /** The policy whose categories should be shown */
+ policyID: string | undefined;
+
+ /** Currently selected category */
+ selectedCategory?: string;
+
+ /** Called when the user confirms a category selection */
+ onSelected?: (item: ListItem) => void;
+} & Omit;
+
+function CategoryPickerModal({
+ isVisible,
+ onClose,
+ anchorPosition,
+ policyID,
+ selectedCategory,
+ onSelected,
+ anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT,
+ shouldMeasureAnchorPositionFromTop = false,
+}: CategoryPickerModalProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const anchorRef = useRef(null);
+
+ const handleCategorySelect = (item: ListItem) => {
+ // If clicking the same category that's already selected, treat it as deselection
+ if (item.keyForList === selectedCategory) {
+ onSelected?.({keyForList: '', searchText: ''});
+ } else {
+ onSelected?.(item);
+ }
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default CategoryPickerModal;
diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker/index.tsx
similarity index 92%
rename from src/components/CategoryPicker.tsx
rename to src/components/CategoryPicker/index.tsx
index ddfc54cb2ea4..909a4f7a8353 100644
--- a/src/components/CategoryPicker.tsx
+++ b/src/components/CategoryPicker/index.tsx
@@ -1,4 +1,8 @@
import React from 'react';
+import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem';
+import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections';
+import type {ListItem} from '@components/SelectionList/types';
+import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
@@ -12,10 +16,6 @@ import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
-import SingleSelectListItem from './SelectionList/ListItem/SingleSelectListItem';
-import SelectionListWithSections from './SelectionList/SelectionListWithSections';
-import type {ListItem} from './SelectionList/types';
-import type {BaseTextInputRef} from './TextInput/BaseTextInput/types';
type CategoryPickerProps = {
policyID: string | undefined;
diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx
index 8164aac4823a..2558e2297d2b 100644
--- a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx
+++ b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx
@@ -22,9 +22,12 @@ type MonthPickerModalProps = {
/** Function to call when the user closes the month picker */
onClose?: () => void;
+
+ /** Whether RIGHT_DOCKED modal should keep backdrop in narrow pane context */
+ shouldEnableBackdropInNarrowPane?: boolean;
};
-function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMonthChange, onClose}: MonthPickerModalProps) {
+function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMonthChange, onClose, shouldEnableBackdropInNarrowPane = false}: MonthPickerModalProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [searchText, setSearchText] = useState('');
@@ -66,6 +69,7 @@ function MonthPickerModal({isVisible, currentMonth = new Date().getMonth(), onMo
shouldHandleNavigationBack
shouldUseCustomBackdrop
onBackdropPress={onClose}
+ shouldKeepRightDockedBackdropInNarrowPane={shouldEnableBackdropInNarrowPane}
enableEdgeToEdgeBottomSafeAreaPadding
>
void;
+
+ /** Whether RIGHT_DOCKED modal should keep backdrop in narrow pane context */
+ shouldEnableBackdropInNarrowPane?: boolean;
};
-function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear(), onYearChange, onClose}: YearPickerModalProps) {
+function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear(), onYearChange, onClose, shouldEnableBackdropInNarrowPane = false}: YearPickerModalProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [searchText, setSearchText] = useState('');
@@ -67,6 +70,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear
shouldHandleNavigationBack
shouldUseCustomBackdrop
onBackdropPress={onClose}
+ shouldKeepRightDockedBackdropInNarrowPane={shouldEnableBackdropInNarrowPane}
enableEdgeToEdgeBottomSafeAreaPadding
>
;
+
+ /** Whether Month/Year right-docked picker modals should keep backdrop in narrow pane context */
+ shouldEnableMonthYearBackdropInNarrowPane?: boolean;
};
function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) {
@@ -71,6 +74,7 @@ function CalendarPicker({
DayComponent = Day,
selectableDates,
headerContainerStyle,
+ shouldEnableMonthYearBackdropInNarrowPane = false,
}: CalendarPickerProps) {
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
@@ -424,12 +428,14 @@ function CalendarPicker({
currentYear={currentYearView}
onYearChange={onYearSelected}
onClose={() => setIsYearPickerVisible(false)}
+ shouldEnableBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane}
/>
setIsMonthPickerVisible(false)}
+ shouldEnableBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane}
/>
);
diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx
index 497e42173eb4..37db2f1190e2 100644
--- a/src/components/DatePicker/DatePickerModal.tsx
+++ b/src/components/DatePicker/DatePickerModal.tsx
@@ -13,6 +13,7 @@ const DEFAULT_ANCHOR_ORIGIN = {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
};
+
const popoverDimensions = {
height: CONST.POPOVER_DATE_MIN_HEIGHT,
width: CONST.POPOVER_DATE_WIDTH,
@@ -31,10 +32,12 @@ function DatePickerModal({
isVisible,
onClose,
anchorPosition,
+ anchorAlignment = DEFAULT_ANCHOR_ORIGIN,
onSelected,
shouldCloseWhenBrowserNavigationChanged = false,
shouldPositionFromTop = false,
forwardedFSClass,
+ shouldEnableMonthYearBackdropInNarrowPane = false,
}: DatePickerProps) {
const [selectedDate, setSelectedDate] = useState(value ?? defaultValue ?? undefined);
const anchorRef = useRef(null);
@@ -69,7 +72,8 @@ function DatePickerModal({
popoverDimensions={popoverDimensions}
shouldCloseWhenBrowserNavigationChanged={shouldCloseWhenBrowserNavigationChanged}
innerContainerStyle={isSmallScreenWidth ? styles.w100 : {width: CONST.POPOVER_DATE_WIDTH}}
- anchorAlignment={DEFAULT_ANCHOR_ORIGIN}
+ anchorAlignment={anchorAlignment}
+ restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE}
shouldSwitchPositionIfOverflow
shouldReturnFocus={false}
shouldMeasureAnchorPositionFromTop={shouldPositionFromTop}
@@ -82,6 +86,7 @@ function DatePickerModal({
maxDate={maxDate}
value={selectedDate}
onSelected={handleDateSelection}
+ shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane}
/>
);
diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts
index dceffd7f6c1e..17ed94e87873 100644
--- a/src/components/DatePicker/types.ts
+++ b/src/components/DatePicker/types.ts
@@ -35,6 +35,12 @@ type DatePickerBaseProps = ForwardedFSClassProps & {
/** ID of the wrapping form */
formID?: keyof OnyxFormValuesMapping;
+
+ /**
+ * Whether Month/Year right-docked picker modals should keep backdrop in narrow pane context.
+ * Used by inline editing flows that require background dimming.
+ */
+ shouldEnableMonthYearBackdropInNarrowPane?: boolean;
};
type DatePickerModalProps = DatePickerBaseProps & {
@@ -103,6 +109,12 @@ type DatePickerProps = {
/** If the popover will be positioned from the top */
shouldPositionFromTop?: boolean;
+
+ /**
+ * Whether Month/Year right-docked picker modals should keep backdrop in narrow pane context.
+ * Used by inline editing flows that require background dimming.
+ */
+ shouldEnableMonthYearBackdropInNarrowPane?: boolean;
} & Omit;
export type {DatePickerBaseProps, DatePickerModalProps, DateInputWithPickerProps, DatePickerProps};
diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx
index 3663405363d9..cbe1ce04f432 100644
--- a/src/components/Modal/BaseModal.tsx
+++ b/src/components/Modal/BaseModal.tsx
@@ -73,6 +73,7 @@ function BaseModal({
forwardedFSClass = CONST.FULLSTORY.CLASS.UNMASK,
ref,
shouldDisplayBelowModals = false,
+ shouldKeepRightDockedBackdropInNarrowPane = false,
shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode = true,
}: BaseModalProps) {
// When the `enableEdgeToEdgeBottomSafeAreaPadding` prop is explicitly set, we enable edge-to-edge mode.
@@ -305,8 +306,10 @@ function BaseModal({
const {originalValues} = useContext(ScreenWrapperOfflineIndicatorContext);
const offlineIndicatorContextValue = useMemo(() => (isInNarrowPane ? (originalValues ?? {}) : {}), [isInNarrowPane, originalValues]);
+ const shouldSuppressRightDockedBackdrop =
+ type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED && !isSmallScreenWidth && (isInNarrowPane || isInNarrowPaneModal) && !shouldKeepRightDockedBackdropInNarrowPane;
const backdropOpacityAdjusted =
- hideBackdrop || (type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED && !isSmallScreenWidth && (isInNarrowPane || isInNarrowPaneModal)) // right_docked modals shouldn't add backdrops when opened in same-width RHP
+ hideBackdrop || shouldSuppressRightDockedBackdrop // right_docked modals shouldn't add backdrops when opened in same-width RHP
? 0
: backdropOpacity;
diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts
index e13c7b82020f..66c2633d3e9b 100644
--- a/src/components/Modal/types.ts
+++ b/src/components/Modal/types.ts
@@ -125,6 +125,12 @@ type BaseModalProps = Partial &
*/
shouldDisplayBelowModals?: boolean;
+ /**
+ * Whether a RIGHT_DOCKED modal should keep its backdrop when rendered in a narrow pane context.
+ * See https://github.com/Expensify/App/issues/88645 for more details.
+ */
+ shouldKeepRightDockedBackdropInNarrowPane?: boolean;
+
/**
* Whether the modal should wrap the children in a scroll view if it is a bottom docked modal in landscape mode.
* Defaults to true.
diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx
index ca5a41743f9f..2017856e8fec 100644
--- a/src/components/MoneyRequestAmountInput.tsx
+++ b/src/components/MoneyRequestAmountInput.tsx
@@ -99,6 +99,9 @@ type MoneyRequestAmountInputProps = {
/** Whether to allow direct negative input (for split amounts where value is already negative) */
allowNegativeInput?: boolean;
+ /** Style for the negative symbol */
+ negativeSymbolStyle?: StyleProp;
+
/** The testID of the input. Used to locate this view in end-to-end tests. */
testID?: string;
@@ -124,6 +127,15 @@ type MoneyRequestAmountInputProps = {
*/
shouldWrapInputInContainer?: boolean;
+ /** Style applied to the outer ScrollView inside NumberWithSymbolForm */
+ scrollViewStyle?: StyleProp;
+
+ /**
+ * Whether to refocus the input when clicking on the ScrollView empty space.
+ * Prevents focus loss when clicking empty space left of the right-aligned input.
+ */
+ shouldRefocusOnScrollViewClick?: boolean;
+
/** Whether the input is disabled or not */
disabled?: boolean;
@@ -132,7 +144,7 @@ type MoneyRequestAmountInputProps = {
/** Determines which keyboard to open */
keyboardType?: KeyboardTypeOptions;
-} & Pick;
+} & Pick;
type Selection = {
start: number;
@@ -167,9 +179,12 @@ function MoneyRequestAmountInput({
shouldApplyPaddingToContainer = false,
shouldUseDefaultLineHeightForPrefix = true,
shouldWrapInputInContainer = true,
+ scrollViewStyle,
+ shouldRefocusOnScrollViewClick = false,
isNegative = false,
allowFlippingAmount = false,
allowNegativeInput = false,
+ negativeSymbolStyle,
toggleNegative,
clearNegative,
ref,
@@ -245,6 +260,7 @@ function MoneyRequestAmountInput({
currency={currency}
hideSymbol={hideCurrencySymbol}
isSymbolPressable={isCurrencyPressable}
+ symbolTextStyle={props.symbolTextStyle}
shouldShowBigNumberPad={shouldShowBigNumberPad}
style={inputStyle}
autoGrow={autoGrow}
@@ -254,12 +270,15 @@ function MoneyRequestAmountInput({
shouldApplyPaddingToContainer={shouldApplyPaddingToContainer}
shouldUseDefaultLineHeightForPrefix={shouldUseDefaultLineHeightForPrefix}
shouldWrapInputInContainer={shouldWrapInputInContainer}
+ scrollViewStyle={scrollViewStyle}
+ shouldRefocusOnScrollViewClick={shouldRefocusOnScrollViewClick}
containerStyle={props.containerStyle}
prefixStyle={props.prefixStyle}
prefixContainerStyle={props.prefixContainerStyle}
touchableInputWrapperStyle={props.touchableInputWrapperStyle}
contentWidth={contentWidth}
isNegative={isNegative}
+ negativeSymbolStyle={negativeSymbolStyle}
testID={testID}
errorText={props.errorText}
footer={props.footer}
diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx
index f6bc2174070b..6ed8a8ce984e 100644
--- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx
+++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx
@@ -5,6 +5,7 @@ import {getButtonRole} from '@components/Button/utils';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import {PressableWithFeedback} from '@components/Pressable';
import type {SearchColumnType, TableColumnSize} from '@components/Search/types';
+import {useEditingCellState} from '@components/Table/EditableCell';
import TransactionItemRow from '@components/TransactionItemRow';
import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useLocalize from '@hooks/useLocalize';
@@ -13,6 +14,7 @@ import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import useTransactionInlineEdit from '@hooks/useTransactionInlineEdit';
import useTransactionViolations from '@hooks/useTransactionViolations';
import ControlSelection from '@libs/ControlSelection';
import canUseTouchScreen from '@libs/DeviceCapabilities/canUseTouchScreen';
@@ -102,6 +104,8 @@ function MoneyRequestReportTransactionItem({
const {translate} = useLocalize();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ const {isEditingCell} = useEditingCellState();
+
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout();
const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP();
@@ -111,6 +115,22 @@ function MoneyRequestReportTransactionItem({
// Filter violations based on user visibility and dismissal state at the row level.
const filteredViolations = useTransactionViolations(transaction.transactionID);
+ const {
+ canEditDate,
+ canEditMerchant,
+ canEditDescription,
+ canEditCategory,
+ canEditAmount,
+ canEditTag,
+ onEditDate,
+ onEditMerchant,
+ onEditDescription,
+ onEditCategory,
+ onEditAmount,
+ onEditTag,
+ wasEditingOnMouseDownRef,
+ } = useTransactionInlineEdit({transactionID: transaction.transactionID, reportID: transaction.reportID});
+
const viewRef = useRef(null);
// This useEffect scrolls to this transaction when it is newly added to the report
@@ -139,6 +159,17 @@ function MoneyRequestReportTransactionItem({
{
+ // Prevent row press from firing while a cell is being inline-edited (e.g. pressing Space would otherwise open the expense)
+ // See https://github.com/Expensify/App/issues/88646 for more details
+ if (isEditingCell) {
+ return;
+ }
+ // If a cell was being edited when the user tapped the row, suppress navigation
+ // so the second tap doesn't immediately open the transaction detail.
+ if (wasEditingOnMouseDownRef.current) {
+ wasEditingOnMouseDownRef.current = false;
+ return;
+ }
handleOnPress(transaction.transactionID);
}}
accessibilityLabel={translate('iou.viewDetails')}
@@ -149,7 +180,12 @@ function MoneyRequestReportTransactionItem({
style={[styles.transactionListItemStyle, !shouldUseNarrowLayout ? StyleUtils.getSearchTableRowPressableStyle(isLastItem, isSelected) : styles.noBorderRadius]}
hoverStyle={[!isPendingDelete && styles.hoveredComponentBG, isSelected && styles.activeComponentBG]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
- onPressIn={() => canUseTouchScreen() && ControlSelection.block()}
+ onPressIn={() => {
+ wasEditingOnMouseDownRef.current = isEditingCell;
+ if (canUseTouchScreen()) {
+ ControlSelection.block();
+ }
+ }}
onPressOut={() => ControlSelection.unblock()}
onLongPress={() => {
handleLongPress(transaction.transactionID);
@@ -182,6 +218,18 @@ function MoneyRequestReportTransactionItem({
isHover={hovered}
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
shouldRemoveTotalColumnFlex={hasFlexColumn(columns)}
+ canEditDate={canEditDate}
+ canEditMerchant={canEditMerchant}
+ canEditDescription={canEditDescription}
+ canEditCategory={canEditCategory}
+ canEditAmount={canEditAmount}
+ canEditTag={canEditTag}
+ onEditDate={onEditDate}
+ onEditMerchant={onEditMerchant}
+ onEditDescription={onEditDescription}
+ onEditCategory={onEditCategory}
+ onEditAmount={onEditAmount}
+ onEditTag={onEditTag}
/>
)}
diff --git a/src/components/NumberWithSymbolForm.tsx b/src/components/NumberWithSymbolForm.tsx
index 25cdc0c73bd3..5d2bd8044cc7 100644
--- a/src/components/NumberWithSymbolForm.tsx
+++ b/src/components/NumberWithSymbolForm.tsx
@@ -1,7 +1,7 @@
import {useIsFocused} from '@react-navigation/native';
import type {ForwardedRef} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
-import type {KeyboardTypeOptions, NativeSyntheticEvent} from 'react-native';
+import type {KeyboardTypeOptions, NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -69,6 +69,12 @@ type NumberWithSymbolFormProps = {
/** Whether to wrap the input in a container */
shouldWrapInputInContainer?: boolean;
+ /** Style applied to the outer ScrollView */
+ scrollViewStyle?: StyleProp;
+
+ /** Whether to refocus the input when clicking on the ScrollView empty space */
+ shouldRefocusOnScrollViewClick?: boolean;
+
/** Whether the amount is negative */
isNegative?: boolean;
@@ -84,6 +90,9 @@ type NumberWithSymbolFormProps = {
/** Whether to allow direct negative input (for split amounts where value is already negative) */
allowNegativeInput?: boolean;
+ /** Style for the negative symbol */
+ negativeSymbolStyle?: StyleProp;
+
/** Whether to use dynamic font size for the amount input */
shouldUseDynamicFontSize?: boolean;
@@ -171,9 +180,12 @@ function NumberWithSymbolForm({
shouldApplyPaddingToContainer = false,
shouldUseDefaultLineHeightForPrefix = true,
shouldWrapInputInContainer = true,
+ scrollViewStyle,
+ shouldRefocusOnScrollViewClick = false,
isNegative = false,
allowFlippingAmount = false,
allowNegativeInput = false,
+ negativeSymbolStyle,
toggleNegative,
clearNegative,
ref,
@@ -569,6 +581,7 @@ function NumberWithSymbolForm({
prefixContainerStyle={props.prefixContainerStyle}
touchableInputWrapperStyle={props.touchableInputWrapperStyle}
isNegative={isNegative}
+ negativeSymbolStyle={negativeSymbolStyle}
toggleNegative={toggleNegative}
onFocus={props.onFocus}
accessibilityLabel={props.accessibilityLabel}
@@ -644,8 +657,20 @@ function NumberWithSymbolForm({
return (
{
+ if (!shouldRefocusOnScrollViewClick) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ textInput.current?.focus();
+ }}
>
{shouldWrapInputInContainer ? (
diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx
index e0904e5441c4..6b366d6adaab 100644
--- a/src/components/Search/SearchContext.tsx
+++ b/src/components/Search/SearchContext.tsx
@@ -51,6 +51,7 @@ const defaultSearchContextData: SearchContextData = {
isOnSearch: false,
shouldTurnOffSelectionMode: false,
shouldResetSearchQuery: false,
+ hasSelectedTransactions: false,
currentSearchHash: -1,
currentSimilarSearchHash: -1,
suggestedSearches: {} as Record,
@@ -327,6 +328,7 @@ function SearchContextProvider({children}: SearchContextProps) {
lastSearchType,
shouldShowSelectAllMatchingItems,
areAllMatchingItemsSelected,
+ hasSelectedTransactions: searchContextData.selectedTransactionIDs.length > 0 || Object.values(searchContextData.selectedTransactions).some((t) => t.isSelected),
currentSearchQueryJSON,
};
diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx
index 45526e8a5eb2..d03d720f3bb6 100644
--- a/src/components/Search/SearchList/BaseSearchList/index.tsx
+++ b/src/components/Search/SearchList/BaseSearchList/index.tsx
@@ -1,10 +1,11 @@
import {useIsFocused} from '@react-navigation/native';
import {FlashList} from '@shopify/flash-list';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
-import type {NativeSyntheticEvent} from 'react-native';
+import type {GestureResponderEvent, NativeSyntheticEvent} from 'react-native';
import Animated from 'react-native-reanimated';
import type {SearchListItem} from '@components/Search/SearchList/ListItem/types';
import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types';
+import {useEditingCellState} from '@components/Table/EditableCell';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import {isMobileChrome} from '@libs/Browser';
@@ -37,6 +38,7 @@ function BaseSearchList({
}: BaseSearchListProps) {
const hasKeyBeenPressed = useRef(false);
const isFocused = useIsFocused();
+ const {focusedCellId, isEditingCell} = useEditingCellState();
const setHasKeyBeenPressed = useCallback(() => {
if (hasKeyBeenPressed.current) {
@@ -82,22 +84,39 @@ function BaseSearchList({
[focusedIndex, renderItem, setFocusedIndex],
);
- const selectFocusedOption = useCallback(() => {
- const focusedItem = data.at(focusedIndex);
+ const selectFocusedOption = useCallback(
+ (event?: GestureResponderEvent | KeyboardEvent) => {
+ // Allow event propagation during cell editing so Enter can trigger TextInput.onSubmitEditing.
+ // When not editing, stop propagation to prevent unintended button activation and handle row selection.
+ if (isEditingCell) {
+ return;
+ }
- if (!focusedItem) {
- return;
- }
+ // If a cell has keyboard focus (via Tab), let the Enter event propagate to trigger the cell's onPress
+ if (focusedCellId) {
+ return;
+ }
+
+ event?.stopPropagation();
+
+ const focusedItem = data.at(focusedIndex);
- onSelectRow(focusedItem);
- }, [data, focusedIndex, onSelectRow]);
+ if (!focusedItem) {
+ return;
+ }
+
+ onSelectRow(focusedItem);
+ },
+ [data, focusedCellId, focusedIndex, isEditingCell, onSelectRow],
+ );
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
captureOnInputs: true,
shouldBubble: false,
shouldPreventDefault: false,
isActive: isFocused && focusedIndex >= 0,
- shouldStopPropagation: true,
+ // Propagation is controlled manually in selectFocusedOption based on editing state
+ shouldStopPropagation: false,
});
useEffect(() => {
diff --git a/src/components/Search/SearchList/ListItem/DateCell.tsx b/src/components/Search/SearchList/ListItem/DateCell.tsx
index 2db0a8278f64..5cb143f4c150 100644
--- a/src/components/Search/SearchList/ListItem/DateCell.tsx
+++ b/src/components/Search/SearchList/ListItem/DateCell.tsx
@@ -1,5 +1,9 @@
import React from 'react';
+import DatePickerModal from '@components/DatePicker/DatePickerModal';
+import {EditableCell, usePopoverEditState} from '@components/Table/EditableCell';
+import type {EditableProps} from '@components/Table/EditableCell/types';
import TextWithTooltip from '@components/TextWithTooltip';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import DateUtils from '@libs/DateUtils';
import CONST from '@src/CONST';
@@ -9,21 +13,57 @@ type DateCellProps = {
showTooltip: boolean;
isLargeScreenWidth: boolean;
suffixText?: string;
-};
+} & EditableProps;
-function DateCell({date, showTooltip, isLargeScreenWidth, suffixText}: DateCellProps) {
+function DateCell({date, showTooltip, isLargeScreenWidth, suffixText, canEdit, onSave}: DateCellProps) {
const styles = useThemeStyles();
+ const {isInNarrowPaneModal} = useResponsiveLayout();
+ const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing} = usePopoverEditState({canEdit});
const formattedDate = DateUtils.formatWithUTCTimeZone(date, DateUtils.doesDateBelongToAPastYear(date) ? CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT : CONST.DATE.MONTH_DAY_ABBR_FORMAT);
const displayText = suffixText ? `${formattedDate} • ${suffixText}` : formattedDate;
- return (
+ const handleDateSelected = (newDate: string) => {
+ onSave?.(newDate);
+ cancelEditing();
+ };
+
+ const displayContent = (
);
+
+ return (
+
+ }
+ >
+ {displayContent}
+
+ );
}
export default DateCell;
diff --git a/src/components/Search/SearchList/ListItem/TransactionListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionListItem.tsx
index 7d7ec7af397f..3483e2075d73 100644
--- a/src/components/Search/SearchList/ListItem/TransactionListItem.tsx
+++ b/src/components/Search/SearchList/ListItem/TransactionListItem.tsx
@@ -2,7 +2,7 @@
// SearchStaticList (src/components/Search/SearchStaticList.tsx) used for fast
// perceived performance. If you change the narrow-layout UI here, verify the
// static version still looks visually identical.
-import React, {useRef} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
import type {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
// Use the original useOnyx hook to get the real-time data from Onyx and not from the snapshot
@@ -14,6 +14,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import {useSearchStateContext} from '@components/Search/SearchContext';
import type {ListItem} from '@components/SelectionList/types';
+import {useEditingCellState} from '@components/Table/EditableCell';
import TransactionItemRow from '@components/TransactionItemRow';
import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
@@ -23,13 +24,14 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useSyncFocus from '@hooks/useSyncFocus';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import useTransactionInlineEdit from '@hooks/useTransactionInlineEdit';
import type {TransactionPreviewData} from '@libs/actions/Search';
import {handleActionButtonPress as handleActionButtonPressUtil} from '@libs/actions/Search';
import {syncMissingAttendeesViolation} from '@libs/AttendeeUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils';
import {isInvoiceReport} from '@libs/ReportUtils';
-import {isDeletedTransaction as isDeletedTransactionUtil, isViolationDismissed, mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils';
+import {isDeletedTransaction as isDeletedTransactionUtil, isExpenseUnreported, isViolationDismissed, mergeProhibitedViolations, shouldShowViolation} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {isActionLoadingSelector} from '@src/selectors/ReportMetaData';
@@ -79,11 +81,13 @@ function TransactionListItem({
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED);
- // Use report's policyID as fallback when transaction doesn't have policyID directly
- // Use active policy as final fallback for SelfDM (tracking expenses)
- // NOTE: Using || instead of ?? to treat empty string "" as falsy
+ const [parentReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionItem.reportID)}`);
+ const [transactionThreadReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionItem?.reportAction?.childReportID}`);
+ const [transaction] = originalUseOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionItem.transactionID)}`);
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- const policyID = transactionItem.policyID || snapshotReport?.policyID || activePolicyID;
+ const transactionPolicyID = transactionItem.policyID || snapshotReport?.policyID;
+ const policyID = isExpenseUnreported(transaction) ? activePolicyID : transactionPolicyID;
const [parentPolicy] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`);
const snapshotPolicy = (currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.POLICY}${transactionItem.policyID}`] ?? {}) as Policy;
@@ -93,9 +97,6 @@ function TransactionListItem({
// Fetch policy categories directly from Onyx since they are not included in the search snapshot
const [policyCategories] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`);
- const [parentReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionItem.reportID)}`);
- const [transactionThreadReport] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionItem?.reportAction?.childReportID}`);
- const [transaction] = originalUseOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(transactionItem.transactionID)}`);
const parentReportActionSelector = (reportActions: OnyxEntry): OnyxEntry => reportActions?.[`${transactionItem?.reportAction?.reportActionID}`];
const [parentReportAction] = originalUseOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(transactionItem.reportID)}`, {selector: parentReportActionSelector}, [
transactionItem,
@@ -174,6 +175,70 @@ function TransactionListItem({
const {isDelegateAccessRestricted} = useDelegateNoAccessState();
const {showDelegateNoAccessModal} = useDelegateNoAccessActions();
+ const transactionID = transactionItem.transactionID;
+
+ const {isEditingCell, wasRecentlyEditingCell} = useEditingCellState();
+ const [shouldDisableHoverStyle, setShouldDisableHoverStyle] = useState(false);
+
+ // When a popover is opened during inline editing, onHoverOut never fires after editing ends, leaving the hover style stuck.
+ // Disable it until the next intentional hover (onHoverIn).
+ // See: https://github.com/Expensify/App/pull/83127#issuecomment-4114490080
+ useEffect(() => {
+ if (!wasRecentlyEditingCell) {
+ return;
+ }
+ queueMicrotask(() => setShouldDisableHoverStyle(true));
+ }, [wasRecentlyEditingCell]);
+
+ const {
+ canEditDate,
+ canEditMerchant,
+ canEditDescription,
+ canEditCategory,
+ canEditAmount,
+ canEditTag,
+ onEditDate,
+ onEditMerchant,
+ onEditDescription,
+ onEditCategory,
+ onEditAmount,
+ onEditTag,
+ wasEditingOnMouseDownRef,
+ } = useTransactionInlineEdit({
+ transactionID,
+ reportID: transactionItem.reportID,
+ reportActionID: transactionItem.reportAction?.reportActionID,
+ parentReportAction,
+ hash: currentSearchHash,
+ fallbackReport: snapshotReport,
+ });
+
+ const handleOnPress = () => {
+ // Consume the tap that dismissed an editing cell — a second tap will open the row.
+ // We check the ref rather than isEditingCell because blur fires before onPress and resets the state.
+ if (wasEditingOnMouseDownRef.current) {
+ wasEditingOnMouseDownRef.current = false;
+ return;
+ }
+ // react-native-web fires onPress on Space for role="button" elements; suppress it while a cell is being edited.
+ if (isEditingCell) {
+ return;
+ }
+ if (isDeletedTransaction && !canSelectMultiple) {
+ return;
+ }
+ onSelectRow(item, transactionPreviewData);
+ };
+
+ const handleOnMouseDown = (e?: React.MouseEvent) => {
+ wasEditingOnMouseDownRef.current = isEditingCell;
+
+ // Skip preventDefault when editing so the browser naturally blurs the input (triggering save/cancel).
+ if (!isEditingCell) {
+ e?.preventDefault();
+ }
+ };
+
const handleActionButtonPress = () => {
handleActionButtonPressUtil({
hash: currentSearchHash,
@@ -203,13 +268,14 @@ function TransactionListItem({
onLongPressRow?.(item)}
- onPress={isDeletedTransaction && !canSelectMultiple ? undefined : () => onSelectRow(item, transactionPreviewData)}
+ onPress={handleOnPress}
disabled={isDisabled && !item.isSelected}
accessibilityLabel={item.text ?? ''}
role={!isDeletedTransaction ? getButtonRole(true) : 'none'}
isNested
- onMouseDown={(e) => e.preventDefault()}
- hoverStyle={[!item.isDisabled && styles.hoveredComponentBG, item.isSelected && styles.activeComponentBG]}
+ onMouseDown={handleOnMouseDown}
+ onHoverIn={() => setShouldDisableHoverStyle(false)}
+ hoverStyle={[!item.isDisabled && !shouldDisableHoverStyle && styles.hoveredComponentBG, item.isSelected && styles.activeComponentBG]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true, [CONST.INNER_BOX_SHADOW_ELEMENT]: false}}
id={item.keyForList ?? ''}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_LIST_ITEM}
@@ -276,6 +342,18 @@ function TransactionListItem({
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
reportActions={exportedReportActions}
policyForMovingExpenses={policyForMovingExpenses}
+ onEditDate={onEditDate}
+ onEditMerchant={onEditMerchant}
+ onEditDescription={onEditDescription}
+ onEditCategory={onEditCategory}
+ onEditAmount={onEditAmount}
+ onEditTag={onEditTag}
+ canEditDate={canEditDate}
+ canEditMerchant={canEditMerchant}
+ canEditDescription={canEditDescription}
+ canEditCategory={canEditCategory}
+ canEditAmount={canEditAmount}
+ canEditTag={canEditTag}
/>
>
)}
diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx
index df4cff50cd2f..6891297241e2 100644
--- a/src/components/Search/SearchList/index.tsx
+++ b/src/components/Search/SearchList/index.tsx
@@ -15,6 +15,7 @@ import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import ScrollView from '@components/ScrollView';
import type {SearchColumnType, SearchGroupBy, SearchQueryJSON, SelectedTransactions} from '@components/Search/types';
import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types';
+import {useEditingCellState} from '@components/Table/EditableCell';
import Text from '@components/Text';
import useKeyboardState from '@hooks/useKeyboardState';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -303,6 +304,7 @@ function SearchList({
const hasItemsBeingRemoved = prevDataLength && prevDataLength > data.length;
const personalDetails = usePersonalDetails();
+ const {isEditingCell, wasRecentlyEditingCell} = useEditingCellState();
const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID);
const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD);
@@ -397,9 +399,16 @@ function SearchList({
return;
}
+ // Don't scroll while a cell is being edited
+ // as it can cause unwanted scrolling when the edit is dismissed
+ // See: https://github.com/Expensify/App/pull/83127#issuecomment-4064533155
+ if (isEditingCell || wasRecentlyEditingCell) {
+ return;
+ }
+
listRef.current.scrollToIndex({index, animated, viewOffset: -variables.contentHeaderHeight});
},
- [data],
+ [data, isEditingCell, wasRecentlyEditingCell],
);
useFocusEffect(
diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx
index d6af247daad3..c89ffe3a2f02 100644
--- a/src/components/Search/SearchTableHeader.tsx
+++ b/src/components/Search/SearchTableHeader.tsx
@@ -15,6 +15,7 @@ type SearchColumnConfig = {
translationKey: TranslationPaths | undefined;
icon?: IconAsset;
isColumnSortable?: boolean;
+ canEdit?: boolean;
};
type SearchHeaderIcons = {
@@ -37,6 +38,7 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [
{
columnName: CONST.SEARCH.TABLE_COLUMNS.DATE,
translationKey: 'common.date',
+ canEdit: true,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.POSTED,
@@ -57,10 +59,12 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [
{
columnName: CONST.SEARCH.TABLE_COLUMNS.MERCHANT,
translationKey: 'common.merchant',
+ canEdit: true,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION,
translationKey: 'common.description',
+ canEdit: true,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.FROM,
@@ -81,6 +85,7 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [
{
columnName: CONST.SEARCH.TABLE_COLUMNS.CATEGORY,
translationKey: 'common.category',
+ canEdit: true,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.ATTENDEES,
@@ -93,6 +98,7 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [
{
columnName: CONST.SEARCH.TABLE_COLUMNS.TAG,
translationKey: 'common.tag',
+ canEdit: true,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE,
@@ -129,6 +135,7 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [
{
columnName: CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT,
translationKey: groupBy ? 'common.total' : 'iou.amount',
+ canEdit: true,
},
{
columnName: CONST.SEARCH.TABLE_COLUMNS.BASE_62_REPORT_ID,
diff --git a/src/components/Search/SortableTableHeader.tsx b/src/components/Search/SortableTableHeader.tsx
index a86a7e3e0a09..93b76d510764 100644
--- a/src/components/Search/SortableTableHeader.tsx
+++ b/src/components/Search/SortableTableHeader.tsx
@@ -16,6 +16,7 @@ type ColumnConfig = {
icon?: IconAsset;
isColumnSortable?: boolean;
canBeMissing?: boolean;
+ canEdit?: boolean;
};
type SearchTableHeaderProps = {
@@ -64,7 +65,7 @@ function SortableTableHeader({
return (
- {columns.map(({columnName, translationKey, icon, isColumnSortable}) => {
+ {columns.map(({columnName, translationKey, icon, isColumnSortable, canEdit}) => {
if (!shouldShowColumn(columnName)) {
return null;
}
@@ -83,6 +84,7 @@ function SortableTableHeader({
isActive={isActive}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.SORTABLE_HEADER}
containerStyle={[
+ canEdit && styles.editableCellHeader,
StyleUtils.getReportTableColumnStyles(columnName, {
isDateColumnWide: dateColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE,
isSubmittedColumnWide: submittedColumnSize === CONST.SEARCH.TABLE_COLUMN_SIZES.WIDE,
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 838d9c0bc667..c6745539277f 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -181,6 +181,8 @@ type SearchContextData = {
isOnSearch: boolean;
shouldTurnOffSelectionMode: boolean;
shouldResetSearchQuery: boolean;
+ /** True when at least one transaction is selected. */
+ hasSelectedTransactions: boolean;
};
type SearchStateContextValue = SearchContextData & {
diff --git a/src/components/Table/EditableCell/EditableCell.tsx b/src/components/Table/EditableCell/EditableCell.tsx
new file mode 100644
index 000000000000..5c822a769e01
--- /dev/null
+++ b/src/components/Table/EditableCell/EditableCell.tsx
@@ -0,0 +1,114 @@
+import React, {useEffect, useId} from 'react';
+import type {ReactNode, RefObject} from 'react';
+import {View} from 'react-native';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import {useEditingCellActions} from './EditingCellContext';
+
+type EditableCellProps = {
+ /** Content to display when not editing */
+ children: ReactNode;
+
+ /** Content to display when editing (inline replacement like TextInput). If omitted while isEditing, children are shown with an active border (popover mode). */
+ editContent?: ReactNode;
+
+ /** Popover or modal rendered as a sibling to the cell (e.g. date picker, category picker). Rendered for all edit-capable branches. */
+ popoverContent?: ReactNode;
+
+ /** Whether the cell is currently in editing mode */
+ isEditing: boolean;
+
+ /**
+ * Whether editing is currently permitted.
+ * Only meaningful when the layout supports editing. When false the styled container is still rendered (maintaining layout
+ * consistency) but the pressable is disabled and editing cannot be triggered.
+ */
+ canEdit?: boolean;
+
+ /** Callback when edit mode should be activated */
+ onStartEditing: () => void;
+
+ /** Ref attached to the cell wrapper — used as popover anchor for date/category pickers */
+ anchorRef?: RefObject;
+};
+
+/**
+ * A stateless wrapper that handles hover highlight + click-to-edit for table cells.
+ * Does not manage editing state — the consumer controls that via hooks.
+ *
+ * Modes:
+ * 1. isEditable=false → renders children as-is (narrow/mobile layout — no container needed)
+ * 2. isEditing + editContent → replaces children with editContent (inline edit)
+ * 3. isEditing + no editContent → shows children with active border (popover edit)
+ * 4. canEdit=false → styled container View, no pressable (transient: loading / no permission)
+ * 5. default → PressableWithFeedback (hover border, click triggers edit)
+ */
+function EditableCell({children, editContent, popoverContent, isEditing, canEdit, onStartEditing, anchorRef}: EditableCellProps) {
+ const styles = useThemeStyles();
+ const {isLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP();
+ const isEditable = isLargeScreenWidth && !shouldUseNarrowLayout;
+ const cellId = useId();
+ const {setIsEditingCell, setFocusedCellId} = useEditingCellActions();
+
+ useEffect(() => {
+ if (!isEditable || !isEditing) {
+ return;
+ }
+ setIsEditingCell(true);
+ return () => setIsEditingCell(false);
+ }, [isEditing, isEditable, setIsEditingCell]);
+
+ // Architectural exclusion (e.g. narrow layout) — no container, no padding.
+ if (!isEditable) {
+ return children;
+ }
+
+ // Inline edit mode: replace display content with the edit input
+ if (isEditing && editContent) {
+ return {editContent};
+ }
+
+ // Popover edit mode: keep showing display content with active border
+ if (isEditing && popoverContent) {
+ return (
+ <>
+
+ {children}
+
+ {popoverContent}
+ >
+ );
+ }
+
+ // Transient non-editable state (loading, permissions pending): render the container for layout
+ // consistency but skip the pressable so the user cannot trigger edit mode.
+ if (!canEdit) {
+ return {children};
+ }
+
+ return (
+ setFocusedCellId(cellId)}
+ onBlur={() => setFocusedCellId(null)}
+ style={styles.editableCell}
+ wrapperStyle={styles.w100}
+ focusStyle={styles.editableCellFocus}
+ hoverStyle={styles.editableCellHover}
+ >
+ {children}
+
+ );
+}
+
+EditableCell.displayName = 'EditableCell';
+
+export default EditableCell;
diff --git a/src/components/Table/EditableCell/EditingCellContext.tsx b/src/components/Table/EditableCell/EditingCellContext.tsx
new file mode 100644
index 000000000000..87b0e75246b3
--- /dev/null
+++ b/src/components/Table/EditableCell/EditingCellContext.tsx
@@ -0,0 +1,83 @@
+import React, {useCallback, useContext, useState} from 'react';
+import type {ReactNode} from 'react';
+
+/**
+ * Context for managing inline editable cell state across the component tree.
+ * Tracks whether a cell is currently being edited and which cell has keyboard focus.
+ */
+
+type EditingCellStateContextType = {
+ isEditingCell: boolean;
+ wasRecentlyEditingCell: boolean;
+ focusedCellId: string | null;
+};
+
+type EditingCellActionsContextType = {
+ setIsEditingCell: (value: boolean) => void;
+ setFocusedCellId: (cellId: string | null) => void;
+};
+
+const defaultEditingCellActionsContextValue: EditingCellActionsContextType = {
+ setIsEditingCell: () => {},
+ setFocusedCellId: () => {},
+};
+
+const EditingCellStateContext = React.createContext({
+ isEditingCell: false,
+ wasRecentlyEditingCell: false,
+ focusedCellId: null,
+});
+
+const EditingCellActionsContext = React.createContext(defaultEditingCellActionsContextValue);
+
+type EditingCellProviderProps = {
+ children: ReactNode;
+};
+
+function EditingCellProvider({children}: EditingCellProviderProps) {
+ const [isEditingCell, setIsEditingCellState] = useState(false);
+ const [wasRecentlyEditingCell, setWasRecentlyEditingCell] = useState(false);
+ const [focusedCellId, setFocusedCellId] = useState(null);
+
+ const setIsEditingCell = useCallback((value: boolean) => {
+ setIsEditingCellState(value);
+
+ if (!value) {
+ setWasRecentlyEditingCell(true);
+
+ // Clear the flag after one frame (after focus effects have run)
+ requestAnimationFrame(() => {
+ setWasRecentlyEditingCell(false);
+ });
+ }
+ }, []);
+
+ const stateValue = {
+ isEditingCell,
+ wasRecentlyEditingCell,
+ focusedCellId,
+ };
+
+ const actionsValue = {
+ setIsEditingCell,
+ setFocusedCellId,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+function useEditingCellState(): EditingCellStateContextType {
+ return useContext(EditingCellStateContext);
+}
+
+function useEditingCellActions(): EditingCellActionsContextType {
+ return useContext(EditingCellActionsContext);
+}
+
+export default EditingCellProvider;
+export {EditingCellActionsContext, EditingCellStateContext, useEditingCellActions, useEditingCellState};
+export type {EditingCellActionsContextType, EditingCellStateContextType};
diff --git a/src/components/Table/EditableCell/index.ts b/src/components/Table/EditableCell/index.ts
new file mode 100644
index 000000000000..fa46e271a8ad
--- /dev/null
+++ b/src/components/Table/EditableCell/index.ts
@@ -0,0 +1,6 @@
+export {default as EditableCell} from './EditableCell';
+export {default as EditingCellProvider} from './EditingCellContext';
+export {useEditingCellActions, useEditingCellState} from './EditingCellContext';
+export {default as useInlineEditState} from './useInlineEditState';
+export {default as usePopoverEditState} from './usePopoverEditState';
+export type {EditableProps} from './types';
diff --git a/src/components/Table/EditableCell/types.ts b/src/components/Table/EditableCell/types.ts
new file mode 100644
index 000000000000..075be0b712e6
--- /dev/null
+++ b/src/components/Table/EditableCell/types.ts
@@ -0,0 +1,19 @@
+/**
+ * Shared props for all inline-editable table cells.
+ *
+ * @template T The type of the value being saved (e.g. `string`, `number`).
+ */
+type EditableProps = {
+ /**
+ * Transient flag: false while editing is temporarily unavailable
+ * (e.g. receipt scanning, insufficient permissions).
+ * The styled container is still rendered to preserve column alignment.
+ */
+ canEdit?: boolean;
+
+ /** Called with the new value when the user commits an edit. */
+ onSave?: (value: T) => void;
+};
+
+// eslint-disable-next-line import/prefer-default-export -- Intentional single named type export for consistency with other `types.ts` files.
+export type {EditableProps};
diff --git a/src/components/Table/EditableCell/useInlineEditState.ts b/src/components/Table/EditableCell/useInlineEditState.ts
new file mode 100644
index 000000000000..599cc78bfe78
--- /dev/null
+++ b/src/components/Table/EditableCell/useInlineEditState.ts
@@ -0,0 +1,66 @@
+import {useCallback, useEffect, useRef, useState} from 'react';
+
+/**
+ * Hook for managing inline editing state (text, number fields).
+ *
+ * Handles:
+ * - Local value buffering (so the input doesn't write to parent on every keystroke)
+ * - Save on blur (compares localValue vs original value)
+ * - Cancel (reset to original)
+ * - isEditing toggle
+ * - Auto-cancel when canEdit becomes false
+ */
+function useInlineEditState(canEdit: boolean | undefined, value: T, onSave?: (value: T) => void, isEqual?: (newValue: T, originalValue: T) => boolean) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [localValue, setLocalValue] = useState(value);
+ const [prevValue, setPrevValue] = useState(value);
+ const hasEndedRef = useRef(false);
+
+ if (prevValue !== value) {
+ setPrevValue(value);
+ setLocalValue(value);
+ }
+
+ const startEditing = useCallback(() => {
+ hasEndedRef.current = false;
+ setIsEditing(true);
+ }, []);
+
+ const save = useCallback(() => {
+ if (hasEndedRef.current) {
+ return;
+ }
+ hasEndedRef.current = true;
+ if (onSave) {
+ const shouldSave = isEqual ? !isEqual(localValue, value) : !Object.is(localValue, value);
+ if (shouldSave) {
+ onSave(localValue);
+ }
+ }
+ // Always reset to the source-of-truth so a rejected save (e.g. empty merchant)
+ // doesn't leave stale localValue displayed after edit mode closes.
+ setLocalValue(value);
+ setIsEditing(false);
+ }, [localValue, value, onSave, isEqual]);
+
+ const cancelEditing = useCallback(() => {
+ if (hasEndedRef.current) {
+ return;
+ }
+ hasEndedRef.current = true;
+ setLocalValue(value);
+ setIsEditing(false);
+ }, [value]);
+
+ // Cancel editing when permission is revoked (e.g., transaction status changed)
+ useEffect(() => {
+ if (canEdit || !isEditing) {
+ return;
+ }
+ cancelEditing();
+ }, [canEdit, cancelEditing, isEditing]);
+
+ return {isEditing, localValue, setLocalValue, startEditing, save, cancelEditing};
+}
+
+export default useInlineEditState;
diff --git a/src/components/Table/EditableCell/usePopoverEditState.ts b/src/components/Table/EditableCell/usePopoverEditState.ts
new file mode 100644
index 000000000000..375d5d6d5167
--- /dev/null
+++ b/src/components/Table/EditableCell/usePopoverEditState.ts
@@ -0,0 +1,99 @@
+import {useEffect, useRef, useState} from 'react';
+import type {View} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import CONST from '@src/CONST';
+
+type PopoverPosition = {
+ horizontal: number;
+ vertical: number;
+};
+
+type UsePopoverEditStateOptions = {
+ /** Whether editing is currently permitted. When false, editing will be cancelled. */
+ canEdit: boolean | undefined;
+
+ /** Height of the popover content (used for overflow detection). Defaults to CONST.POPOVER_DATE_MAX_HEIGHT */
+ popoverHeight?: number;
+
+ /** Padding between the anchor and the popover */
+ padding?: number;
+
+ /**
+ * Which horizontal edge of the anchor to use as the popover's x-origin.
+ */
+ anchorEdge?: ValueOf;
+};
+
+/**
+ * Hook for managing popover-based editing state (date picker, category picker, etc.).
+ *
+ * Handles:
+ * - Anchor ref for popover positioning
+ * - measureInWindow-based position calculation
+ * - Overflow detection (inverts when too close to bottom)
+ * - Adaptive height calculation (shrinks popover when space is limited)
+ * - Auto-open after layout via InteractionManager
+ * - isEditing + isPopoverVisible toggling
+ * - Auto-cancel when canEdit becomes false
+ */
+function usePopoverEditState({
+ canEdit,
+ popoverHeight = CONST.POPOVER_DROPDOWN_MAX_HEIGHT,
+ padding = CONST.MODAL.POPOVER_MENU_PADDING,
+ anchorEdge = CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+}: UsePopoverEditStateOptions) {
+ const {windowHeight} = useWindowDimensions();
+ const anchorRef = useRef(null);
+ const [isEditing, setIsEditing] = useState(false);
+ const [isPopoverVisible, setIsPopoverVisible] = useState(false);
+ const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0});
+ const [isInverted, setIsInverted] = useState(false);
+
+ const openPopover = () => {
+ anchorRef.current?.measureInWindow((x, y, width, height) => {
+ const wouldExceedBottom = y + popoverHeight + padding > windowHeight;
+ setIsInverted(wouldExceedBottom);
+ setPopoverPosition({
+ horizontal: anchorEdge === CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT ? x : x + width,
+ vertical: y + (wouldExceedBottom ? 0 : height + padding),
+ });
+ setIsPopoverVisible(true);
+ });
+ };
+
+ const startEditing = () => {
+ setIsEditing(true);
+ // EditableCell renders conditionally based on isEditing, defer measurement until that render completes and the anchor is laid out
+ requestAnimationFrame(() => {
+ openPopover();
+ });
+ };
+
+ const cancelEditing = () => {
+ setIsPopoverVisible(false);
+ setIsEditing(false);
+ };
+
+ // Cancel editing when permission is revoked (e.g., transaction status changed)
+ useEffect(() => {
+ if (canEdit || !isEditing) {
+ return;
+ }
+ queueMicrotask(() => {
+ cancelEditing();
+ });
+ }, [canEdit, isEditing]);
+
+ return {
+ isEditing,
+ anchorRef,
+ isPopoverVisible,
+ popoverPosition,
+ isInverted,
+ startEditing,
+ cancelEditing,
+ };
+}
+
+export default usePopoverEditState;
diff --git a/src/components/TagPicker/TagPickerModal.tsx b/src/components/TagPicker/TagPickerModal.tsx
new file mode 100644
index 000000000000..f513b3fff582
--- /dev/null
+++ b/src/components/TagPicker/TagPickerModal.tsx
@@ -0,0 +1,105 @@
+import React, {useRef} from 'react';
+import {View} from 'react-native';
+import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent';
+import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasuredContent/types';
+import useOnyx from '@hooks/useOnyx';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getTagList} from '@libs/PolicyUtils';
+import type {OptionData} from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import TagPicker from '.';
+
+const popoverDimensions = {
+ width: CONST.POPOVER_DROPDOWN_WIDTH,
+ height: CONST.POPOVER_DROPDOWN_MAX_HEIGHT,
+};
+
+const DEFAULT_ANCHOR_ALIGNMENT = {
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
+};
+
+type TagPickerModalProps = {
+ /** Callback to close the modal */
+ onClose: () => void;
+
+ /** The policy whose tags should be shown */
+ policyID: string | undefined;
+
+ /** Currently selected tag */
+ selectedTag?: string;
+
+ /** The current transaction tag of the expense */
+ transactionTag?: string;
+
+ /** Whether the policy has dependent tags */
+ hasDependentTags?: boolean;
+
+ /** Called when the user confirms a tag selection */
+ onSelected?: (tag: string) => void;
+} & Omit;
+
+function TagPickerModal({
+ isVisible,
+ onClose,
+ anchorPosition,
+ policyID,
+ selectedTag = '',
+ transactionTag,
+ hasDependentTags,
+ onSelected,
+ anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT,
+ shouldMeasureAnchorPositionFromTop = false,
+}: TagPickerModalProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+
+ const anchorRef = useRef(null);
+
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`);
+ const tagListName = getTagList(policyTags, 0).name;
+
+ const handleTagSelected = (item: Partial) => {
+ // If clicking the same tag that's already selected, treat it as deselection
+ if (item.keyForList === selectedTag) {
+ onSelected?.('');
+ } else {
+ onSelected?.(item.searchText ?? item.text ?? '');
+ }
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default TagPickerModal;
diff --git a/src/components/TagPicker.tsx b/src/components/TagPicker/index.tsx
similarity index 95%
rename from src/components/TagPicker.tsx
rename to src/components/TagPicker/index.tsx
index 7914a9906632..d9aa5cd91f48 100644
--- a/src/components/TagPicker.tsx
+++ b/src/components/TagPicker/index.tsx
@@ -1,4 +1,7 @@
import React, {useMemo, useState} from 'react';
+import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem';
+import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections';
+import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
@@ -12,9 +15,6 @@ import {getTagArrayFromName} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PolicyTag, PolicyTags} from '@src/types/onyx';
-import SingleSelectListItem from './SelectionList/ListItem/SingleSelectListItem';
-import SelectionListWithSections from './SelectionList/SelectionListWithSections';
-import type {BaseTextInputRef} from './TextInput/BaseTextInput/types';
type TagPickerProps = {
/** The policyID we are getting tags for */
diff --git a/src/components/TextInputWithSymbol/BaseTextInputWithSymbol.tsx b/src/components/TextInputWithSymbol/BaseTextInputWithSymbol.tsx
index a5827b9c9602..2346e77e6b73 100644
--- a/src/components/TextInputWithSymbol/BaseTextInputWithSymbol.tsx
+++ b/src/components/TextInputWithSymbol/BaseTextInputWithSymbol.tsx
@@ -24,6 +24,7 @@ function BaseTextInputWithSymbol({
style,
symbolTextStyle,
isNegative = false,
+ negativeSymbolStyle,
rightHandSideComponent,
ref,
disabled,
@@ -42,7 +43,7 @@ function BaseTextInputWithSymbol({
onChangeAmount(newAmount);
};
- const negativeSymbol = -;
+ const negativeSymbol = -;
return (
<>
diff --git a/src/components/TextInputWithSymbol/types.ts b/src/components/TextInputWithSymbol/types.ts
index 6f5dc80cb359..df88419b6467 100644
--- a/src/components/TextInputWithSymbol/types.ts
+++ b/src/components/TextInputWithSymbol/types.ts
@@ -87,6 +87,9 @@ type BaseTextInputWithSymbolProps = {
/** Function to toggle the amount to negative */
toggleNegative?: () => void;
+ /** Style for the negative symbol */
+ negativeSymbolStyle?: StyleProp;
+
/** The test ID of TextInput. Used to locate the view in end-to-end tests. */
testID?: string;
diff --git a/src/components/TransactionItemRow/DataCells/CategoryCell.tsx b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx
index 80e8801c9549..a9ed438d043e 100644
--- a/src/components/TransactionItemRow/DataCells/CategoryCell.tsx
+++ b/src/components/TransactionItemRow/DataCells/CategoryCell.tsx
@@ -1,18 +1,37 @@
import React from 'react';
+import CategoryPickerModal from '@components/CategoryPicker/CategoryPickerModal';
import TextWithIconCell from '@components/Search/SearchList/ListItem/TextWithIconCell';
+import type {ListItem} from '@components/SelectionList/types';
+import {EditableCell, usePopoverEditState} from '@components/Table/EditableCell';
+import type {EditableProps} from '@components/Table/EditableCell';
import TextWithTooltip from '@components/TextWithTooltip';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useThemeStyles from '@hooks/useThemeStyles';
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
import type TransactionDataCellProps from './TransactionDataCellProps';
-function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem}: TransactionDataCellProps) {
+type CategoryCellProps = TransactionDataCellProps &
+ EditableProps & {
+ policyID?: string;
+ };
+
+function CategoryCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem, canEdit, onSave, policyID}: CategoryCellProps) {
const icons = useMemoizedLazyExpensifyIcons(['Folder']);
const styles = useThemeStyles();
+ const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing} = usePopoverEditState({canEdit});
+ // For display: decoded category name for user-readable text
const categoryForDisplay = isCategoryMissing(transactionItem?.category) ? '' : getDecodedCategoryName(transactionItem?.category ?? '');
- return shouldUseNarrowLayout ? (
+ // For picker comparison: raw category name (empty if missing, matches IOURequestStepCategory)
+ const categoryForComparison = isCategoryMissing(transactionItem?.category) ? '' : (transactionItem?.category ?? '');
+
+ const handleCategorySelected = (item: ListItem) => {
+ onSave?.(item.keyForList);
+ cancelEditing();
+ };
+
+ const displayContent = shouldUseNarrowLayout ? (
);
+
+ return (
+
+ }
+ >
+ {displayContent}
+
+ );
}
export default CategoryCell;
diff --git a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx
index 924aa5168854..fc4ed039619f 100644
--- a/src/components/TransactionItemRow/DataCells/MerchantCell.tsx
+++ b/src/components/TransactionItemRow/DataCells/MerchantCell.tsx
@@ -1,35 +1,114 @@
-import React, {useMemo} from 'react';
+import React, {useMemo, useRef} from 'react';
+import type {TextInput as RNTextInput} from 'react-native';
+import {EditableCell, useInlineEditState} from '@components/Table/EditableCell';
+import type {EditableProps} from '@components/Table/EditableCell/types';
+import TextInput from '@components/TextInput';
+import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import TextWithTooltip from '@components/TextWithTooltip';
+import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import {moveSelectionToEnd, scrollToBottom} from '@libs/InputUtils';
import Parser from '@libs/Parser';
+import StringUtils from '@libs/StringUtils';
+import CONST from '@src/CONST';
-function MerchantOrDescriptionCell({
- merchantOrDescription,
- shouldShowTooltip,
- shouldUseNarrowLayout,
- isDescription,
-}: {
+type MerchantOrDescriptionCellProps = {
merchantOrDescription: string;
shouldUseNarrowLayout?: boolean;
shouldShowTooltip: boolean;
isDescription?: boolean;
-}) {
+} & EditableProps;
+
+function MerchantOrDescriptionCell({merchantOrDescription, shouldShowTooltip, shouldUseNarrowLayout, isDescription, canEdit, onSave}: MerchantOrDescriptionCellProps) {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const inputRef = useRef(null);
const text = useMemo(() => {
if (!isDescription) {
return merchantOrDescription;
}
- return Parser.htmlToText(merchantOrDescription).replaceAll('\n', ' ');
+ return StringUtils.lineBreaksToSpaces(Parser.htmlToText(merchantOrDescription));
}, [merchantOrDescription, isDescription]);
+ // Normalize merchant values for save/equality checks (descriptions can keep whitespace)
+ const getNormalizedValue = (value: string) => (isDescription ? value : value.trim());
+
+ const {isEditing, localValue, setLocalValue, startEditing, save, cancelEditing} = useInlineEditState(
+ canEdit,
+ text,
+ onSave ? (value) => onSave(getNormalizedValue(value)) : undefined,
+ (value, originalValue) => getNormalizedValue(value) === getNormalizedValue(originalValue),
+ );
+
+ const isMultilineInput = isDescription;
+
+ const handleChangeText = (value: string) => {
+ // Sanitize line breaks on change for single line inputs.
+ if (!isMultilineInput) {
+ setLocalValue(StringUtils.removeLineBreaks(value));
+ return;
+ }
+ setLocalValue(value);
+ };
+
+ const handleRef = (element: BaseTextInputRef | null) => {
+ inputRef.current = element as RNTextInput | null;
+ };
+
+ // Multiline TextInputs with autoFocus default cursor to the beginning; manually position it at the end on focus
+ const handleFocus = () => {
+ requestAnimationFrame(() => {
+ const input = inputRef.current;
+ if (!input) {
+ return;
+ }
+
+ scrollToBottom(input);
+ moveSelectionToEnd(input);
+ });
+ };
+
+ const handleEscape = () => {
+ cancelEditing();
+ inputRef.current?.blur();
+ };
+
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, handleEscape, {captureOnInputs: true, isActive: isEditing});
+
return (
-
+
+ }
+ >
+
+
);
}
diff --git a/src/components/TransactionItemRow/DataCells/TagCell.tsx b/src/components/TransactionItemRow/DataCells/TagCell.tsx
index ebbabd194aec..cf63d0024a63 100644
--- a/src/components/TransactionItemRow/DataCells/TagCell.tsx
+++ b/src/components/TransactionItemRow/DataCells/TagCell.tsx
@@ -1,29 +1,78 @@
import React from 'react';
import TextWithIconCell from '@components/Search/SearchList/ListItem/TextWithIconCell';
+import type {EditableProps} from '@components/Table/EditableCell';
+import {EditableCell, usePopoverEditState} from '@components/Table/EditableCell';
+import TagPickerModal from '@components/TagPicker/TagPickerModal';
import TextWithTooltip from '@components/TextWithTooltip';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
+import {hasDependentTags} from '@libs/PolicyUtils';
import {getTagForDisplay} from '@libs/TransactionUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
import type TransactionDataCellProps from './TransactionDataCellProps';
-function TagCell({shouldUseNarrowLayout, shouldShowTooltip, transactionItem}: TransactionDataCellProps) {
+type TagCellProps = TransactionDataCellProps &
+ EditableProps & {
+ policyID?: string;
+ };
+
+function TagCell({canEdit, onSave, shouldUseNarrowLayout, shouldShowTooltip, transactionItem, policyID}: TagCellProps) {
const icons = useMemoizedLazyExpensifyIcons(['Tag']);
const styles = useThemeStyles();
- return shouldUseNarrowLayout ? (
+ const {isEditing, anchorRef, isPopoverVisible, popoverPosition, isInverted, startEditing, cancelEditing} = usePopoverEditState({canEdit});
+
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`);
+
+ const policyHasDependentTags = hasDependentTags(policy, policyTags);
+
+ const handleTagSelected = (tag: string) => {
+ onSave?.(tag);
+ cancelEditing();
+ };
+
+ const tagForDisplay = getTagForDisplay(transactionItem);
+
+ const displayContent = shouldUseNarrowLayout ? (
) : (
);
+
+ return (
+
+ }
+ >
+ {displayContent}
+
+ );
}
export default TagCell;
diff --git a/src/components/TransactionItemRow/DataCells/TotalCell.tsx b/src/components/TransactionItemRow/DataCells/TotalCell.tsx
index 20192d3ab48f..65622821d8a1 100644
--- a/src/components/TransactionItemRow/DataCells/TotalCell.tsx
+++ b/src/components/TransactionItemRow/DataCells/TotalCell.tsx
@@ -1,15 +1,36 @@
-import React from 'react';
+import React, {useRef, useState} from 'react';
+import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput';
+import {EditableCell, useInlineEditState} from '@components/Table/EditableCell';
+import type {EditableProps} from '@components/Table/EditableCell';
+import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import TextWithTooltip from '@components/TextWithTooltip';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
+import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import {getTransactionDetails} from '@libs/ReportUtils';
-import {getCurrency as getTransactionCurrency, isDeletedTransaction, isScanning} from '@libs/TransactionUtils';
+import {convertToBackendAmount, convertToFrontendAmountAsString, getCurrencyDecimals} from '@libs/CurrencyUtils';
+import {formatToParts} from '@libs/NumberFormatUtils';
+import {parseFloatAnyLocale, roundToTwoDecimalPlaces} from '@libs/NumberUtils';
+import {getTransactionDetails, isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils';
+import {getCurrency as getTransactionCurrency, isDeletedTransaction, isExpenseUnreported, isScanning} from '@libs/TransactionUtils';
+import CONST from '@src/CONST';
import type TransactionDataCellProps from './TransactionDataCellProps';
-function TotalCell({shouldShowTooltip, transactionItem}: TransactionDataCellProps) {
+type TotalCellProps = TransactionDataCellProps & EditableProps;
+type TransactionItem = TransactionDataCellProps['transactionItem'];
+
+function getTransactionItemIouType(transactionItem: TransactionItem) {
+ if (isInvoiceReport(transactionItem.report)) {
+ return CONST.IOU.TYPE.INVOICE;
+ }
+
+ const isSplitTransaction = transactionItem.comment?.source === CONST.IOU.TYPE.SPLIT || !!transactionItem.comment?.splits;
+ return isSplitTransaction ? CONST.IOU.TYPE.SPLIT : CONST.IOU.TYPE.SUBMIT;
+}
+
+function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave}: TotalCellProps) {
const styles = useThemeStyles();
- const {translate} = useLocalize();
+ const {translate, preferredLocale} = useLocalize();
const {convertToDisplayString} = useCurrencyListActions();
const currency = getTransactionCurrency(transactionItem);
@@ -20,13 +41,131 @@ function TotalCell({shouldShowTooltip, transactionItem}: TransactionDataCellProp
amountToDisplay = translate('iou.receiptStatusTitle');
}
- return (
+ const iouType = getTransactionItemIouType(transactionItem);
+ const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT;
+ const isUnreportedExpense = isExpenseUnreported(transactionItem);
+ const allowNegative = isUnreportedExpense || shouldEnableNegative(transactionItem.report, transactionItem.policy, iouType, transactionItem.participants);
+
+ const absoluteAmount = Math.abs(amount ?? 0);
+ const isOriginalAmountNegative = (amount ?? 0) < 0;
+ const [isNegative, setIsNegative] = useState(isOriginalAmountNegative);
+
+ const getNormalizedValue = (amountString: string, isAmountNegative: boolean) => {
+ const parsedValue = parseFloatAnyLocale(amountString);
+ if (Number.isNaN(parsedValue) || parsedValue < 0) {
+ return undefined;
+ }
+
+ const normalizedValue = roundToTwoDecimalPlaces(parsedValue);
+ const finalAmount = isAmountNegative ? -normalizedValue : normalizedValue;
+ return convertToBackendAmount(finalAmount);
+ };
+
+ // localValue tracks the frontend-format amount string (e.g. "12.34") while editing
+ const {isEditing, setLocalValue, startEditing, save, cancelEditing} = useInlineEditState(
+ canEdit,
+ convertToFrontendAmountAsString(absoluteAmount, getCurrencyDecimals(currency)),
+ onSave
+ ? (value) => {
+ const normalizedValue = getNormalizedValue(value, isNegative);
+ if (normalizedValue === undefined) {
+ return;
+ }
+ onSave(normalizedValue);
+ }
+ : undefined,
+ (value, originalValue) => getNormalizedValue(value, isNegative) === getNormalizedValue(originalValue, isOriginalAmountNegative),
+ );
+
+ // Ref used to programmatically focus the input when edit mode starts
+ const inputRef = useRef(null);
+
+ const focusOnMount = (ref: BaseTextInputRef | null) => {
+ inputRef.current = ref;
+ ref?.focus();
+ };
+
+ const handleStartEditing = () => {
+ setIsNegative(isOriginalAmountNegative);
+ startEditing();
+ };
+
+ const handleAmountChange = (amountString: string) => {
+ setLocalValue(amountString);
+ };
+
+ const onFormatAmount = (amountAsInt: number, currencyParam?: string) => {
+ const decimals = getCurrencyDecimals(currencyParam);
+ return convertToFrontendAmountAsString(amountAsInt, decimals);
+ };
+
+ const toggleNegative = () => setIsNegative((prev) => !prev);
+
+ const clearNegative = () => setIsNegative(false);
+
+ const handleEscape = () => {
+ cancelEditing();
+ inputRef.current?.blur();
+ };
+
+ // Some currencies display with a space between symbol and amount (e.g., "CZK 100.00") in convertToDisplayString (in preview).
+ // We detect this spacing and apply matching padding to the input to prevent visual flicker when entering edit mode.
+ // See: https://github.com/Expensify/App/pull/83127#issuecomment-4240055145
+ const hasSymbolSpaceInPreview = formatToParts(preferredLocale, 0, {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: getCurrencyDecimals(currency),
+ maximumFractionDigits: CONST.DEFAULT_CURRENCY_DECIMALS,
+ }).some((part) => part.type === 'literal' && part.value.trim() === '');
+
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, handleEscape, {captureOnInputs: true, isActive: isEditing});
+
+ const displayContent = (
);
+
+ return (
+
+ }
+ >
+ {displayContent}
+
+ );
}
export default TotalCell;
diff --git a/src/components/TransactionItemRow/index.tsx b/src/components/TransactionItemRow/index.tsx
index 5d13894fc48c..5ff0a5d92bf7 100644
--- a/src/components/TransactionItemRow/index.tsx
+++ b/src/components/TransactionItemRow/index.tsx
@@ -20,6 +20,7 @@ import Text from '@components/Text';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -46,6 +47,7 @@ import {
hasMissingSmartscanFields,
isAmountMissing,
isDeletedTransaction as isDeletedTransactionUtil,
+ isExpenseUnreported,
isMerchantMissing,
isScanning,
isTimeRequest,
@@ -54,6 +56,7 @@ import {
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
import type {CardList, PersonalDetails, Policy, Report, ReportAction, TransactionViolation} from '@src/types/onyx';
import type {SearchTransactionAction} from '@src/types/onyx/SearchResults';
import CategoryCell from './DataCells/CategoryCell';
@@ -149,6 +152,21 @@ type TransactionItemRowProps = {
nonPersonalAndWorkspaceCards?: CardList;
isActionColumnWide?: boolean;
shouldRemoveTotalColumnFlex?: boolean;
+ /** Callbacks for inline cell editing */
+ onEditDate?: (newDate: string) => void;
+ onEditMerchant?: (newMerchant: string) => void;
+ onEditDescription?: (newDescription: string) => void;
+ onEditCategory?: (newCategory: string) => void;
+ onEditAmount?: (newAmount: number) => void;
+ onEditTag?: (newTag: string) => void;
+
+ /** Per-field edit permissions — controls whether the cell shows editable affordance */
+ canEditDate?: boolean;
+ canEditMerchant?: boolean;
+ canEditDescription?: boolean;
+ canEditCategory?: boolean;
+ canEditAmount?: boolean;
+ canEditTag?: boolean;
};
const EMPTY_ACTIVE_STYLE: StyleProp = [];
@@ -205,12 +223,29 @@ function TransactionItemRow({
policyForMovingExpenses,
isActionColumnWide: isActionColumnWideProp,
shouldRemoveTotalColumnFlex,
+ onEditDate,
+ onEditMerchant,
+ onEditDescription,
+ onEditCategory,
+ onEditAmount,
+ onEditTag,
+ canEditDate,
+ canEditMerchant,
+ canEditDescription,
+ canEditCategory,
+ canEditAmount,
+ canEditTag,
}: TransactionItemRowProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const StyleUtils = useStyleUtils();
const theme = useTheme();
const isLargeScreenWidth = isLargeScreenWidthProp ?? !shouldUseNarrowLayout;
+
+ // For unreported expenses (SelfDM), use active policy to show policy-specific fields like categories and tags
+ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
+ const reportPolicyID = report?.policyID ?? transactionItem.report?.policyID;
+ const effectivePolicyID = isExpenseUnreported(transactionItem) ? activePolicyID : reportPolicyID;
const createdAt = getTransactionCreated(transactionItem);
const expensicons = useMemoizedLazyExpensifyIcons(['ArrowRight']);
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
@@ -317,6 +352,9 @@ function TransactionItemRow({
transactionItem={transactionItem}
shouldShowTooltip={shouldShowTooltip}
shouldUseNarrowLayout={shouldUseNarrowLayout}
+ canEdit={canEditTag}
+ onSave={onEditTag}
+ policyID={effectivePolicyID}
/>
);
@@ -327,7 +365,9 @@ function TransactionItemRow({
style={[StyleUtils.getReportTableColumnStyles(CONST.SEARCH.TABLE_COLUMNS.DATE, {isDateColumnWide})]}
>
@@ -395,6 +435,9 @@ function TransactionItemRow({
transactionItem={transactionItem}
shouldShowTooltip={shouldShowTooltip}
shouldUseNarrowLayout={shouldUseNarrowLayout}
+ canEdit={canEditCategory}
+ onSave={onEditCategory}
+ policyID={effectivePolicyID}
/>
);
@@ -444,13 +487,13 @@ function TransactionItemRow({
key={column}
style={[StyleUtils.getReportTableColumnStyles(CONST.SEARCH.TABLE_COLUMNS.MERCHANT)]}
>
- {!!merchant && (
-
- )}
+
);
case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION:
@@ -459,14 +502,14 @@ function TransactionItemRow({
key={column}
style={[StyleUtils.getReportTableColumnStyles(CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION)]}
>
- {!!description && (
-
- )}
+
);
case CONST.SEARCH.TABLE_COLUMNS.TO:
@@ -556,6 +599,8 @@ function TransactionItemRow({
transactionItem={transactionItem}
shouldShowTooltip={shouldShowTooltip}
shouldUseNarrowLayout={shouldUseNarrowLayout}
+ canEdit={canEditAmount}
+ onSave={onEditAmount}
/>
);
diff --git a/src/hooks/useTransactionInlineEdit.ts b/src/hooks/useTransactionInlineEdit.ts
new file mode 100644
index 000000000000..508d01f39729
--- /dev/null
+++ b/src/hooks/useTransactionInlineEdit.ts
@@ -0,0 +1,215 @@
+/**
+ * Centralizes inline-editing logic for a transaction row so that permission
+ * derivation, Onyx subscriptions, and edit handlers live in one place rather
+ * than being duplicated across every surface that renders a transaction.
+ */
+import {useCallback, useRef} from 'react';
+// eslint-disable-next-line no-restricted-imports -- Need original useOnyx to avoid reading partial Search snapshot policy data.
+import {useOnyx as originalUseOnyx} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
+import {useSearchStateContext} from '@components/Search/SearchContext';
+import type {TransactionInlineEditParams} from '@libs/actions/TransactionInlineEdit';
+import {
+ editTransactionAmountInline,
+ editTransactionCategoryInline,
+ editTransactionDateInline,
+ editTransactionDescriptionInline,
+ editTransactionMerchantInline,
+ editTransactionTagInline,
+ getTransactionEditPermissions,
+} from '@libs/actions/TransactionInlineEdit';
+import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
+import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils';
+import {isExpenseUnreported, isPerDiemRequest} from '@libs/TransactionUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Report, ReportAction, ReportActions} from '@src/types/onyx';
+import useOnyx from './useOnyx';
+import usePolicyForMovingExpenses from './usePolicyForMovingExpenses';
+import usePolicyForTransaction from './usePolicyForTransaction';
+
+type UseTransactionInlineEditParams = {
+ transactionID: string;
+ reportID: string | undefined;
+
+ /**
+ * When provided the action is looked up directly by ID (faster).
+ * If omitted, the hook scans all actions for the one matching this transaction.
+ * Ignored when `parentReportAction` is supplied directly.
+ */
+ reportActionID?: string;
+
+ /**
+ * Pre-fetched parent report action.
+ * When provided the hook skips its own Onyx subscription entirely, avoiding
+ * a duplicate subscription in components that already hold this data.
+ */
+ parentReportAction?: OnyxEntry;
+
+ /**
+ * Search snapshot hash.
+ * When provided, edit functions will optimistically update the snapshot row.
+ * Omit (or pass undefined) when editing from outside the Search table.
+ */
+ hash?: number;
+
+ /**
+ * Fallback report from the search snapshot.
+ * Used when the Onyx report cache is empty (e.g. after cache clear) so that
+ * permission checks like isSettled still have a report object with statusNum.
+ */
+ fallbackReport?: OnyxEntry;
+};
+
+type UseTransactionInlineEditReturn = {
+ canEditDate: boolean;
+ canEditMerchant: boolean;
+ canEditDescription: boolean;
+ canEditCategory: boolean;
+ canEditAmount: boolean;
+ canEditTag: boolean;
+ transactionThreadReportID: string | undefined;
+ onEditDate: (newDate: string) => void;
+ onEditMerchant: (newMerchant: string) => void;
+ onEditDescription: (newDescription: string) => void;
+ onEditCategory: (newCategory: string) => void;
+ onEditAmount: (newAmount: number) => void;
+ onEditTag: (newTag: string) => void;
+ /**
+ * Ref that should be written in onPressIn and checked in onPress to suppress
+ * row navigation when a cell edit is being dismissed.
+ */
+ wasEditingOnMouseDownRef: React.RefObject;
+};
+
+function useTransactionInlineEdit({
+ transactionID,
+ reportID,
+ reportActionID,
+ parentReportAction: externalParentReportAction,
+ hash,
+ fallbackReport,
+}: UseTransactionInlineEditParams): UseTransactionInlineEditReturn {
+ // Look up the parent IOU report action from live Onyx. If the caller already
+ // knows the action ID we can select it directly; otherwise we scan all actions.
+ // When the caller supplies `parentReportAction` directly we still must call
+ // useOnyx (rules of hooks) but we ignore its result and prefer the external value.
+ const parentReportActionSelector = useCallback(
+ (reportActions: ReportActions | undefined) => (reportActionID ? reportActions?.[reportActionID] : getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID)),
+ [reportActionID, transactionID],
+ );
+
+ const [internalParentReportAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`, {
+ selector: parentReportActionSelector,
+ });
+
+ const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
+ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`);
+
+ const parentReportAction = externalParentReportAction !== undefined ? externalParentReportAction : internalParentReportAction;
+
+ const transactionThreadReportID = parentReportAction?.childReportID;
+ const chatReportID = parentReport?.chatReportID;
+
+ // For unreported expenses (SelfDM), use active policy to show policy-specific fields like categories and tags.
+ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
+ const reportPolicyID = parentReport?.policyID;
+ const policyID = isExpenseUnreported(transaction) ? activePolicyID : reportPolicyID;
+
+ const {policy} = usePolicyForTransaction({
+ transaction,
+ reportPolicyID,
+ action: CONST.IOU.ACTION.EDIT,
+ iouType: CONST.IOU.TYPE.SUBMIT,
+ isPerDiemRequest: isPerDiemRequest(transaction),
+ });
+
+ const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReportID)}`);
+ const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`);
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(policyID)}`);
+ const [transactionThreadNVP] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(transactionThreadReportID)}`);
+ const [chatReportNVP] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(chatReportID)}`);
+ const [policyRecentlyUsedCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`);
+ const [policyRecentlyUsedTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${getNonEmptyStringOnyxID(policyID)}`);
+ const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(reportID)}`);
+ // Use original Onyx here because the useOnyx wrapper can read partial Search snapshot policy data instead of the full policy object.
+ const [completePolicy] = originalUseOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(policyID)}`);
+
+ const originalTransactionID = transaction?.comment?.originalTransactionID;
+ const [originalTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(originalTransactionID)}`);
+
+ const {hasSelectedTransactions} = useSearchStateContext();
+
+ const isPerDiem = isPerDiemRequest(transaction);
+ const {shouldSelectPolicy} = usePolicyForMovingExpenses(isPerDiem);
+
+ const permissions = getTransactionEditPermissions({
+ transaction,
+ parentReportAction,
+ parentReport: parentReport ?? fallbackReport,
+ policy: completePolicy ?? policy,
+ transactionThreadReport: transactionThreadReport ?? parentReport ?? fallbackReport,
+ policyCategories,
+ policyTags,
+ transactionThreadNVP,
+ chatReportNVP,
+ originalTransaction,
+ disabled: hasSelectedTransactions,
+ shouldSelectPolicyForUnreported: shouldSelectPolicy,
+ });
+
+ const wasEditingOnMouseDownRef = useRef(false);
+
+ const getEditParams = (): TransactionInlineEditParams => {
+ return {
+ hash,
+ transactionID,
+ parentReport: parentReport ?? fallbackReport,
+ transactionThreadReport: transactionThreadReport ?? parentReport ?? fallbackReport,
+ policy: completePolicy ?? policy,
+ policyCategories,
+ policyTags,
+ policyRecentlyUsedCategories,
+ policyRecentlyUsedTags,
+ parentReportNextStep,
+ };
+ };
+
+ const onEditDate = (newDate: string) => {
+ editTransactionDateInline(getEditParams(), newDate);
+ };
+
+ const onEditMerchant = (newMerchant: string) => {
+ editTransactionMerchantInline(getEditParams(), newMerchant);
+ };
+
+ const onEditDescription = (newDescription: string) => {
+ editTransactionDescriptionInline(getEditParams(), newDescription);
+ };
+
+ const onEditCategory = (newCategory: string) => {
+ editTransactionCategoryInline(getEditParams(), newCategory);
+ };
+
+ const onEditAmount = (newAmount: number) => {
+ editTransactionAmountInline(getEditParams(), newAmount);
+ };
+
+ const onEditTag = (newTag: string) => {
+ editTransactionTagInline(getEditParams(), newTag);
+ };
+
+ return {
+ ...permissions,
+ transactionThreadReportID,
+ onEditDate,
+ onEditMerchant,
+ onEditDescription,
+ onEditCategory,
+ onEditAmount,
+ onEditTag,
+ wasEditingOnMouseDownRef,
+ };
+}
+
+export default useTransactionInlineEdit;
diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts
index 5c82db19144e..c12637b8be76 100644
--- a/src/libs/MoneyRequestUtils.ts
+++ b/src/libs/MoneyRequestUtils.ts
@@ -1,4 +1,11 @@
+import type {OnyxEntry} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
+import type {Report, Transaction} from '@src/types/onyx';
+import {isIOUReport} from './ReportUtils';
+import StringUtils from './StringUtils';
+import {isExpenseUnreported} from './TransactionUtils';
+import {isInvalidMerchantValue} from './ValidationUtils';
/**
* Strip comma from the amount
@@ -119,6 +126,72 @@ function handleNegativeAmountFlipping(amount: string, allowFlippingAmount: boole
return amount;
}
+const nonZeroMoneyRequestTypes = new Set>([CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.INVOICE, CONST.IOU.TYPE.SPLIT]);
+
+/**
+ * Validates a money request amount according to business rules.
+ *
+ * @param amount - Amount in backend format (cents as integer)
+ * @param iouType - Type of IOU (PAY, INVOICE, SPLIT, REQUEST, SUBMIT, etc.)
+ * @param allowNegative - Whether negative amounts are allowed
+ * @param isIOUReport - Whether this is an IOU report (zero amounts not allowed)
+ * @param isP2P - Whether this is a peer-to-peer transaction
+ */
+function isValidMoneyRequestAmount(amount: number | undefined, iouType: ValueOf, allowNegative = true, isP2P = false): boolean {
+ if (amount === undefined || amount === null || Number.isNaN(amount)) {
+ return false;
+ }
+
+ if (amount < 0 && !allowNegative) {
+ return false;
+ }
+
+ const absoluteAmount = Math.abs(amount);
+
+ if ((iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SUBMIT) && isP2P) {
+ return absoluteAmount >= 1;
+ }
+
+ if (nonZeroMoneyRequestTypes.has(iouType)) {
+ return absoluteAmount >= 1;
+ }
+
+ return true;
+}
+
+/**
+ * Validates a merchant value according to business rules.
+ *
+ * @param merchant - The merchant name to validate
+ * @param transaction - The transaction to validate merchant for (used to determine if clearing is allowed)
+ * @param report - The parent report for the transaction (used to determine if IOU clearing is allowed)
+ * @returns Whether the merchant value is valid
+ */
+function isValidMerchant(merchant: string | undefined, transaction?: OnyxEntry, report?: OnyxEntry): boolean {
+ const trimmedMerchant = merchant?.trim() ?? '';
+ const isEmpty = !trimmedMerchant;
+
+ // Unreported expenses and IOU requests can have empty merchants (allows clearing)
+ const isUnreported = transaction ? isExpenseUnreported(transaction) : false;
+ const isIOU = !!report && isIOUReport(report);
+ if (isEmpty && (isUnreported || isIOU)) {
+ return true;
+ }
+
+ // Reported transactions or non-empty merchants must pass validation
+ if (isEmpty) {
+ return false;
+ }
+
+ // Check if it's an invalid merchant value (PARTIAL or DEFAULT constants)
+ if (isInvalidMerchantValue(trimmedMerchant)) {
+ return false;
+ }
+
+ const valueByteLength = StringUtils.getUTF8ByteLength(trimmedMerchant);
+ return valueByteLength <= CONST.MERCHANT_NAME_MAX_BYTES;
+}
+
export {
addLeadingZero,
replaceAllDigits,
@@ -129,4 +202,6 @@ export {
validateAmount,
validatePercentage,
handleNegativeAmountFlipping,
+ isValidMoneyRequestAmount,
+ isValidMerchant,
};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 1480bf4a695c..13b756202ab2 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -4712,7 +4712,7 @@ function canEditMoneyRequest(
return !isForwarded;
}
- return !isReportApproved({report: moneyRequestReport}) && !isSettled(moneyRequestReport?.reportID) && !isClosedReport(moneyRequestReport) && isRequestor;
+ return !isReportApproved({report: moneyRequestReport}) && !isSettled(moneyRequestReport) && !isClosedReport(moneyRequestReport) && isRequestor;
}
function getNextApproverAccountID(report: OnyxEntry, isUnapproved = false) {
@@ -4900,7 +4900,7 @@ function canEditFieldOfMoneyRequest({
return false;
}
- if (isSettled(String(moneyRequestReport.reportID)) || isReportIDApproved(String(moneyRequestReport.reportID))) {
+ if (isSettled(moneyRequestReport) || isReportApproved({report: moneyRequestReport})) {
return false;
}
diff --git a/src/libs/StringUtils/index.ts b/src/libs/StringUtils/index.ts
index 8cddf1460b99..d3db35fd3141 100644
--- a/src/libs/StringUtils/index.ts
+++ b/src/libs/StringUtils/index.ts
@@ -120,6 +120,15 @@ function normalizeCRLF(value?: string): string | undefined {
return value?.replaceAll('\r\n', '\n');
}
+/**
+ * Remove all line breaks from a string
+ * @param text - The input string
+ * @returns The string with all line breaks removed
+ */
+function removeLineBreaks(text = '') {
+ return text.replaceAll(CONST.REGEX.LINE_BREAK, '');
+}
+
/**
* Replace all line breaks with white spaces
*/
@@ -216,6 +225,7 @@ export default {
normalizeAccents,
normalizeCRLF,
lineBreaksToSpaces,
+ removeLineBreaks,
getFirstLine,
removeDoubleQuotes,
removePreCodeBlock,
diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts
index 811dac6949a9..c72f89069594 100644
--- a/src/libs/actions/IOU/UpdateMoneyRequest.ts
+++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts
@@ -65,6 +65,7 @@ type UpdateMoneyRequestDateParams = {
isASAPSubmitBetaEnabled: boolean;
parentReportNextStep: OnyxEntry;
isOffline: boolean;
+ hash?: number;
};
/** Updates the created date of an expense */
@@ -83,6 +84,7 @@ function updateMoneyRequestDate({
isASAPSubmitBetaEnabled,
parentReportNextStep,
isOffline,
+ hash,
}: UpdateMoneyRequestDateParams) {
const transactionChanges: TransactionChanges = {
created: value,
@@ -90,7 +92,7 @@ function updateMoneyRequestDate({
let data: UpdateMoneyRequestData;
if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) {
- data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy);
+ data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy, hash);
} else {
data = getUpdateMoneyRequestParams({
transactionID,
@@ -107,6 +109,7 @@ function updateMoneyRequestDate({
isASAPSubmitBetaEnabled,
iouReportNextStep: parentReportNextStep,
isOffline,
+ hash,
});
removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations);
}
@@ -229,6 +232,7 @@ function updateMoneyRequestMerchant({
currentUserEmailParam,
isASAPSubmitBetaEnabled,
parentReportNextStep,
+ hash,
}: {
transactionID: string;
transactionThreadReport: OnyxEntry;
@@ -241,6 +245,7 @@ function updateMoneyRequestMerchant({
currentUserEmailParam: string;
isASAPSubmitBetaEnabled: boolean;
parentReportNextStep: OnyxEntry;
+ hash?: number;
}) {
const transactionChanges: TransactionChanges = {
merchant: value,
@@ -248,7 +253,7 @@ function updateMoneyRequestMerchant({
let data: UpdateMoneyRequestData;
if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) {
- data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy);
+ data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy, hash);
} else {
data = getUpdateMoneyRequestParams({
transactionID,
@@ -264,6 +269,7 @@ function updateMoneyRequestMerchant({
currentUserEmailParam,
isASAPSubmitBetaEnabled,
iouReportNextStep: parentReportNextStep,
+ hash,
});
}
const {params, onyxData} = data;
@@ -671,6 +677,7 @@ function updateMoneyRequestDescription({
currentUserEmailParam,
isASAPSubmitBetaEnabled,
parentReportNextStep,
+ hash,
}: {
transactionID: string;
transactionThreadReport: OnyxEntry;
@@ -683,6 +690,7 @@ function updateMoneyRequestDescription({
currentUserEmailParam: string;
isASAPSubmitBetaEnabled: boolean;
parentReportNextStep: OnyxEntry;
+ hash?: number;
}) {
const parsedComment = getParsedComment(comment);
const transactionChanges: TransactionChanges = {
@@ -691,7 +699,7 @@ function updateMoneyRequestDescription({
let data: UpdateMoneyRequestData;
if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) {
- data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy);
+ data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy, hash);
} else {
data = getUpdateMoneyRequestParams({
transactionID,
@@ -707,6 +715,7 @@ function updateMoneyRequestDescription({
currentUserEmailParam,
isASAPSubmitBetaEnabled,
iouReportNextStep: parentReportNextStep,
+ hash,
});
}
const {params, onyxData} = data;
@@ -813,6 +822,7 @@ type UpdateMoneyRequestAmountAndCurrencyParams = {
isASAPSubmitBetaEnabled: boolean;
policyRecentlyUsedCurrencies: string[];
parentReportNextStep: OnyxEntry;
+ hash?: number;
};
/** Updates the amount and currency fields of an expense */
@@ -836,6 +846,7 @@ function updateMoneyRequestAmountAndCurrency({
isASAPSubmitBetaEnabled,
policyRecentlyUsedCurrencies,
parentReportNextStep,
+ hash,
}: UpdateMoneyRequestAmountAndCurrencyParams) {
const transactionChanges = {
amount,
@@ -848,7 +859,7 @@ function updateMoneyRequestAmountAndCurrency({
let data: UpdateMoneyRequestData;
if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) {
- data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy);
+ data = getUpdateTrackExpenseParams(transactionID, transactionThreadReport?.reportID, transactionChanges, policy, hash);
} else {
data = getUpdateMoneyRequestParams({
transactionID,
@@ -866,6 +877,7 @@ function updateMoneyRequestAmountAndCurrency({
isASAPSubmitBetaEnabled,
policyRecentlyUsedCurrencies,
iouReportNextStep: parentReportNextStep,
+ hash,
});
removeTransactionFromDuplicateTransactionViolation(data.onyxData, transactionID, transactions, transactionViolations);
}
@@ -958,6 +970,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.TRANSACTION
| typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS
+ | typeof ONYXKEYS.COLLECTION.SNAPSHOT
>
> = [];
const failureData: Array<
@@ -1418,29 +1431,30 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U
key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`,
value: currentTransactionViolations,
});
+
+ // Optimistically update the search snapshot so the search list reflects the
+ // new values immediately (the snapshot is the exclusive data source for search
+ // result rendering and is not automatically updated by the TRANSACTION write above).
if (hash) {
- // Initializing as an empty typed object to allow dynamic key assignment resolves TypeScript type inference issue
- const optimisticSnapshotData: SearchResultDataType = {};
- optimisticSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] = Array.isArray(violationsOnyxData.value) ? violationsOnyxData.value : [];
- optimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
- value: {
- data: optimisticSnapshotData,
- },
- });
+ const snapshotKey = `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` as const;
+ const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}` as const;
+ const violationsKey = `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}` as const;
- // Initializing as an empty typed object to allow dynamic key assignment resolves TypeScript type inference issue
- const failureSnapshotData: SearchResultDataType = {};
- failureSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] = currentTransactionViolations;
- failureData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
- value: {
- data: failureSnapshotData,
- },
- });
+ const optimisticSnapshotData: SearchResultDataType = {};
+ optimisticSnapshotData[transactionKey] = {...updatedTransaction, pendingFields};
+ optimisticSnapshotData[violationsKey] = Array.isArray(violationsOnyxData.value) ? violationsOnyxData.value : [];
+ optimisticData.push({onyxMethod: Onyx.METHOD.MERGE, key: snapshotKey, value: {data: optimisticSnapshotData}});
+
+ const successSnapshotData: NullishDeep = {};
+ successSnapshotData[transactionKey] = {pendingFields: clearedPendingFields};
+ successData.push({onyxMethod: Onyx.METHOD.MERGE, key: snapshotKey, value: {data: successSnapshotData}});
+
+ const failureSnapshotData: NullishDeep = {};
+ failureSnapshotData[transactionKey] = {...transaction, pendingFields: clearedPendingFields};
+ failureSnapshotData[violationsKey] = currentTransactionViolations;
+ failureData.push({onyxMethod: Onyx.METHOD.MERGE, key: snapshotKey, value: {data: failureSnapshotData}});
}
+
if (
violationsOnyxData &&
((iouReport?.statusNum ?? CONST.REPORT.STATUS_NUM.OPEN) === CONST.REPORT.STATUS_NUM.OPEN ||
@@ -1540,13 +1554,20 @@ function getUpdateTrackExpenseParams(
transactionThreadReportID: string | undefined,
transactionChanges: TransactionChanges,
policy: OnyxEntry,
+ hash?: number,
shouldBuildOptimisticModifiedExpenseReportAction = true,
): UpdateMoneyRequestData<
- typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS | typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT
+ | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS
+ | typeof ONYXKEYS.COLLECTION.TRANSACTION
+ | typeof ONYXKEYS.COLLECTION.REPORT
+ | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT
+ | typeof ONYXKEYS.COLLECTION.SNAPSHOT
> {
const optimisticData: Array> = [];
const successData: Array> = [];
- const failureData: Array> = [];
+ const failureData: Array<
+ OnyxUpdate
+ > = [];
// Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData
const pendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE]));
@@ -1703,6 +1724,20 @@ function getUpdateTrackExpenseParams(
value: transactionThread,
});
+ // Roll back the snapshot copy of the transaction so the search row reverts to its pre-edit state
+ if (hash) {
+ // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`,
+ value: {
+ data: {
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction ?? null,
+ },
+ },
+ });
+ }
+
return {
params: apiParams,
onyxData: {optimisticData, successData, failureData},
diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts
index e6bcdf7adc98..7c7f55785123 100644
--- a/src/libs/actions/MergeTransaction.ts
+++ b/src/libs/actions/MergeTransaction.ts
@@ -261,6 +261,7 @@ function getOnyxTargetTransactionData({
targetTransactionThreadReport?.reportID,
filteredTransactionChanges,
policy,
+ undefined,
shouldBuildOptimisticModifiedExpenseReportAction,
);
} else {
diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts
new file mode 100644
index 000000000000..9537b8dd4a5f
--- /dev/null
+++ b/src/libs/actions/TransactionInlineEdit.ts
@@ -0,0 +1,422 @@
+/**
+ * Actions for inline editing of transactions from the Search results table and the Expense Report page.
+ *
+ * Each function delegates to the corresponding IOU action which owns the canonical Onyx record,
+ * the API write, failure rollback, and snapshot updates (when a hash is provided).
+ */
+import Onyx from 'react-native-onyx';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import {isCategoryMissing} from '@libs/CategoryUtils';
+import {convertToBackendAmount, getCurrencyDecimals} from '@libs/CurrencyUtils';
+import {isValidMerchant, isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils';
+import {getIsOffline} from '@libs/NetworkState';
+import {hasEnabledOptions} from '@libs/OptionsListUtils';
+import Permissions from '@libs/Permissions';
+import {getTagLists, isMultiLevelTags} from '@libs/PolicyUtils';
+import {isMoneyRequestAction} from '@libs/ReportActionsUtils';
+import {
+ canEditFieldOfMoneyRequest,
+ canEditMoneyRequest,
+ canUserPerformWriteAction,
+ isArchivedReport,
+ isInvoiceReport,
+ isIOUReport,
+ isReportInGroupPolicy,
+ shouldEnableNegative,
+} from '@libs/ReportUtils';
+import {hasEnabledTags} from '@libs/TagsOptionsListUtils';
+import {calculateTaxAmount, getCurrency, getOriginalTransactionWithSplitInfo, getTaxValue, isDistanceRequest, isExpenseUnreported, isScanning} from '@libs/TransactionUtils';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {
+ Beta,
+ Policy,
+ PolicyCategories,
+ PolicyTagLists,
+ RecentlyUsedCategories,
+ RecentlyUsedTags,
+ Report,
+ ReportAction,
+ ReportNameValuePairs,
+ ReportNextStepDeprecated,
+ Transaction,
+ TransactionViolations,
+} from '@src/types/onyx';
+import {
+ updateMoneyRequestAmountAndCurrency,
+ updateMoneyRequestCategory,
+ updateMoneyRequestDate,
+ updateMoneyRequestDescription,
+ updateMoneyRequestMerchant,
+ updateMoneyRequestTag,
+} from './IOU/UpdateMoneyRequest';
+
+type TransactionEditPermissions = {
+ canEditDate: boolean;
+ canEditMerchant: boolean;
+ canEditDescription: boolean;
+ canEditCategory: boolean;
+ canEditAmount: boolean;
+ canEditTag: boolean;
+};
+
+let allTransactions: NonNullable> = {};
+Onyx.connectWithoutView({
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ allTransactions = value ?? {};
+ },
+});
+
+let allTransactionViolations: NonNullable> = {};
+Onyx.connectWithoutView({
+ key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ allTransactionViolations = value ?? {};
+ },
+});
+
+let currentUserAccountID: number = CONST.DEFAULT_NUMBER_ID;
+let currentUserEmail = '';
+Onyx.connectWithoutView({
+ key: ONYXKEYS.SESSION,
+ callback: (value) => {
+ currentUserEmail = value?.email ?? '';
+ currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID;
+ },
+});
+
+let allBetas: Beta[] | undefined;
+Onyx.connectWithoutView({
+ key: ONYXKEYS.BETAS,
+ callback: (value) => {
+ allBetas = value ?? undefined;
+ },
+});
+
+const NO_EDIT: Readonly = Object.freeze({
+ canEditDate: false,
+ canEditMerchant: false,
+ canEditDescription: false,
+ canEditCategory: false,
+ canEditAmount: false,
+ canEditTag: false,
+});
+
+type TransactionEditPermissionsParams = {
+ transaction: OnyxEntry;
+
+ parentReportAction: OnyxEntry;
+
+ parentReport: OnyxEntry;
+
+ policy?: OnyxEntry;
+
+ transactionThreadReport?: OnyxEntry;
+
+ policyCategories?: OnyxEntry;
+
+ policyTags?: OnyxEntry;
+
+ transactionThreadNVP?: OnyxEntry;
+
+ chatReportNVP?: OnyxEntry;
+
+ originalTransaction?: OnyxEntry;
+
+ /** When true, all editing is disabled regardless of permissions. */
+ disabled?: boolean;
+
+ /** When true, unreported expenses require workspace selection before category can be edited. */
+ shouldSelectPolicyForUnreported?: boolean;
+};
+
+type GetIouParamsInput = {
+ transactionID: string;
+ parentReport: OnyxEntry;
+ transactionThreadReport: OnyxEntry;
+ policy: OnyxEntry;
+ policyCategories: OnyxEntry;
+ policyTags: OnyxEntry;
+ policyRecentlyUsedCategories: OnyxEntry;
+ policyRecentlyUsedTags: OnyxEntry;
+ parentReportNextStep: OnyxEntry;
+};
+
+type TransactionInlineEditParams = GetIouParamsInput & {
+ hash: number | undefined;
+};
+
+/**
+ * @private
+ * Builds all params needed for IOU action calls.
+ * The returned object can be spread directly into any updateMoneyRequest* call
+ * (all shared fields are at the top level); field-specific extras like
+ * policyTagList, policyRecentlyUsedCategories, and transaction are also included.
+ */
+function getIouParamsForTransaction({
+ transactionID,
+ parentReport,
+ transactionThreadReport,
+ policy,
+ policyCategories,
+ policyTags,
+ policyRecentlyUsedCategories,
+ policyRecentlyUsedTags,
+ parentReportNextStep,
+}: GetIouParamsInput) {
+ const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
+
+ return {
+ // Shared base fields — spread directly into any updateMoneyRequest* call
+ transactionID,
+ transactionThreadReport,
+ parentReport,
+ policy,
+ policyCategories,
+ parentReportNextStep,
+ currentUserAccountIDParam: currentUserAccountID,
+ currentUserEmailParam: currentUserEmail,
+ isASAPSubmitBetaEnabled: Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, allBetas),
+ // Field-specific extras
+ transaction,
+ policyTagList: policyTags,
+ policyRecentlyUsedCategories,
+ policyRecentlyUsedTags,
+ };
+}
+
+/** Updates the date of an expense from the Search results table or the Expense Report page. */
+function editTransactionDateInline(params: TransactionInlineEditParams, newDate: string) {
+ const iouParams = getIouParamsForTransaction(params);
+ updateMoneyRequestDate({
+ ...iouParams,
+ // updateMoneyRequestDate uses 'policyTags' (not policyTagList)
+ policyTags: iouParams.policyTagList,
+ value: newDate,
+ transactions: allTransactions,
+ transactionViolations: allTransactionViolations,
+ isOffline: getIsOffline(),
+ hash: params.hash,
+ });
+}
+
+/** Updates the merchant of an expense from the Search results table or the Expense Report page. */
+function editTransactionMerchantInline(params: TransactionInlineEditParams, newMerchant: string) {
+ const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`];
+
+ if (!isValidMerchant(newMerchant, transaction, params.parentReport)) {
+ return;
+ }
+
+ const iouParams = getIouParamsForTransaction(params);
+ updateMoneyRequestMerchant({
+ ...iouParams,
+ value: newMerchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
+ hash: params.hash,
+ });
+}
+
+/** Updates the description of an expense from the Search results table or the Expense Report page. */
+function editTransactionDescriptionInline(params: TransactionInlineEditParams, newDescription: string) {
+ const iouParams = getIouParamsForTransaction(params);
+ updateMoneyRequestDescription({
+ ...iouParams,
+ comment: newDescription,
+ hash: params.hash,
+ });
+}
+
+/** Updates the category of an expense from the Search results table or the Expense Report page. */
+function editTransactionCategoryInline(params: TransactionInlineEditParams, newCategory: string) {
+ const iouParams = getIouParamsForTransaction(params);
+ updateMoneyRequestCategory({
+ ...iouParams,
+ category: newCategory,
+ hash: params.hash,
+ });
+}
+
+/** Updates the amount and currency of an expense from the Search results table or the Expense Report page. */
+function editTransactionAmountInline(params: TransactionInlineEditParams, newAmount: number) {
+ const iouParams = getIouParamsForTransaction(params);
+ const iouType = isInvoiceReport(params.parentReport) ? CONST.IOU.TYPE.INVOICE : CONST.IOU.TYPE.SUBMIT;
+ const allowNegative = shouldEnableNegative(params.parentReport, iouParams.policy, iouType);
+ const isP2P = isIOUReport(params.parentReport);
+
+ if (!isValidMoneyRequestAmount(newAmount, iouType, allowNegative, isP2P)) {
+ return;
+ }
+
+ // Keep the existing currency — only the amount is changing from the search table
+ const currency = iouParams.transaction?.modifiedCurrency ?? iouParams.transaction?.currency ?? CONST.CURRENCY.USD;
+ // Recalculate tax from the existing tax code and the new amount
+ const taxCode = iouParams.transaction?.taxCode ?? '';
+ const taxPercentage = getTaxValue(iouParams.policy, iouParams.transaction, taxCode) ?? '';
+ const decimals = getCurrencyDecimals(getCurrency(iouParams.transaction));
+ const taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, newAmount, decimals));
+ updateMoneyRequestAmountAndCurrency({
+ ...iouParams,
+ amount: newAmount,
+ currency,
+ taxAmount,
+ taxCode,
+ taxValue: taxPercentage,
+ allowNegative,
+ transactions: allTransactions,
+ transactionViolations: allTransactionViolations,
+ policyRecentlyUsedCurrencies: [],
+ hash: params.hash,
+ });
+}
+
+/** Updates the tag of an expense from the Search results table or the Expense Report page. */
+function editTransactionTagInline(params: TransactionInlineEditParams, newTag: string) {
+ const iouParams = getIouParamsForTransaction(params);
+ updateMoneyRequestTag({
+ ...iouParams,
+ tag: newTag,
+ policyRecentlyUsedTags: iouParams.policyRecentlyUsedTags,
+ hash: params.hash,
+ });
+}
+
+/**
+ * Core inline-edit permission check, shared by the Search table and the Expense Report page.
+ * Mirrors MoneyRequestView's permission logic:
+ * 1. isEditable = canUserPerformWriteAction(transactionThreadReport)
+ * 2. canEdit = isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(...) && isEditable
+ * 3. Restricted fields (date, merchant, amount): canEditFieldOfMoneyRequest per field
+ * 4. Non-restricted fields (description, category, tag): canEdit + policy feature flags
+ */
+function getTransactionEditPermissions({
+ transaction,
+ parentReportAction,
+ parentReport,
+ policy,
+ transactionThreadReport,
+ policyCategories,
+ policyTags,
+ transactionThreadNVP,
+ chatReportNVP,
+ originalTransaction,
+ disabled,
+ shouldSelectPolicyForUnreported,
+}: TransactionEditPermissionsParams): TransactionEditPermissions {
+ if (disabled || !transaction) {
+ return NO_EDIT;
+ }
+
+ const isUnreported = isExpenseUnreported(transaction);
+ const isChatReportArchived = isArchivedReport(chatReportNVP);
+ const isTransactionThreadArchived = isArchivedReport(transactionThreadNVP);
+ const isTransactionScanning = isScanning(transaction);
+
+ // Matches MoneyRequestView's isEditable.
+ // For unreported expenses the user always owns these. When the transaction
+ // thread report hasn't been loaded into Onyx yet (common in Search), we
+ // can't evaluate canUserPerformWriteAction — skip the check and let
+ // canEditMoneyRequest be the gatekeeper instead of blocking all editing.
+ const isEditable = isUnreported || !transactionThreadReport || !!canUserPerformWriteAction(transactionThreadReport, isTransactionThreadArchived);
+ if (!isEditable) {
+ return NO_EDIT;
+ }
+
+ // Matches MoneyRequestView's canEdit.
+ // For unreported expenses, parentReportAction may not be loaded; they are
+ // always editable by the owner.
+ const canEdit = isUnreported || (isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, isChatReportArchived, parentReport, policy));
+ if (!canEdit) {
+ return NO_EDIT;
+ }
+
+ // For restricted fields, delegate to canEditFieldOfMoneyRequest.
+ // Unreported expenses bypass this (all restricted fields editable by owner).
+ const canEditRestricted = (field: ValueOf) => {
+ if (field === CONST.EDIT_REQUEST_FIELD.AMOUNT) {
+ // Split expense children cannot have their amount edited inline
+ const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction);
+
+ if (isExpenseSplit) {
+ return false;
+ }
+
+ // Amount field shows "Scanning..." during SmartScan
+ if (isTransactionScanning) {
+ return false;
+ }
+ }
+
+ if (field === CONST.EDIT_REQUEST_FIELD.MERCHANT) {
+ // Distance expenses cannot have their merchant edited
+ if (isDistanceRequest(transaction)) {
+ return false;
+ }
+
+ // Merchant field shows "Scanning..." during SmartScan
+ if (isTransactionScanning) {
+ return false;
+ }
+ }
+
+ if (field === CONST.EDIT_REQUEST_FIELD.CATEGORY) {
+ if (!policy?.areCategoriesEnabled && isCategoryMissing(transaction?.category)) {
+ return false;
+ }
+ // Matches MoneyRequestView's shouldShowCategory logic
+ // For policy expenses, check if there's a category or enabled options
+ if (isReportInGroupPolicy(parentReport, policy)) {
+ return !!(transaction?.category ?? '') || hasEnabledOptions(policyCategories ?? {});
+ }
+ // For unreported expenses, disable inline category editing while workspace selection is required.
+ if (isUnreported) {
+ return !shouldSelectPolicyForUnreported && hasEnabledOptions(policyCategories ?? {});
+ }
+ }
+
+ if (field === CONST.EDIT_REQUEST_FIELD.TAG) {
+ // Single-level tags only (multi-level needs a picker UI not available inline)
+ if (isMultiLevelTags(policyTags)) {
+ return false;
+ }
+ return !!transaction?.tag || hasEnabledTags(getTagLists(policyTags));
+ }
+
+ return (
+ isUnreported ||
+ canEditFieldOfMoneyRequest({
+ reportAction: parentReportAction,
+ fieldToEdit: field,
+ isChatReportArchived,
+ transaction,
+ report: parentReport,
+ policy,
+ })
+ );
+ };
+
+ return {
+ canEditDate: canEditRestricted(CONST.EDIT_REQUEST_FIELD.DATE),
+ canEditMerchant: canEditRestricted(CONST.EDIT_REQUEST_FIELD.MERCHANT),
+ // Non-restricted; always editable when canEdit is true
+ canEditDescription: true,
+ canEditCategory: canEditRestricted(CONST.EDIT_REQUEST_FIELD.CATEGORY),
+ canEditAmount: canEditRestricted(CONST.EDIT_REQUEST_FIELD.AMOUNT),
+ canEditTag: canEditRestricted(CONST.EDIT_REQUEST_FIELD.TAG),
+ };
+}
+
+export {
+ editTransactionDateInline,
+ editTransactionMerchantInline,
+ editTransactionDescriptionInline,
+ editTransactionCategoryInline,
+ editTransactionAmountInline,
+ editTransactionTagInline,
+ getTransactionEditPermissions,
+};
+
+export type {TransactionEditPermissions, TransactionInlineEditParams, TransactionEditPermissionsParams};
diff --git a/src/styles/index.ts b/src/styles/index.ts
index acabe0c39d15..c8453138070f 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -1127,6 +1127,57 @@ const staticStyles = (theme: ThemeColors) =>
borderColor: theme.bordersBold,
},
+ /**
+ * Matches the border and padding of editableCell so column headers stay
+ * visually aligned with their editable cells.
+ */
+ editableCellHeader: {
+ borderWidth: 1,
+ borderRadius: variables.componentBorderRadius,
+ borderColor: 'transparent',
+ paddingHorizontal: 4,
+ },
+
+ editableCell: {
+ width: '100%',
+ borderWidth: 1,
+ borderRadius: variables.componentBorderRadius,
+ borderColor: 'transparent',
+ padding: 4,
+ height: 'auto',
+ minHeight: variables.editableCellHeight,
+ overflow: 'hidden',
+ justifyContent: 'center',
+ },
+
+ editableCellHover: {
+ borderColor: theme.buttonHoveredBG,
+ },
+
+ editableCellFocus: {
+ borderColor: theme.borderFocus,
+ backgroundColor: theme.appBG,
+ },
+
+ /** Suppresses all visual styling on TextInput when rendered inside EditableCell */
+ editableCellInputStyle: {
+ padding: 0,
+ borderWidth: 0,
+ borderColor: 'transparent',
+ borderRadius: 0,
+ backgroundColor: 'transparent',
+ height: '100%',
+ minHeight: 0,
+ fontSize: variables.fontSizeNormal,
+ },
+
+ editableCellSymbolStyle: {
+ ...FontUtils.fontFamily.platform.EXP_NEUE,
+ color: theme.textSupporting,
+ fontSize: variables.fontSizeNormal,
+ lineHeight: variables.fontSizeNormalHeight,
+ },
+
borderColorFocus: {
borderColor: theme.borderFocus,
},
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 560a397679ed..4350b3254015 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1916,7 +1916,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
columnWidth = {...getWidthStyle(isExportedColumnWide ? variables.w92 : variables.w72)};
break;
case CONST.SEARCH.TABLE_COLUMNS.DATE:
- columnWidth = {...getWidthStyle(isDateColumnWide ? variables.w92 : variables.w52)};
+ columnWidth = {...getWidthStyle(isDateColumnWide ? variables.w92 : variables.w62)};
break;
case CONST.SEARCH.TABLE_COLUMNS.WITHDRAWN:
case CONST.SEARCH.TABLE_COLUMNS.GROUP_WITHDRAWN:
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 43a9625f1e9a..92da80ce803b 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -147,6 +147,7 @@ export default {
inputPaddingTop: getValueUsingPixelRatio(15, 21),
inputPaddingBottom: getValueUsingPixelRatio(8, 11),
inputHeightSmall: 28,
+ editableCellHeight: 32,
inputIconMarginTopSmall: getValueUsingPixelRatio(8, 11),
inputIconMarginTopLarge: getValueUsingPixelRatio(16, 21),
formErrorLineHeight: getValueUsingPixelRatio(18, 23),
@@ -377,6 +378,7 @@ export default {
w44: 44,
w46: 46,
w52: 52,
+ w62: 62,
w68: 68,
w72: 72,
w80: 80,
diff --git a/tests/ui/CategoryListItemHeaderTest.tsx b/tests/ui/CategoryListItemHeaderTest.tsx
index 70ef3916fa4b..6931285b8051 100644
--- a/tests/ui/CategoryListItemHeaderTest.tsx
+++ b/tests/ui/CategoryListItemHeaderTest.tsx
@@ -41,6 +41,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
+ hasSelectedTransactions: false,
} satisfies SearchStateContextValue;
const mockSearchActionsContext = {
diff --git a/tests/ui/MerchantListItemHeaderTest.tsx b/tests/ui/MerchantListItemHeaderTest.tsx
index 83b3a85a4fa6..b7ac8ee1c4f9 100644
--- a/tests/ui/MerchantListItemHeaderTest.tsx
+++ b/tests/ui/MerchantListItemHeaderTest.tsx
@@ -41,6 +41,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
+ hasSelectedTransactions: false,
} satisfies SearchStateContextValue;
const mockSearchActionsContext = {
diff --git a/tests/ui/MonthListItemHeaderTest.tsx b/tests/ui/MonthListItemHeaderTest.tsx
index 4f55dbaabef8..9f5fda8c521a 100644
--- a/tests/ui/MonthListItemHeaderTest.tsx
+++ b/tests/ui/MonthListItemHeaderTest.tsx
@@ -41,6 +41,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
+ hasSelectedTransactions: false,
} satisfies SearchStateContextValue;
const mockSearchActionsContext = {
diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx
index d926be4761a3..d87789e1428f 100644
--- a/tests/ui/WeekListItemHeaderTest.tsx
+++ b/tests/ui/WeekListItemHeaderTest.tsx
@@ -40,6 +40,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
+ hasSelectedTransactions: false,
} satisfies SearchStateContextValue;
const mockSearchActionsContext = {
diff --git a/tests/ui/YearListItemHeaderTest.tsx b/tests/ui/YearListItemHeaderTest.tsx
index 7bb9c96a3e83..5483298246c0 100644
--- a/tests/ui/YearListItemHeaderTest.tsx
+++ b/tests/ui/YearListItemHeaderTest.tsx
@@ -41,6 +41,7 @@ const mockSearchStateContext = {
shouldUseLiveData: false,
currentSimilarSearchHash: -1,
suggestedSearches: {} as SearchStateContextValue['suggestedSearches'],
+ hasSelectedTransactions: false,
} satisfies SearchStateContextValue;
const mockSearchActionsContext = {
diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts
index d2dbabc686ce..13decab07708 100644
--- a/tests/unit/MoneyRequestUtilsTest.ts
+++ b/tests/unit/MoneyRequestUtilsTest.ts
@@ -1,6 +1,8 @@
import {isValidPerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem';
-import {handleNegativeAmountFlipping, validateAmount, validatePercentage} from '@libs/MoneyRequestUtils';
+import {handleNegativeAmountFlipping, isValidMerchant, isValidMoneyRequestAmount, validateAmount, validatePercentage} from '@libs/MoneyRequestUtils';
import CONST from '@src/CONST';
+import type Report from '@src/types/onyx/Report';
+import type Transaction from '@src/types/onyx/Transaction';
import type {TransactionCustomUnit} from '@src/types/onyx/Transaction';
describe('ReportActionsUtils', () => {
@@ -188,4 +190,126 @@ describe('ReportActionsUtils', () => {
expect(isValidPerDiemExpenseAmount(customUnit, 2)).toBe(true);
});
});
+
+ describe('isValidMoneyRequestAmount', () => {
+ describe('invalid inputs', () => {
+ it('should return false for nullish and NaN values', () => {
+ expect(isValidMoneyRequestAmount(undefined, CONST.IOU.TYPE.SUBMIT)).toBe(false);
+ expect(isValidMoneyRequestAmount(null as unknown as number, CONST.IOU.TYPE.SUBMIT)).toBe(false);
+ expect(isValidMoneyRequestAmount(NaN, CONST.IOU.TYPE.SUBMIT)).toBe(false);
+ });
+ });
+
+ describe('negative amounts', () => {
+ it('should respect the allowNegative flag', () => {
+ expect(isValidMoneyRequestAmount(-100, CONST.IOU.TYPE.SUBMIT, false)).toBe(false);
+ expect(isValidMoneyRequestAmount(-100, CONST.IOU.TYPE.SUBMIT, true)).toBe(true);
+ expect(isValidMoneyRequestAmount(100, CONST.IOU.TYPE.SUBMIT, false)).toBe(true);
+ });
+ });
+
+ describe('P2P (peer-to-peer) transactions', () => {
+ const allowNegative = true;
+ const isP2P = true;
+
+ it('should return false for zero or sub-cent amounts', () => {
+ expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.REQUEST, allowNegative, isP2P)).toBe(false);
+ expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.SUBMIT, allowNegative, isP2P)).toBe(false);
+ });
+
+ it('should return true for amounts >= 1 cent', () => {
+ expect(isValidMoneyRequestAmount(1, CONST.IOU.TYPE.REQUEST, allowNegative, isP2P)).toBe(true);
+ expect(isValidMoneyRequestAmount(100, CONST.IOU.TYPE.REQUEST, allowNegative, isP2P)).toBe(true);
+ expect(isValidMoneyRequestAmount(1, CONST.IOU.TYPE.SUBMIT, allowNegative, isP2P)).toBe(true);
+ });
+ });
+
+ describe('non-zero IOU types (PAY, INVOICE, SPLIT)', () => {
+ it('should return false for zero amount', () => {
+ expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.PAY)).toBe(false);
+ expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.INVOICE)).toBe(false);
+ expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.SPLIT)).toBe(false);
+ });
+
+ it('should return true for amounts >= 1 cent', () => {
+ expect(isValidMoneyRequestAmount(1, CONST.IOU.TYPE.PAY)).toBe(true);
+ expect(isValidMoneyRequestAmount(1, CONST.IOU.TYPE.INVOICE)).toBe(true);
+ expect(isValidMoneyRequestAmount(1, CONST.IOU.TYPE.SPLIT)).toBe(true);
+ });
+ });
+
+ describe('SUBMIT and REQUEST types (non-P2P)', () => {
+ it('should allow zero, positive, and negative amounts', () => {
+ const allowNegative = true;
+ const isP2P = false;
+ expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.SUBMIT, allowNegative, isP2P)).toBe(true);
+ expect(isValidMoneyRequestAmount(100, CONST.IOU.TYPE.SUBMIT, allowNegative, isP2P)).toBe(true);
+ expect(isValidMoneyRequestAmount(-100, CONST.IOU.TYPE.SUBMIT, allowNegative, isP2P)).toBe(true);
+ expect(isValidMoneyRequestAmount(0, CONST.IOU.TYPE.REQUEST, allowNegative, isP2P)).toBe(true);
+ expect(isValidMoneyRequestAmount(100, CONST.IOU.TYPE.REQUEST, allowNegative, isP2P)).toBe(true);
+ });
+ });
+ });
+
+ describe('isValidMerchant', () => {
+ const iouReport = {
+ reportID: '1',
+ type: CONST.REPORT.TYPE.IOU,
+ } as Report;
+
+ const expenseReport = {
+ reportID: '123',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ } as Report;
+
+ const unreportedTransaction = {
+ reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
+ amount: 0,
+ } as Transaction;
+
+ const reportedTransaction = {
+ reportID: '123',
+ amount: 0,
+ } as Transaction;
+
+ describe('empty merchants', () => {
+ it('should return true for empty/undefined merchant when transaction is unreported or IOU', () => {
+ expect(isValidMerchant('', unreportedTransaction)).toBe(true);
+ expect(isValidMerchant(' ', unreportedTransaction)).toBe(true);
+ expect(isValidMerchant(undefined, unreportedTransaction)).toBe(true);
+
+ expect(isValidMerchant('', reportedTransaction, iouReport)).toBe(true);
+ expect(isValidMerchant(' ', reportedTransaction, iouReport)).toBe(true);
+ expect(isValidMerchant(undefined, reportedTransaction, iouReport)).toBe(true);
+ });
+
+ it('should return false for empty/undefined merchant when transaction is reported or missing', () => {
+ expect(isValidMerchant('', reportedTransaction)).toBe(false);
+ expect(isValidMerchant('', reportedTransaction, expenseReport)).toBe(false);
+ expect(isValidMerchant(' ', reportedTransaction, expenseReport)).toBe(false);
+ expect(isValidMerchant(undefined, reportedTransaction, expenseReport)).toBe(false);
+ expect(isValidMerchant('')).toBe(false);
+ });
+ });
+
+ describe('invalid merchant constants', () => {
+ it('should return false for invalid merchant constants regardless of transaction', () => {
+ expect(isValidMerchant(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)).toBe(false);
+ expect(isValidMerchant(CONST.TRANSACTION.DEFAULT_MERCHANT)).toBe(false);
+ expect(isValidMerchant(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, unreportedTransaction)).toBe(false);
+ });
+ });
+
+ describe('byte length validation', () => {
+ it('should respect the 255 byte limit', () => {
+ expect(isValidMerchant('Valid Merchant Name')).toBe(true);
+ expect(isValidMerchant('a'.repeat(CONST.MERCHANT_NAME_MAX_BYTES))).toBe(true);
+ expect(isValidMerchant('a'.repeat(CONST.MERCHANT_NAME_MAX_BYTES + 1))).toBe(false);
+ });
+ });
+
+ it('should trim whitespace before validation', () => {
+ expect(isValidMerchant(' Valid Merchant ')).toBe(true);
+ });
+ });
});
diff --git a/tests/unit/TransactionInlineEditTest.ts b/tests/unit/TransactionInlineEditTest.ts
new file mode 100644
index 000000000000..f74c358017c1
--- /dev/null
+++ b/tests/unit/TransactionInlineEditTest.ts
@@ -0,0 +1,398 @@
+import type {TransactionEditPermissions, TransactionEditPermissionsParams} from '@libs/actions/TransactionInlineEdit';
+import {getTransactionEditPermissions} from '@libs/actions/TransactionInlineEdit';
+import CONST from '@src/CONST';
+import type {Policy, PolicyCategories, PolicyTagLists, Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx';
+
+describe('getTransactionEditPermissions', () => {
+ // Use unreported transaction by default to bypass most permission checks
+ const baseTransaction: Transaction = {
+ transactionID: '1',
+ reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
+ amount: 1000,
+ currency: 'USD',
+ merchant: 'Test Merchant',
+ created: '2024-01-01',
+ comment: {
+ comment: 'Test comment',
+ },
+ };
+
+ const baseParentReportAction: ReportAction = {
+ reportActionID: '1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
+ actorAccountID: 1,
+ created: '2024-01-01',
+ message: [],
+ originalMessage: {
+ IOUTransactionID: '1',
+ amount: 1000,
+ currency: 'USD',
+ type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
+ },
+ };
+
+ const baseParentReport: Report = {
+ reportID: '100',
+ ownerAccountID: 1,
+ managerID: 1,
+ policyID: '1',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ statusNum: CONST.REPORT.STATUS_NUM.OPEN,
+ };
+
+ const basePolicy: Policy = {
+ id: '1',
+ name: 'Test Policy',
+ role: 'admin',
+ type: CONST.POLICY.TYPE.TEAM,
+ owner: '',
+ outputCurrency: 'USD',
+ isPolicyExpenseChatEnabled: false,
+ areCategoriesEnabled: true,
+ };
+
+ const baseParams: TransactionEditPermissionsParams = {
+ transaction: baseTransaction,
+ parentReportAction: baseParentReportAction,
+ parentReport: baseParentReport,
+ policy: basePolicy,
+ };
+
+ const policyCategories: PolicyCategories = {
+ Food: {name: 'Food', enabled: true},
+ Travel: {name: 'Travel', enabled: true},
+ };
+
+ const singleLevelTags: PolicyTagLists = {
+ Tag: {
+ name: 'Tag',
+ required: false,
+ orderWeight: 1,
+ tags: {
+ Project1: {name: 'Project1', enabled: true},
+ Project2: {name: 'Project2', enabled: true},
+ },
+ },
+ };
+
+ const baseUnreportedParams: TransactionEditPermissionsParams = {
+ ...baseParams,
+ parentReportAction: undefined,
+ parentReport: undefined,
+ policyCategories,
+ policyTags: singleLevelTags,
+ };
+
+ const allFalsePermissions: TransactionEditPermissions = {
+ canEditDate: false,
+ canEditMerchant: false,
+ canEditDescription: false,
+ canEditCategory: false,
+ canEditAmount: false,
+ canEditTag: false,
+ } as const;
+
+ describe('disabled flag', () => {
+ it('should return all false when disabled is true', () => {
+ const permissions = getTransactionEditPermissions({
+ ...baseParams,
+ disabled: true,
+ });
+
+ expect(permissions).toEqual(allFalsePermissions);
+ });
+ });
+
+ describe('missing transaction', () => {
+ it('should return all false when transaction is undefined', () => {
+ const permissions = getTransactionEditPermissions({
+ ...baseParams,
+ transaction: undefined,
+ });
+
+ expect(permissions).toEqual(allFalsePermissions);
+ });
+ });
+
+ describe('scanning transactions', () => {
+ it('should handle field permissions correctly while transaction is scanning', () => {
+ const scanningTransaction: Transaction = {
+ ...baseTransaction,
+ reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
+ merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
+ amount: 0,
+ receipt: {
+ state: CONST.IOU.RECEIPT_STATE.SCANNING,
+ },
+ };
+
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: scanningTransaction,
+ });
+
+ expect(permissions).toMatchObject({
+ canEditCategory: true,
+ canEditDate: true,
+ canEditDescription: true,
+ canEditTag: true,
+ // Amount and merchant editing should be disabled for scanning transactions
+ canEditAmount: false,
+ canEditMerchant: false,
+ } satisfies TransactionEditPermissions);
+ });
+ });
+
+ describe('distance requests', () => {
+ it('should handle field permissions correctly for distance requests', () => {
+ const distanceTransaction: Transaction = {
+ ...baseTransaction,
+ reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
+ comment: {
+ type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT,
+ customUnit: {
+ name: CONST.CUSTOM_UNITS.NAME_DISTANCE,
+ },
+ },
+ };
+
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: distanceTransaction,
+ });
+
+ expect(permissions).toMatchObject({
+ canEditCategory: true,
+ canEditDate: true,
+ canEditDescription: true,
+ canEditTag: true,
+ canEditAmount: true,
+ // Merchant editing should be disabled for distance requests
+ canEditMerchant: false,
+ } satisfies TransactionEditPermissions);
+ });
+ });
+
+ describe('split expenses', () => {
+ it('should handle field permissions correctly for split expense children', () => {
+ const splitTransaction: Transaction = {
+ ...baseTransaction,
+ reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
+ comment: {
+ originalTransactionID: 'original123',
+ source: CONST.IOU.TYPE.SPLIT,
+ },
+ };
+
+ const originalTransaction: Transaction = {
+ ...baseTransaction,
+ transactionID: 'original123',
+ reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
+ comment: {},
+ };
+
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: splitTransaction,
+ originalTransaction,
+ });
+
+ expect(permissions).toMatchObject({
+ canEditCategory: true,
+ canEditDate: true,
+ canEditDescription: true,
+ canEditTag: true,
+ canEditMerchant: true,
+ // Amount editing should be disabled for split expense children
+ canEditAmount: false,
+ } satisfies TransactionEditPermissions);
+ });
+ });
+
+ describe('category permissions', () => {
+ it('should disable category editing when categories are not enabled on policy', () => {
+ const policyWithoutCategories: Policy = {
+ ...basePolicy,
+ areCategoriesEnabled: false,
+ };
+
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ policy: policyWithoutCategories,
+ });
+
+ expect(permissions.canEditCategory).toBe(false);
+ });
+
+ it('should enable category editing when transaction already has a category', () => {
+ const transactionWithCategory: Transaction = {
+ ...baseTransaction,
+ reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
+ category: 'Food',
+ };
+
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: transactionWithCategory,
+ });
+
+ expect(permissions.canEditCategory).toBe(true);
+ });
+ });
+
+ describe('tag permissions', () => {
+ it('should disable tag editing for multi-level tags', () => {
+ const multiLevelTags: PolicyTagLists = {
+ Department: {
+ name: 'Department',
+ required: false,
+ orderWeight: 1,
+ tags: {
+ Engineering: {name: 'Engineering', enabled: true},
+ },
+ },
+ Team: {
+ name: 'Team',
+ required: false,
+ orderWeight: 2,
+ tags: {
+ Frontend: {name: 'Frontend', enabled: true},
+ },
+ },
+ };
+
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ policyTags: multiLevelTags,
+ });
+
+ expect(permissions.canEditTag).toBe(false);
+ });
+
+ it('should enable tag editing when transaction already has a tag', () => {
+ const transactionWithTag: Transaction = {
+ ...baseTransaction,
+ reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
+ tag: 'Project1',
+ };
+
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: transactionWithTag,
+ policyTags: undefined,
+ });
+
+ expect(permissions.canEditTag).toBe(true);
+ });
+ });
+
+ describe('unreported expenses', () => {
+ const unreportedTransaction: Transaction = {
+ ...baseTransaction,
+ reportID: CONST.REPORT.UNREPORTED_REPORT_ID,
+ };
+
+ it('should allow editing all fields', () => {
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: unreportedTransaction,
+ });
+
+ expect(permissions).toMatchObject({
+ canEditAmount: true,
+ canEditDate: true,
+ canEditDescription: true,
+ canEditMerchant: true,
+ canEditCategory: true,
+ canEditTag: true,
+ } satisfies TransactionEditPermissions);
+ });
+
+ it('should disable category and tag editing without available options', () => {
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: unreportedTransaction,
+ policyCategories: undefined,
+ policyTags: undefined,
+ });
+
+ expect(permissions).toMatchObject({
+ canEditAmount: true,
+ canEditDate: true,
+ canEditDescription: true,
+ canEditMerchant: true,
+ canEditCategory: false,
+ canEditTag: false,
+ } satisfies TransactionEditPermissions);
+ });
+
+ it('should respect scanning restrictions', () => {
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: {
+ ...unreportedTransaction,
+ merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
+ amount: 0,
+ receipt: {state: CONST.IOU.RECEIPT_STATE.SCANNING},
+ },
+ });
+
+ expect(permissions).toMatchObject({
+ canEditAmount: false,
+ canEditMerchant: false,
+ } satisfies Partial);
+ });
+
+ it('should respect distance request restrictions', () => {
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: {
+ ...unreportedTransaction,
+ comment: {
+ type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT,
+ customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE},
+ },
+ },
+ });
+
+ expect(permissions).toMatchObject({
+ canEditMerchant: false,
+ } satisfies Partial);
+ });
+
+ it('should disable category editing when workspace selection is required', () => {
+ const permissions = getTransactionEditPermissions({
+ ...baseUnreportedParams,
+ transaction: unreportedTransaction,
+ // No policy context yet since workspace selection is pending
+ policy: undefined,
+ shouldSelectPolicyForUnreported: true,
+ });
+
+ expect(permissions).toMatchObject({
+ canEditCategory: false,
+ } satisfies Partial);
+ });
+ });
+
+ describe('archived reports', () => {
+ it('should disable all editing when chat report is archived', () => {
+ const reportedTransaction: Transaction = {
+ ...baseTransaction,
+ reportID: '100',
+ };
+
+ const chatReportNVP: ReportNameValuePairs = {
+ private_isArchived: 'true',
+ };
+
+ const permissions = getTransactionEditPermissions({
+ ...baseParams,
+ transaction: reportedTransaction,
+ chatReportNVP,
+ });
+
+ expect(permissions).toEqual(allFalsePermissions);
+ });
+ });
+});
diff --git a/tests/unit/hooks/useInlineEditState.test.ts b/tests/unit/hooks/useInlineEditState.test.ts
new file mode 100644
index 000000000000..8869fad611b0
--- /dev/null
+++ b/tests/unit/hooks/useInlineEditState.test.ts
@@ -0,0 +1,238 @@
+import {act, renderHook} from '@testing-library/react-native';
+import type {Dispatch, SetStateAction} from 'react';
+import useInlineEditState from '@components/Table/EditableCell/useInlineEditState';
+
+type SetupOptions = {
+ canEdit?: boolean;
+ onSave?: (value: T) => void;
+ isEqual?: (newValue: T, originalValue: T) => boolean;
+};
+
+type WidenLiteral = T extends string ? string : T extends number ? number : T extends boolean ? boolean : T;
+
+type HookProps = {
+ value: T;
+ canEdit: boolean;
+};
+
+type HookResult = {
+ current: {
+ isEditing: boolean;
+ localValue: T;
+ setLocalValue: Dispatch>;
+ startEditing: () => void;
+ save: () => void;
+ cancelEditing: () => void;
+ };
+};
+
+const setup = (value: T, {canEdit = true, onSave, isEqual}: SetupOptions> = {}) =>
+ renderHook(({value: currentValue, canEdit: currentCanEdit}: HookProps>) => useInlineEditState>(currentCanEdit, currentValue, onSave, isEqual), {
+ initialProps: {value: value as WidenLiteral, canEdit},
+ });
+
+const startEditing = (result: HookResult) => {
+ act(() => result.current.startEditing());
+};
+
+const setValue = (result: HookResult, value: T) => {
+ act(() => result.current.setLocalValue(value));
+};
+
+const save = (result: HookResult) => {
+ act(() => result.current.save());
+};
+
+const cancelEditing = (result: HookResult) => {
+ act(() => result.current.cancelEditing());
+};
+
+describe('useInlineEditState', () => {
+ it('starts with isEditing=false and localValue equal to the initial value', () => {
+ const {result} = setup('hello');
+
+ expect(result.current.isEditing).toBe(false);
+ expect(result.current.localValue).toBe('hello');
+ });
+
+ it('save calls onSave with the new value when localValue differs from original', () => {
+ const onSave = jest.fn();
+ const {result} = setup('hello', {onSave});
+
+ startEditing(result);
+ setValue(result, 'world');
+ save(result);
+
+ expect(onSave).toHaveBeenCalledWith('world');
+ expect(result.current.isEditing).toBe(false);
+ expect(result.current.localValue).toBe('hello');
+ });
+
+ it('save does not call onSave when localValue matches the original value', () => {
+ const onSave = jest.fn();
+ const {result} = setup('hello', {onSave});
+
+ startEditing(result);
+ save(result);
+
+ expect(onSave).not.toHaveBeenCalled();
+ expect(result.current.isEditing).toBe(false);
+ });
+
+ it('uses isEqual to skip redundant saves after normalization', () => {
+ const onSave = jest.fn();
+ const isEqual = jest.fn((newValue: string, originalValue: string) => newValue.trim() === originalValue.trim());
+ const {result} = setup('hello', {onSave, isEqual});
+
+ startEditing(result);
+ setValue(result, ' hello ');
+ save(result);
+
+ expect(onSave).not.toHaveBeenCalled();
+ expect(result.current.isEditing).toBe(false);
+ expect(result.current.localValue).toBe('hello');
+ });
+
+ it('calls onSave when isEqual returns false', () => {
+ const onSave = jest.fn();
+ const isEqual = jest.fn((newValue: string, originalValue: string) => newValue.length === originalValue.length);
+ const {result} = setup('hello', {onSave, isEqual});
+
+ startEditing(result);
+ setValue(result, 'world!');
+ save(result);
+
+ expect(isEqual).toHaveBeenCalledWith('world!', 'hello');
+ expect(onSave).toHaveBeenCalledWith('world!');
+ expect(result.current.localValue).toBe('hello');
+ });
+
+ it('save exits cleanly when onSave is undefined and the value changed', () => {
+ const {result} = setup('hello');
+
+ startEditing(result);
+ setValue(result, 'changed');
+
+ expect(() => save(result)).not.toThrow();
+ expect(result.current.isEditing).toBe(false);
+ expect(result.current.localValue).toBe('hello');
+ });
+
+ it('cancel resets localValue to the original and sets isEditing to false', () => {
+ const onSave = jest.fn();
+ const {result} = setup('hello', {onSave});
+
+ startEditing(result);
+ setValue(result, 'modified');
+ expect(result.current.localValue).toBe('modified');
+
+ cancelEditing(result);
+
+ expect(result.current.localValue).toBe('hello');
+ expect(result.current.isEditing).toBe(false);
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it('syncs localValue when the external value prop changes', () => {
+ const {result, rerender} = setup('initial');
+
+ expect(result.current.localValue).toBe('initial');
+
+ rerender({value: 'updated', canEdit: true});
+
+ expect(result.current.localValue).toBe('updated');
+ });
+
+ it('syncs localValue to the external value even while editing', () => {
+ const {result, rerender} = setup('initial');
+
+ startEditing(result);
+ setValue(result, 'draft');
+
+ expect(result.current.localValue).toBe('draft');
+
+ rerender({value: 'updated externally', canEdit: true});
+
+ expect(result.current.isEditing).toBe(true);
+ expect(result.current.localValue).toBe('updated externally');
+ });
+
+ it('cancels editing when canEdit becomes false while editing', () => {
+ const onSave = jest.fn();
+ const {result, rerender} = setup('hello', {canEdit: true, onSave});
+
+ startEditing(result);
+
+ expect(result.current.isEditing).toBe(true);
+
+ setValue(result, 'modified');
+
+ rerender({value: 'hello', canEdit: false});
+
+ expect(result.current.isEditing).toBe(false);
+ expect(result.current.localValue).toBe('hello');
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it('prevents duplicate onSave calls when save is called multiple times', () => {
+ const onSave = jest.fn();
+ const {result} = setup('hello', {onSave});
+
+ startEditing(result);
+ setValue(result, 'world');
+
+ act(() => {
+ result.current.save();
+ result.current.save();
+ });
+
+ expect(onSave).toHaveBeenCalledTimes(1);
+ expect(onSave).toHaveBeenCalledWith('world');
+ });
+
+ it('ignores cancelEditing after save has already ended editing', () => {
+ const onSave = jest.fn();
+ const {result} = setup('hello', {onSave});
+
+ startEditing(result);
+ setValue(result, 'world');
+
+ act(() => {
+ result.current.save();
+ result.current.cancelEditing();
+ });
+
+ expect(onSave).toHaveBeenCalledTimes(1);
+ expect(onSave).toHaveBeenCalledWith('world');
+ expect(result.current.isEditing).toBe(false);
+ expect(result.current.localValue).toBe('hello');
+ });
+
+ it('ignores save after cancelEditing has already ended editing', () => {
+ const onSave = jest.fn();
+ const {result} = setup('hello', {onSave});
+
+ startEditing(result);
+ setValue(result, 'world');
+
+ act(() => {
+ result.current.cancelEditing();
+ result.current.save();
+ });
+
+ expect(onSave).not.toHaveBeenCalled();
+ expect(result.current.isEditing).toBe(false);
+ expect(result.current.localValue).toBe('hello');
+ });
+
+ it('auto-cancels after starting to edit when canEdit is already false', () => {
+ const onSave = jest.fn();
+ const {result} = setup('hello', {canEdit: false, onSave});
+
+ startEditing(result);
+
+ expect(result.current.isEditing).toBe(false);
+ expect(result.current.localValue).toBe('hello');
+ expect(onSave).not.toHaveBeenCalled();
+ });
+});