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(); + }); +});