Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
df224eb
saved-expense-text-inputs
DylanDylann Nov 6, 2026
d276574
saved-expense-text-inputs
DylanDylann Nov 9, 2026
73af02d
Merge branch 'main' into dylann/105904-saved-expense-text-inputs
DylanDylann Nov 9, 2026
a438559
enhance merchant input handling by stripping invisible characters and…
DylanDylann Nov 9, 2026
0733cb7
update attendee handling in MoneyRequest components to use new hooks …
DylanDylann Nov 9, 2026
f960425
Merge branch 'main' into dylann/105904-saved-expense-text-inputs
DylanDylann Nov 10, 2026
ea6b8b0
Refactor Money Request components to utilize new `useMoneyRequestUpda…
DylanDylann Nov 10, 2026
f19eb0a
address comments
DylanDylann Nov 10, 2026
54466ae
address comments
DylanDylann Nov 11, 2026
47a9c2f
Merge branch 'main' into dylann/105904-saved-expense-text-inputs
DylanDylann Nov 11, 2026
6d5c7f6
Refactor TaxAmountInputField to use AmountFormField component instead…
DylanDylann Nov 11, 2026
0379e58
Enhance AmountActionButtons and AmountInputField components by adding…
DylanDylann Nov 11, 2026
491612a
Merge branch 'main' into dylann/105904-saved-expense-text-inputs
DylanDylann Nov 12, 2026
c059c72
update padding
DylanDylann Nov 12, 2026
d405b51
Fix selected tax rate calculation in MoneyRequestView component
DylanDylann Nov 12, 2026
c8d7453
Enhance MenuItemContent to accept custom styles and update ExpenseFie…
DylanDylann Nov 13, 2026
fe8de32
Merge branch 'main' into dylann/105904-saved-expense-text-inputs
DylanDylann Nov 13, 2026
ec532f0
merge main
DylanDylann Nov 13, 2026
c417d65
address comments
DylanDylann Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,7 @@ const CONST = {
NL_FILTERS: 'nlFilters',
SUPPORT_TICKET: 'supportTicket',
ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns',
EXPENSE_VIEW_INLINE_FIELDS: 'expenseViewInlineFields',
RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns',
COLLECT_DEPOSIT_ACCOUNTS: 'collectDepositAccounts',
RESIZABLE_RHP: 'resizableRHP',
Expand Down Expand Up @@ -11161,6 +11162,8 @@ const CONST = {
GOOGLE_MERCHANT_SEARCH_BUTTON: 'MoneyRequest-GoogleMerchantSearchButton',
REIMBURSABLE_SWITCH: 'MoneyRequest-ReimbursableSwitch',
BILLABLE_SWITCH: 'MoneyRequest-BillableSwitch',
MERCHANT_INPUT: 'MoneyRequest-MerchantInput',
DESCRIPTION_INPUT: 'MoneyRequest-DescriptionInput',
},
SPLIT_EXPENSE: {
ADD_SPLIT_BUTTON: 'SplitExpense-AddSplitButton',
Expand Down
127 changes: 79 additions & 48 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ import {View} from 'react-native';
import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx';

import MoneyRequestReceiptView from './MoneyRequestReceiptView';
import DescriptionInputField from './MoneyRequestViewFields/DescriptionInputField';
import MerchantInputField from './MoneyRequestViewFields/MerchantInputField';

type MoneyRequestViewProps = {
transactionThreadReport?: OnyxEntry<OnyxTypes.Report>;
Expand Down Expand Up @@ -468,6 +470,12 @@ function MoneyRequestView({
rules,
});

// Users on the beta edit the saved expense's text fields in place. A value shown for review, from the duplicates
// or merge flows, is not saved yet, so those keep the rows that open their page.
const canUseInlineFields = isBetaEnabled(CONST.BETAS.EXPENSE_VIEW_INLINE_FIELDS) && !isFromReviewDuplicates && !isFromMergeTransaction;
const shouldUseInlineDescriptionInput = canUseInlineFields && canEdit;
const shouldUseInlineMerchantInput = canUseInlineFields && canEditMerchant && !isTransactionScanning;

const canEditDate =
isEditable &&
canEditFieldOfMoneyRequest({
Expand Down Expand Up @@ -1390,60 +1398,83 @@ function MoneyRequestView({
</OfflineWithFeedback>
{!shouldHideEmptyDescription && (
<OfflineWithFeedback pendingAction={getPendingFieldAction('comment')}>
<MenuItemWithTopDescription
description={translate('common.description')}
descriptionTextStyle={[styles.breakWord, styles.userSelectNone]}
shouldRenderAsHTML
title={updatedTransactionDescription ?? transactionDescription}
interactive={canEdit}
shouldShowRightIcon={canEdit}
titleStyle={styles.flex1}
onPress={() => {
Navigation.navigate(
createDynamicRoute(
DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID),
),
);
}}
wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]}
brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
errorText={getErrorForField('comment')}
errorTextStyle={styles.userSelectNone}
numberOfLinesTitle={0}
copyValue={descriptionCopyValue}
copyable={!canEdit && !!descriptionCopyValue}
isTitleSelectable={!!descriptionCopyValue}
/>
{shouldUseInlineDescriptionInput ? (
<DescriptionInputField
transaction={transaction}
transactionThreadReport={transactionThreadReport}
isReportInGroupPolicy={isPolicyExpenseChat}
errorText={getErrorForField('comment')}
/>
) : (
<MenuItemWithTopDescription
description={translate('common.description')}
descriptionTextStyle={[styles.breakWord, styles.userSelectNone]}
shouldRenderAsHTML
title={updatedTransactionDescription ?? transactionDescription}
interactive={canEdit}
shouldShowRightIcon={canEdit}
titleStyle={styles.flex1}
onPress={() => {
Navigation.navigate(
createDynamicRoute(
DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(
CONST.IOU.ACTION.EDIT,
iouType,
transaction.transactionID,
transactionThreadReport?.reportID,
),
),
);
}}
wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]}
brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
errorText={getErrorForField('comment')}
errorTextStyle={styles.userSelectNone}
numberOfLinesTitle={0}
copyValue={descriptionCopyValue}
copyable={!canEdit && !!descriptionCopyValue}
isTitleSelectable={!!descriptionCopyValue}
/>
)}
</OfflineWithFeedback>
)}
{isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? (
distanceRequestFields
) : (
<OfflineWithFeedback pendingAction={getPendingFieldAction('merchant')}>
<MenuItemWithTopDescription
description={translate('common.merchant')}
descriptionTextStyle={[styles.breakWord, styles.userSelectNone]}
title={updatedMerchantTitle}
interactive={canEditMerchant}
shouldShowRightIcon={canEditMerchant}
titleStyle={styles.flex1}
onPress={() => {
Navigation.navigate(
createDynamicRoute(
DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID),
),
);
}}
wrapperStyle={[styles.taskDescriptionMenuItem]}
furtherDetailsComponent={shouldShowGoogleMerchantSearchLink ? renderGoogleMerchantSearchLink() : undefined}
brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
errorText={getErrorForField('merchant')}
errorTextStyle={styles.userSelectNone}
numberOfLinesTitle={0}
copyValue={merchantCopyValue}
copyable={!canEditMerchant && !!merchantCopyValue}
isTitleSelectable={!!merchantCopyValue}
/>
{shouldUseInlineMerchantInput ? (
<MerchantInputField
transaction={transaction}
transactionThreadReport={transactionThreadReport}
errorText={getErrorForField('merchant')}
footer={shouldShowGoogleMerchantSearchLink ? renderGoogleMerchantSearchLink() : undefined}
/>
) : (
<MenuItemWithTopDescription
description={translate('common.merchant')}
descriptionTextStyle={[styles.breakWord, styles.userSelectNone]}
title={updatedMerchantTitle}
interactive={canEditMerchant}
shouldShowRightIcon={canEditMerchant}
titleStyle={styles.flex1}
onPress={() => {
Navigation.navigate(
createDynamicRoute(
DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID),
),
);
}}
wrapperStyle={[styles.taskDescriptionMenuItem]}
furtherDetailsComponent={shouldShowGoogleMerchantSearchLink ? renderGoogleMerchantSearchLink() : undefined}
brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
errorText={getErrorForField('merchant')}
errorTextStyle={styles.userSelectNone}
numberOfLinesTitle={0}
copyValue={merchantCopyValue}
copyable={!canEditMerchant && !!merchantCopyValue}
isTitleSelectable={!!merchantCopyValue}
/>
)}
</OfflineWithFeedback>
)}
<OfflineWithFeedback pendingAction={getPendingFieldAction('created')}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import TextInput from '@components/TextInput';

import useLocalize from '@hooks/useLocalize';
import useMirroredInputValue from '@hooks/useMirroredInputValue';
import useThemeStyles from '@hooks/useThemeStyles';
import useUpdateTransactionDescription from '@hooks/useUpdateTransactionDescription';

import Parser from '@libs/Parser';
import StringUtils from '@libs/StringUtils';
import {getDescription} from '@libs/TransactionUtils';
import {getInlineDescriptionErrorMessage} from '@libs/ValidationUtils';

import variables from '@styles/variables';

import CONST from '@src/CONST';
import type * as OnyxTypes from '@src/types/onyx';

import type {OnyxEntry} from 'react-native-onyx';

import React, {useState} from 'react';
import {View} from 'react-native';

type DescriptionInputFieldProps = {
Comment thread
DylanDylann marked this conversation as resolved.
Resolved
/** Expense whose description is edited */
transaction: OnyxEntry<OnyxTypes.Transaction>;

/** Transaction thread report of the expense */
transactionThreadReport: OnyxEntry<OnyxTypes.Report>;

/** Whether the expense is on a group workspace, where a description can mention a room */
isReportInGroupPolicy: boolean;

/** Violation message for the description, shown while the typed value has no error of its own */
errorText?: string;
};

/**
* The description of a saved expense, edited in place as markdown. The value is saved when the input loses focus,
* through the same write the description page uses.
*/
function DescriptionInputField({transaction, transactionThreadReport, isReportInGroupPolicy, errorText = ''}: DescriptionInputFieldProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {updateDescription} = useUpdateTransactionDescription({transaction, transactionThreadReport});

// The saved description is HTML, so it is compared and edited as markdown.
const savedDescription = Parser.htmlToMarkdown(getDescription(transaction));
const {inputValue, setInputValue, markFocused, markBlurred} = useMirroredInputValue(savedDescription);
const [inputError, setInputError] = useState('');

const saveDescription = () => {
markBlurred();
// The description page saves the value after its form strips invisible characters and trims it.
const newDescription = StringUtils.removeInvisibleCharacters(inputValue).trim();
const descriptionError = getInlineDescriptionErrorMessage(translate, newDescription);
if (descriptionError) {
setInputError(descriptionError);
return;
}
setInputError('');
setInputValue(newDescription);
if (newDescription === savedDescription) {
return;
}
updateDescription(newDescription);
};

return (
<View style={[styles.mh5, styles.mv2]}>
<TextInput
value={inputValue}
onChangeText={setInputValue}
onFocus={markFocused}
onBlur={saveDescription}
label={translate('common.description')}
accessibilityLabel={translate('common.description')}
autoGrowHeight
maxAutoGrowHeight={variables.textInputAutoGrowMaxHeight}
type="markdown"
excludedMarkdownStyles={!isReportInGroupPolicy ? ['mentionReport'] : []}
errorText={inputError || errorText}
sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.DESCRIPTION_INPUT}
/>
</View>
);
}

export default DescriptionInputField;
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import TextInput from '@components/TextInput';

import useLocalize from '@hooks/useLocalize';
import useMirroredInputValue from '@hooks/useMirroredInputValue';
import useThemeStyles from '@hooks/useThemeStyles';
import useUpdateTransactionMerchant from '@hooks/useUpdateTransactionMerchant';

import {isMerchantRequired} from '@libs/MoneyRequestUtils';
import {getTransactionDetails} from '@libs/ReportUtils';
import {getInlineMerchantErrorMessage, isInvalidMerchantValue} from '@libs/ValidationUtils';

import CONST from '@src/CONST';
import type * as OnyxTypes from '@src/types/onyx';

import type {ReactNode} from 'react';
import type {OnyxEntry} from 'react-native-onyx';

import React, {useState} from 'react';
import {View} from 'react-native';

type MerchantInputFieldProps = {
Comment thread
DylanDylann marked this conversation as resolved.
Resolved
/** Expense whose merchant is edited */
transaction: OnyxEntry<OnyxTypes.Transaction>;

/** Transaction thread report of the expense */
transactionThreadReport: OnyxEntry<OnyxTypes.Report>;

/** Violation message for the merchant, shown while the typed value has no error of its own */
errorText?: string;

/** Rendered below the input, e.g. the Google search link for a card merchant */
footer?: ReactNode;
Comment thread
DylanDylann marked this conversation as resolved.
Resolved
};

/**
* The merchant of a saved expense, edited in place. The value is saved when the input loses focus, through the same
* write the merchant page uses.
*/
function MerchantInputField({transaction, transactionThreadReport, errorText = '', footer}: MerchantInputFieldProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {updateMerchant} = useUpdateTransactionMerchant({transactionID: transaction?.transactionID, transactionThreadReport});

const merchant = getTransactionDetails(transaction)?.merchant;
const savedMerchant = isInvalidMerchantValue(merchant) ? '' : (merchant ?? '');
const {inputValue, setInputValue, markFocused, markBlurred} = useMirroredInputValue(savedMerchant);
const [inputError, setInputError] = useState('');

const saveMerchant = () => {
markBlurred();
const newMerchant = inputValue.trim();
Comment thread
DylanDylann marked this conversation as resolved.
Resolved
const merchantError = getInlineMerchantErrorMessage(translate, newMerchant, isMerchantRequired(transactionThreadReport, transaction));
if (merchantError) {
setInputError(merchantError);
return;
}
setInputError('');
setInputValue(newMerchant);
if (newMerchant === savedMerchant) {
return;
}
updateMerchant(newMerchant);
};

return (
<View style={[styles.mh5, styles.mv2]}>
<TextInput
value={inputValue}
onChangeText={setInputValue}
onFocus={markFocused}
onBlur={saveMerchant}
label={translate('common.merchant')}
accessibilityLabel={translate('common.merchant')}
errorText={inputError || errorText}
sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.MERCHANT_INPUT}
/>
{!!footer && <View style={styles.mt2}>{footer}</View>}
</View>
);
}

export default MerchantInputField;
41 changes: 41 additions & 0 deletions src/hooks/useMirroredInputValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {useState} from 'react';

type UseMirroredInputValueResult = {
Comment thread
DylanDylann marked this conversation as resolved.
Resolved
/** What the input shows */
inputValue: string;

/** Sets what the input shows, as the user types */
setInputValue: (value: string) => void;

/** Whether the input is being edited */
isFocused: boolean;

/** Marks the input as being edited, which holds off outside changes */
markFocused: () => void;

/** Marks the input as no longer being edited */
markBlurred: () => void;
};

/**
* Mirrors a value stored in Onyx in local state for a controlled text input. Feeding the stored value straight to the
* input lets a write that lands mid-typing overwrite what the user typed and move the caret, so the input shows the
* mirror instead, which picks up outside changes to the stored value only while the input is not being edited.
*/
function useMirroredInputValue(storedValue: string): UseMirroredInputValueResult {
const [isFocused, setIsFocused] = useState(false);
const [inputValue, setInputValue] = useState(storedValue);
const [prevStoredValue, setPrevStoredValue] = useState(storedValue);

// Synced during render rather than in an effect, to avoid a render pass that shows the stale value.
if (storedValue !== prevStoredValue) {
setPrevStoredValue(storedValue);
if (!isFocused) {
setInputValue(storedValue);
}
Comment thread
DylanDylann marked this conversation as resolved.
Resolved
}

return {inputValue, setInputValue, isFocused, markFocused: () => setIsFocused(true), markBlurred: () => setIsFocused(false)};
}

export default useMirroredInputValue;
Loading
Loading