Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions src/CONST/SENTRY_LABEL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1244,13 +1244,15 @@ const SENTRY_LABEL = {
DISTANCE_FIELD: 'MoneyRequestView-DistanceField',
RATE_FIELD: 'MoneyRequestView-RateField',
DATE_FIELD: 'MoneyRequestView-DateField',
DATE_FIELD_CLEAR_BUTTON: 'MoneyRequestView-DateFieldClearButton',
CATEGORY_FIELD: 'MoneyRequestView-CategoryField',
TAG_FIELD: 'MoneyRequestView-TagField',
VENDOR_FIELD: 'MoneyRequestView-VendorField',
TAX_RATE_FIELD: 'MoneyRequestView-TaxRateField',
TAX_AMOUNT_FIELD: 'MoneyRequestView-TaxAmountField',
ATTENDEES_FIELD: 'MoneyRequestView-AttendeesField',
REPORT_FIELD: 'MoneyRequestView-ReportField',
CARD_FIELD: 'MoneyRequestView-CardField',
TRIP_FIELD: 'MoneyRequestView-TripField',
VIEW_TRIP_DETAILS_BUTTON: 'MoneyRequestView-ViewTripDetailsButton',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,24 @@ import useThemeStyles from '@hooks/useThemeStyles';

import CONST from '@src/CONST';

type AmountCurrencyPickerButtonProps = Pick<ButtonProps, 'accessibilityLabel' | 'isDisabled' | 'onPress' | 'style' | 'testID'> & {
type AmountCurrencyPickerButtonProps = Pick<ButtonProps, 'accessibilityLabel' | 'isDisabled' | 'onPress' | 'style' | 'testID' | 'ref'> & {
/** Currency code displayed by the button. */
currency: string;

/** Whether the button drops its fill, so it reads as part of the field it sits in rather than as a control on top of it. */
shouldUseBorderlessStyle?: boolean;
};

/** Presentational currency selector button shared by KeypadAmountEditor and AmountFormField. */
function AmountCurrencyPickerButton({currency, accessibilityLabel, isDisabled = false, onPress, style, testID}: AmountCurrencyPickerButtonProps) {
function AmountCurrencyPickerButton({currency, accessibilityLabel, isDisabled = false, onPress, style, testID, ref, shouldUseBorderlessStyle = false}: AmountCurrencyPickerButtonProps) {
const icons = useMemoizedLazyExpensifyIcons(['CoinsButton']);
const {translate} = useLocalize();
const styles = useThemeStyles();
const buttonAccessibilityLabel = accessibilityLabel ?? `${translate('common.selectCurrency')}, ${currency}`;

return (
<Button
ref={ref}
sentryLabel={CONST.SENTRY_LABEL.AMOUNT_ACTION_BUTTONS.CURRENCY_PICKER_BUTTON}
accessibilityLabel={buttonAccessibilityLabel}
contentContainerStyle={styles.justifyContentCenter}
Expand All @@ -30,10 +34,14 @@ function AmountCurrencyPickerButton({currency, accessibilityLabel, isDisabled =
onMouseDown={(event) => event.preventDefault()}
size={CONST.BUTTON_SIZE.SMALL}
style={style}
innerStyles={shouldUseBorderlessStyle ? styles.bgTransparent : undefined}
// `innerStyles` never reaches the hover style `Button` layers on top, so the fill would come back under the cursor without this.
hoverStyles={shouldUseBorderlessStyle ? styles.bgTransparent : undefined}
testID={testID}
>
<Button.Icon src={icons.CoinsButton} />
<Button.Text>{currency}</Button.Text>
{/* Without its fill the label takes the supporting text color, matching the icon beside it. */}
<Button.Text style={shouldUseBorderlessStyle ? styles.textSupporting : undefined}>{currency}</Button.Text>
</Button>
);
}
Expand Down
11 changes: 9 additions & 2 deletions src/components/AmountActionButtons/AmountSignFlipButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import CONST from '@src/CONST';
type AmountSignFlipButtonProps = Pick<ButtonProps, 'isDisabled' | 'onPress' | 'style' | 'testID'> & {
/** Whether to display the text label alongside the icon. Defaults to true. */
shouldShowLabel?: boolean;

/** Whether the button drops its fill, so it reads as part of the field it sits in rather than as a control on top of it. */
shouldUseBorderlessStyle?: boolean;
};

/** Presentational sign toggle button shared by KeypadAmountEditor and AmountFormField. */
function AmountSignFlipButton({isDisabled = false, onPress, style, testID, shouldShowLabel = true}: AmountSignFlipButtonProps) {
function AmountSignFlipButton({isDisabled = false, onPress, style, testID, shouldShowLabel = true, shouldUseBorderlessStyle = false}: AmountSignFlipButtonProps) {
const icons = useMemoizedLazyExpensifyIcons(['PlusMinus']);
const {translate} = useLocalize();
const styles = useThemeStyles();
Expand All @@ -30,10 +33,14 @@ function AmountSignFlipButton({isDisabled = false, onPress, style, testID, shoul
onMouseDown={(event) => event.preventDefault()}
size={CONST.BUTTON_SIZE.SMALL}
style={style}
innerStyles={shouldUseBorderlessStyle ? styles.bgTransparent : undefined}
// `innerStyles` never reaches the hover style `Button` layers on top, so the fill would come back under the cursor without this.
hoverStyles={shouldUseBorderlessStyle ? styles.bgTransparent : undefined}
testID={testID}
>
<Button.Icon src={icons.PlusMinus} />
{shouldShowLabel && <Button.Text>{label}</Button.Text>}
{/* Without its fill the label takes the supporting text color, matching the icon beside it. */}
{shouldShowLabel && <Button.Text style={shouldUseBorderlessStyle ? styles.textSupporting : undefined}>{label}</Button.Text>}
</Button>
);
}
Expand Down
10 changes: 7 additions & 3 deletions src/components/MenuItem/layout/MenuItemContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,26 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';

import type {PropsWithChildren} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';

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

type MenuItemContentProps = PropsWithChildren;
type MenuItemContentProps = PropsWithChildren<{
/** Overrides for the cell, e.g. a tighter gap between its lines */
style?: StyleProp<ViewStyle>;
}>;

/**
* The flexible middle cell of a `MenuItem.Row`. Stacks its children vertically and takes up all
* the horizontal space left over by the leading and trailing cells.
*/
function MenuItemContent({children}: MenuItemContentProps) {
function MenuItemContent({children, style}: MenuItemContentProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const isCompactPopover = useIsCompactPopover();

return <View style={[styles.menuItemContent, StyleUtils.getMenuItemTextContainerStyle(isCompactPopover)]}>{children}</View>;
return <View style={[styles.menuItemContent, StyleUtils.getMenuItemTextContainerStyle(isCompactPopover), style]}>{children}</View>;
}

export default MenuItemContent;
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import {convertToBackendAmount, convertToFrontendAmountAsString, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import {calculateAmount, isMovingTransactionFromTrackExpense, isP2PRecipient, isParticipantP2P} from '@libs/IOUUtils';
import {isConfirmationAmountMissing} from '@libs/MoneyRequestUtils';
import {getBackendAmountFromInput, isConfirmationAmountMissing} from '@libs/MoneyRequestUtils';
import Navigation from '@libs/Navigation/Navigation';
import navigationRef from '@libs/Navigation/navigationRef';
import {shouldEnableNegative} from '@libs/ReportUtils';
Expand Down Expand Up @@ -189,23 +189,6 @@ function AmountField({
setIsCurrencyPickerVisible(false);
};

const getBackendAmountFromInput = (value: string) => {
const isNegative = value.startsWith('-');
const cleanAmount = value.replace('-', '');

if (!cleanAmount || cleanAmount.trim() === '') {
return null;
}

const numericAmount = Number.parseFloat(cleanAmount);
if (Number.isNaN(numericAmount)) {
return null;
}

const absoluteBackendAmount = convertToBackendAmount(numericAmount);
return isNegative ? -absoluteBackendAmount : absoluteBackendAmount;
};

/**
* When the bill total or currency changes:
* - Editing an existing split bill: recompute shares on the split draft (`SPLIT_TRANSACTION_DRAFT`).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
/**
* The attendees row's dropdown on the expense confirmation form: the attendee list anchored to the row, holding the
* selection until the user applies it to the draft expense.
* The attendees row's dropdown on an expense form: the attendee list anchored to the row, holding the selection until
* the user applies it to the expense.
*/
import AttendeePickerModal from '@components/AttendeePicker/AttendeePickerModal';
import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context';

import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams';
import useOnyx from '@hooks/useOnyx';
import useReportOwnerAsAttendee from '@hooks/useReportOwnerAsAttendee';
import useUpdateTransactionAttendees from '@hooks/useUpdateTransactionAttendees';

import {setMoneyRequestAttendees} from '@libs/actions/IOU/MoneyRequest';
import {getOriginalAttendees} from '@libs/TransactionUtils';

import CONST from '@src/CONST';
import type {IOUType} from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Attendee} from '@src/types/onyx/IOU';
Expand All @@ -20,24 +23,30 @@ import React, {useState} from 'react';
import type {ExpenseFieldDropdownRenderProps} from './ExpenseFieldDropdown';

type AttendeeFieldDropdownProps = ExpenseFieldDropdownRenderProps & {
/** ID of the draft expense the attendees are written to */
/** ID of the expense the attendees are written to */
transactionID: string;

/** Type of the expense, which the list's empty state is worded for */
iouType: IOUType;
};

/**
* The attendee list, in the container anchored to the expense form's attendees row. Mounted only once the row is
* opened. The selection is kept here while the user picks and written to the draft when applied, as the full page
* writes it on save; dismissing the container drops the unapplied selection.
* The attendee list, in the container anchored to an expense form's attendees row. Mounted only once the row is
* opened. The selection is kept here while the user picks and written to the expense when applied, through the same
* hook the full page saves with; dismissing the container drops the unapplied selection.
*/
function AttendeeFieldDropdown({transactionID, iouType, isVisible, onClose, ...popoverProps}: AttendeeFieldDropdownProps) {
const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`);
const reportOwnerAsAttendee = useReportOwnerAsAttendee(draftTransaction);
const originalAttendees = getOriginalAttendees(draftTransaction, reportOwnerAsAttendee);
const {action, reportID} = useConfirmationFields();
const isEditing = action === CONST.IOU.ACTION.EDIT;
// A saved expense being edited is read as saved, and anything else from its draft, the same as the full page reads it.
const [transaction] = useOnyx(`${isEditing ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`);
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
const updateParams = useMoneyRequestUpdateParams(transactionID, report);
const {updateAttendees} = useUpdateTransactionAttendees({transactionID, updateParams, isEditing});
const reportOwnerAsAttendee = useReportOwnerAsAttendee(transaction);
const originalAttendees = getOriginalAttendees(transaction, reportOwnerAsAttendee);

// The list starts from the draft's attendees, the creator when it has none, as the full page does. The row keeps this
// The list starts from the expense's attendees, the creator when it has none, as the full page does. The row keeps this
// dropdown mounted once it has been opened, so the selection is dropped each time it opens or closes: a selection
// dismissed without Apply must not come back on the next open.
const [pickedAttendees, setPickedAttendees] = useState<Attendee[]>();
Expand All @@ -55,7 +64,7 @@ function AttendeeFieldDropdown({transactionID, iouType, isVisible, onClose, ...p
return;
}
if (!deepEqual(originalAttendees, attendees)) {
setMoneyRequestAttendees(transactionID, attendees, true);
updateAttendees(attendees);
}
onClose();
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ function DescriptionField({isDescriptionRequired, policy, descriptionHint = ''}:
const readOnlyDescription = shouldUseDropdownRows ? (
<ExpenseFieldRow
name={translate('common.description')}
valueComponent={descriptionHTML ? <MenuItem.FieldValueHTML isMuted>{descriptionHTML}</MenuItem.FieldValueHTML> : undefined}
valueComponent={descriptionHTML ? <MenuItem.FieldValueHTML>{descriptionHTML}</MenuItem.FieldValueHTML> : undefined}
hasValueComponent={!!descriptionHTML}
accessibilityLabel={descriptionHTML ? `${translate('common.description')}, ${Parser.htmlToText(descriptionHTML)}` : undefined}
rightLabel={isDescriptionRequired ? translate('common.required') : ''}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ type ExpenseFieldRowProps = WithRequiredSentryLabel & {
/** Whether the field can be changed at all. A non-interactive row drops its border, caret and press handler */
isInteractive?: boolean;

/** Text a locked row offers to copy, from a button on hover or a long press on touch. Also makes its value selectable */
copyValue?: string;

/**
* Styles for the `Animated.View` wrapping the row, e.g. the highlight animation `useFieldRowHighlightStyle`
* runs. It is a layer of its own because the row paints its own background over anything drawn behind it, and
Expand Down Expand Up @@ -132,6 +135,7 @@ function ExpenseFieldRow({
isExpanded,
isDisabled = false,
isInteractive = true,
copyValue,
backgroundStyle,
sentryLabel,
testID,
Expand All @@ -144,18 +148,20 @@ function ExpenseFieldRow({
// `Required` answers "what goes here", so it goes once the field holds a value. A label that describes the value
// itself has to stay, or a field the app filled in reads as one the user chose.
const shouldShowRightLabel = !!rightLabel && (shouldKeepRightLabelWhenFilled || !hasValue);
const hasTrailing = shouldShowRightLabel || isInteractive;
const shouldShowCopy = !isInteractive && !!copyValue;
const hasTrailing = shouldShowRightLabel || isInteractive || shouldShowCopy;

const row = (
<MenuItem.Row>
<MenuItem.Content>
{/* A text input stacks its label right on its value, so the row does too and both kinds of field are 52px tall */}
<MenuItem.Content style={styles.gap0}>
{hasValue ? (
<>
<MenuItem.FieldName numberOfLines={1}>{name}</MenuItem.FieldName>
{valueComponent ?? (
<MenuItem.FieldValue
numberOfLines={numberOfLinesValue}
isMuted={!isInteractive}
isSelectable={shouldShowCopy}
>
{value}
</MenuItem.FieldValue>
Expand All @@ -181,6 +187,7 @@ function ExpenseFieldRow({
</>
)}
{isInteractive && <MenuItem.DownCaret />}
{shouldShowCopy && <MenuItem.Copy value={copyValue} />}
</MenuItem.Trailing>
)}
</MenuItem.Row>
Expand Down Expand Up @@ -209,6 +216,7 @@ function ExpenseFieldRow({
// the sign-in prompt rather than the field's selector.
onPress={isInteractive ? callIfAuthenticated(onPress, session) : undefined}
isDisabled={isDisabled}
shouldAllowTextSelection={shouldShowCopy}
accessibilityLabel={accessibilityLabel}
accessibilityState={isExpanded === undefined ? undefined : {expanded: isExpanded}}
sentryLabel={sentryLabel}
Expand Down
6 changes: 4 additions & 2 deletions src/components/ReceiptAudit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ function ReceiptAudit({notes, shouldShowAuditResult, hasReceiptUploadError = fal
}

return (
<View style={[styles.ph5, styles.mbn1]}>
// With the receipt's 12px top margin this leaves the 16px gap every section heading has below it
<View style={[styles.ph5, styles.mb1]}>
<View style={[styles.flexRow, styles.alignItemsCenter]}>
<Text
style={[styles.textLabelSupporting, styles.userSelectNone]}
variant="labelStrong"
style={styles.userSelectNone}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
{translate('common.receipt')}
Expand Down
37 changes: 29 additions & 8 deletions src/components/ReportActionItem/MoneyReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import AnimatedEmptyStateBackground from '@pages/inbox/report/AnimatedEmptyState

import {fontScale} from '@styles/typography';

import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, Report} from '@src/types/onyx';
Expand Down Expand Up @@ -166,13 +167,26 @@ function MoneyReportView({
{/* One-expense reports edit their fields in place like the multi-expense report view does, one field
per row since this screen reads as a single expense rather than a table. */}
{shouldDisplayReportFields && (!isCombinedReport || !isOnlyTitleFieldEnabled) && (
<MoneyRequestViewReportFields
report={report}
policy={policy}
pendingAction={pendingAction}
style={styles.mt5}
shouldUseSingleColumn
/>
<>
{isCombinedReport && (
<View
// The list's own top padding gives the space above the heading
style={[styles.ph5, styles.pb2, styles.userSelectNone]}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
>
<Text variant="labelStrong">{translate('workspace.common.reportFields')}</Text>
</View>
)}
<MoneyRequestViewReportFields
report={report}
policy={policy}
pendingAction={pendingAction}
// Under a heading the fields are spaced like the expense's own: 8px after the heading's 8px, and
// 8px before the divider's 12px.
style={isCombinedReport ? [styles.mt2, styles.mb2] : styles.mt5}
shouldUseSingleColumn
/>
</>
)}
{shouldShowTotalRow && (
<View style={[styles.flexRow, styles.pointerEventsNone, styles.containerWithSpaceBetween, styles.ph5, styles.pv2]}>
Expand Down Expand Up @@ -242,7 +256,14 @@ function MoneyReportView({
</>
)}
</View>
{(shouldShowReportField || shouldShowBreakdown || shouldShowTotalRow) && renderThreadDivider}
{(shouldShowReportField || shouldShowBreakdown || shouldShowTotalRow) &&
(isCombinedReport ? (
// The rule between the report's fields and the expense's, with 16px more below it since the expense's
// "Details" heading brings no top space of its own
<View style={[styles.reportHorizontalRule, styles.borderBottom, styles.mt3, styles.mb7]} />
) : (
renderThreadDivider
))}
</>
);
}
Expand Down
Loading
Loading