Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1d42b70
Restore: Show up to three report fields per row and edit them inline
MelvinBot Sep 24, 2026
bb69d06
Fix: hide the caret on the list report field inline input
MelvinBot Sep 24, 2026
32eee41
Add a test locking in the hidden caret on the list report field
MelvinBot Sep 24, 2026
c6d9488
Reset the inline field display when a whitespace-only edit is skipped
MelvinBot Sep 24, 2026
15561fa
Address AI review: drop em dashes from comments and move compact row …
MelvinBot Sep 25, 2026
c7676e3
Keep report field labels raised and remount inputs per report so valu…
MelvinBot Sep 25, 2026
7cdd0df
Open the list report field dropdown once per tap so Android's duplica…
MelvinBot Sep 25, 2026
69b1383
Merge remote-tracking branch 'origin/main' into claude-reportFieldsGr…
MelvinBot Sep 28, 2026
48fb515
Fix: Run oxfmt on ReportFieldInlineInput
MelvinBot Sep 28, 2026
192d787
Keep the backdrop behind the Month/Year picker when editing a date re…
MelvinBot Sep 28, 2026
d480492
Merge remote-tracking branch 'origin/main' into claude-reportFieldsGr…
MelvinBot Sep 30, 2026
1edffb1
Merge remote-tracking branch 'origin/main' into claude-reportFieldsGr…
MelvinBot Oct 1, 2026
07b54dc
Show a delete button on report fields deleted from the workspace
MelvinBot Oct 1, 2026
4db00c6
Don't reserve offline indicator space inside FilterPopupButton popover
MelvinBot Oct 1, 2026
6467265
Show the delete button on deleted date report fields too
MelvinBot Oct 1, 2026
e870c43
Merge remote-tracking branch 'origin/main' into claude-reportFieldsGr…
MelvinBot Oct 5, 2026
23f9ff3
Allow saving an empty value in a required inline text report field
MelvinBot Oct 8, 2026
924451b
Merge remote-tracking branch 'origin/main' into claude-reportFieldsGr…
MelvinBot Oct 8, 2026
e0e41a4
Move compact report field row style to per-item itemStyle after main …
MelvinBot Oct 8, 2026
2a10606
Show report field violations on open and stop pinning the label of em…
MelvinBot Oct 8, 2026
b81a593
Remove useMemo around the report field list in MoneyReportView
MelvinBot Oct 8, 2026
83e9b52
Merge remote-tracking branch 'origin/main' into claude-reportFieldsGr…
MelvinBot Oct 9, 2026
3d4de0e
Merge remote-tracking branch 'origin/main' into claude-reportFieldsGr…
MelvinBot Oct 12, 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: 1 addition & 2 deletions config/eslint/eslint.seatbelt.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@
"../../src/components/ReimbursementAccountLoadingIndicator.tsx" "no-restricted-imports" 1
"../../src/components/ReportActionAvatars/useReportPreviewSenderID.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/ReportActionItem/IssueCardMessage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/ReportActionItem/MoneyReportView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/ReportActionItem/MoneyReportView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/components/ReportActionItem/MoneyRequestReceiptView.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/ReportActionItem/receiptHoverUtils/index.ts" "@typescript-eslint/no-unsafe-type-assertion" 2
Expand Down Expand Up @@ -958,7 +958,6 @@
"../../src/pages/domain/Members/DomainMembersPage.tsx" "no-restricted-imports" 1
"../../src/pages/domain/Members/MoveUserBetweenGroupsPage.tsx" "no-restricted-imports" 1
"../../src/pages/domain/Members/MoveUsersBetweenGroupsPage.tsx" "no-restricted-imports" 1
"../../src/pages/DynamicEditReportFieldPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/DynamicEditReportFieldPage.tsx" "no-restricted-imports" 1
"../../src/pages/DynamicFlagCommentPage.tsx" "no-restricted-imports" 1
"../../src/pages/DynamicNewReportWorkspaceSelectionPage.tsx" "no-restricted-imports" 1
Expand Down
7 changes: 7 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7402,6 +7402,12 @@ const CONST = {

REPORT_FIELD_TITLE_FIELD_ID: 'text_title',

/** How many report fields are shown side by side in the report view on a wide layout */
REPORT_FIELDS_PER_ROW: 3,

/** Below this many options, a report field list is short enough to scan without a search input */
REPORT_FIELD_LIST_SEARCH_THRESHOLD: 8,

MOBILE_PAGINATION_SIZE: 15,
WEB_PAGINATION_SIZE: 30,

Expand Down Expand Up @@ -9709,6 +9715,7 @@ const CONST = {
MODERATION_BUTTON: 'Report-ModerationButton',
MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL: 'MoneyRequestReportActionsList-SelectAll',
MONEY_REQUEST_REPORT_TRANSACTION_ITEM: 'MoneyRequestReportTransactionItem',
REPORT_FIELD_DELETE_BUTTON: 'Report-ReportFieldDeleteButton',
REPORT_ACTION_AVATAR: 'Report-ReportActionAvatar',
PARTICIPANTS_ROW: 'Report-ParticipantsRow',
ROOM_MEMBERS_ROW: 'Report-RoomMembersRow',
Expand Down
14 changes: 11 additions & 3 deletions src/components/DatePicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,13 @@ function DatePicker({
forwardedFSClass,
shouldDeferShowUntilPositioned = false,
shouldDismissKeyboardBeforeShow = false,
wrapperStyle,
onBlur,
rightHandSideComponent,
onPickerVisibilityChange,
shouldHideCalendarIcon = false,
shouldEnableMonthYearBackdropInNarrowPane = false,
textInputContainerStyles,
}: DateInputWithPickerProps) {
const icons = useMemoizedLazyExpensifyIcons(['Calendar']);
const styles = useThemeStyles();
Expand Down Expand Up @@ -228,13 +232,15 @@ function DatePicker({
<>
<View
ref={anchorRef}
style={styles.mv2}
style={[styles.mv2, wrapperStyle]}
>
<TextInput
ref={combinedTextInputRef}
inputID={inputID}
forceActiveLabel
icon={selectedDate || shouldHideCalendarIcon ? null : icons.Calendar}
// The icon, the clear button and any `rightHandSideComponent` share the right-hand slot. The icon
// gives way when the caller asks for the slot outright, or to a clear button that will actually render.
icon={shouldHideCalendarIcon || (selectedDate && !shouldHideClearButton) ? null : icons.Calendar}
iconContainerStyle={styles.pr0}
label={label}
accessibilityLabel={label}
Expand All @@ -247,9 +253,10 @@ function DatePicker({
disabled={disabled}
hideFocusedState={shouldDismissKeyboardBeforeShow}
onPress={shouldDismissKeyboardBeforeShow ? handlePress : () => showDatePickerModal()}
onBlur={onBlur}
onSubmitEditing={() => showDatePickerModal()}
onKeyPress={handleInputKeyPress}
textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}}
textInputContainerStyles={[textInputContainerStyles, isModalVisible && styles.borderColorFocus]}
shouldHideClearButton={shouldHideClearButton}
onClearInput={handleClear}
forwardedFSClass={forwardedFSClass}
Expand All @@ -271,6 +278,7 @@ function DatePicker({
shouldPositionFromTop={!isInverted}
forwardedFSClass={forwardedFSClass}
shouldCloseWhenBrowserNavigationChanged
shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane}
/>
</>
);
Expand Down
9 changes: 9 additions & 0 deletions src/components/DatePicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types';

import type {OnyxFormValuesMapping} from '@src/ONYXKEYS';

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

type DatePickerBaseProps = ForwardedFSClassProps & {
/**
* The datepicker supports any value that `new Date()` can parse.
Expand Down Expand Up @@ -70,6 +72,13 @@ type DateInputWithPickerProps = DatePickerBaseProps &
*/
shouldDismissKeyboardBeforeShow?: boolean;

/**
* Style for the view that wraps the input and anchors the calendar. It carries a vertical margin by default,
* which a caller laying the field out itself can override, for example to line the input up with a plain
* `TextInput` beside it.
*/
wrapperStyle?: StyleProp<ViewStyle>;

/**
* Reports whether the calendar is open. Opening the picker blurs the input, so this is the signal for "the
* user is on this field" rather than `onFocus`, and it is what drives the input's focused border.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon
onViewableItemsChanged={onViewableItemsChanged}
onEndReached={onEndReached}
onStartReached={onStartReached}
contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3, shouldReserveBulkActionBarSpace && styles.bulkActionBarListSpacing]}
// 20px between the report header and the first row of content, which is the report field inputs when the report has fields.
contentContainerStyle={[styles.pt5, shouldReserveBulkActionBarSpace && styles.bulkActionBarListSpacing]}
isLoadingInitialActions={isInitialReportLoadPending}
/* This list is not inverted, so the footer is the bottom of the message feed —
the same position the indicator occupies in the inverted ReportActionsList. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ function MoneyRequestReportEmptyStateView({report, policy, onLayout}: MoneyReque
const styles = useThemeStyles();

return (
<ScrollView contentContainerStyle={styles.flexGrow1}>
// `pt5` matches the 20px the unified list puts between the report header and its first row.
<ScrollView contentContainerStyle={[styles.flexGrow1, styles.pt5]}>
<MoneyRequestViewReportFields
report={report}
policy={policy}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import {ModalActions} from '@components/Modal/Global/ModalContext';
import OfflineWithFeedback from '@components/OfflineWithFeedback';

import useConfirmModal from '@hooks/useConfirmModal';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
import useSaveReportField from '@hooks/useSaveReportField';
import useThemeStyles from '@hooks/useThemeStyles';

import {clearReportFieldKeyErrors} from '@libs/actions/Report';
import {clearReportFieldKeyErrors, deleteReportField} from '@libs/actions/Report';
import {resolveReportFieldValue} from '@libs/Formula';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import {
getFieldViolation,
getFieldViolationTranslation,
Expand All @@ -26,16 +28,17 @@ import type {ThemeStyles} from '@styles/index';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {DYNAMIC_ROUTES} from '@src/ROUTES';
import type {Policy, PolicyReportField, Report, ReportViolationName} from '@src/types/onyx';
import type {PendingAction} from '@src/types/onyx/OnyxCommon';

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

import {Str} from 'expensify-common';
import React, {useMemo} from 'react';
import {View} from 'react-native';

import ReportFieldInlineInput from './ReportFieldInlineInput';

type MoneyRequestViewReportFieldsProps = {
report: OnyxEntry<Report>;

Expand All @@ -44,53 +47,99 @@ type MoneyRequestViewReportFieldsProps = {

/** Indicates whether we have any pending actions from parent component */
pendingAction?: PendingAction;

/** Extra styles for the block. Each call site sits in a container with its own padding, so it sets the spacing it needs. */
style?: StyleProp<ViewStyle>;

/**
* Stacks the fields one per row at any width, instead of filling rows of `CONST.REPORT_FIELDS_PER_ROW`.
* A one-expense report reads as a single expense rather than a table, so its fields stay vertical there.
* @default false
*/
shouldUseSingleColumn?: boolean;
};

type EnrichedPolicyReportField = {
fieldValue: string;
isFieldDisabled: boolean;

/** Whether the field was deleted from the workspace but still holds a value on this report, so it can be removed */
isFieldDeletable: boolean;
fieldKey: string;
violation: ReportViolationName | undefined;
violationTranslation: string;
} & PolicyReportField;

function ReportFieldView(reportField: EnrichedPolicyReportField, report: OnyxEntry<Report>, styles: ThemeStyles, pendingAction?: PendingAction) {
function ReportFieldView(
reportField: EnrichedPolicyReportField,
report: OnyxEntry<Report>,
policy: OnyxEntry<Policy>,
styles: ThemeStyles,
onSaveValue: (reportField: PolicyReportField, value: string) => void,
onDelete: (reportField: EnrichedPolicyReportField) => Promise<void>,
pendingAction?: PendingAction,
) {
return (
<OfflineWithFeedback
// Need to return undefined when we have pendingAction to avoid the duplicate pending action
pendingAction={pendingAction ? undefined : report?.pendingFields?.[reportField.fieldKey as keyof typeof report.pendingFields]}
errorRowStyles={styles.ph5}
key={`menuItem-${reportField.fieldKey}`}
onClose={() => clearReportFieldKeyErrors(report?.reportID, reportField.fieldKey)}
<View
// The next/previous report arrows only change the `reportID` param, so the screen stays mounted. Keying on
// the report gives every report fresh inputs instead of carrying one report's draft and errors into the next.
key={`reportField-${report?.reportID}-${reportField.fieldKey}`}
style={styles.flex1}
>
<MenuItemWithTopDescription
description={Str.UCFirst(reportField.name)}
title={reportField.fieldValue}
onPress={() => {
if (!report?.policyID) {
return;
<OfflineWithFeedback
// Need to return undefined when we have pendingAction to avoid the duplicate pending action
pendingAction={pendingAction ? undefined : report?.pendingFields?.[reportField.fieldKey as keyof typeof report.pendingFields]}
errors={report?.errorFields?.[reportField.fieldKey]}
onClose={() => clearReportFieldKeyErrors(report?.reportID, reportField.fieldKey)}
>
<ReportFieldInlineInput
reportField={reportField}
fieldKey={reportField.fieldKey}
value={reportField.fieldValue}
isDisabled={reportField.isFieldDisabled}
errorText={reportField.violationTranslation}
fieldList={policy?.fieldList}
onSaveValue={(value) => onSaveValue(reportField, value)}
onDelete={
reportField.isFieldDeletable
? () => {
onDelete(reportField);
}
: undefined
}

Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.EDIT_REPORT_FIELD.getRoute(report.policyID, reportField.fieldID)));
}}
shouldShowRightIcon={!reportField.isFieldDisabled}
wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]}
shouldGreyOutWhenDisabled={false}
numberOfLinesTitle={0}
interactive={!reportField.isFieldDisabled}
titleWithTooltips={[]}
brickRoadIndicator={reportField.violation ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
errorText={reportField.violationTranslation}
/>
</OfflineWithFeedback>
/>
</OfflineWithFeedback>
</View>
);
}
function MoneyRequestViewReportFields({report, policy, pendingAction}: MoneyRequestViewReportFieldsProps) {
function MoneyRequestViewReportFields({report, policy, pendingAction, style, shouldUseSingleColumn = false}: MoneyRequestViewReportFieldsProps) {
const styles = useThemeStyles();
// The report view is a RightModalNavigator screen shown as a wide RHP, where `useResponsiveLayout` reports a narrow layout at any pane width.
const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP();
const saveReportField = useSaveReportField(report, policy);
const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${getNonEmptyStringOnyxID(report?.reportID)}`);
const {getCurrencyDecimals} = useCurrencyListActions();
const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE);
const {translate} = useLocalize();
const {showConfirmModal} = useConfirmModal();

const deleteField = async (reportField: EnrichedPolicyReportField) => {
const result = await showConfirmModal({
title: translate('workspace.reportFields.delete'),
prompt: translate('workspace.reportFields.deleteConfirmation'),
confirmText: translate('common.delete'),
cancelText: translate('common.cancel'),
buttonVariant: CONST.BUTTON_VARIANT.DANGER,
shouldEnableNewFocusManagement: true,
});
if (result.action !== ModalActions.CONFIRM || !report?.reportID) {
return;
}
// The failure data restores whatever field is passed here, so it gets the field as stored on the report rather
// than the copy enriched for display.
deleteReportField(report.reportID, report.fieldList?.[reportField.fieldKey] ?? reportField);
};

const sortedPolicyReportFields = useMemo<EnrichedPolicyReportField[]>((): EnrichedPolicyReportField[] => {
const {fieldValues, fieldsByName} = getReportFieldMaps(report, policy?.fieldList ?? {}, reportNameValuePairs);
Expand All @@ -105,14 +154,20 @@ function MoneyRequestViewReportFields({report, policy, pendingAction}: MoneyRequ
const isFieldDisabled = isReportFieldDisabledForUser(report, field, policy, currentUserAccountID, rules);
const isDeletedFormulaField = field.type === CONST.REPORT_FIELD_TYPES.FORMULA && field.deletable;
const fieldKey = getReportFieldKey(field.fieldID);
const isFieldOnPolicy = !!(policy?.fieldList?.[fieldKey] ?? policy?.fieldList?.[field.fieldID]);
const isEditable = !isFieldDisabled || isDeletedFormulaField;

const violation = isFieldDisabled ? undefined : getFieldViolation(field);
const violationTranslation = getFieldViolationTranslation(field, violation);

return {
...field,
fieldValue,
isFieldDisabled: isFieldDisabled && !isDeletedFormulaField,
isFieldDisabled: !isEditable,
// Only fields that are no longer on the workspace can be deleted. Their `deletable` flag is not
// checked: once the workspace drops a field, whether it was required no longer applies, and the
// report's copy of a date field can still be flagged as not deletable.
isFieldDeletable: field.fieldID !== CONST.REPORT_FIELD_TITLE_FIELD_ID && !isFieldOnPolicy && isEditable,
fieldKey,
violation,
violationTranslation,
Expand All @@ -128,10 +183,35 @@ function MoneyRequestViewReportFields({report, policy, pendingAction}: MoneyRequ
return null;
}

const columnCount = shouldUseNarrowLayout || shouldUseSingleColumn ? 1 : CONST.REPORT_FIELDS_PER_ROW;
const fieldRows: EnrichedPolicyReportField[][] = [];
for (let index = 0; index < sortedPolicyReportFields.length; index += columnCount) {
fieldRows.push(sortedPolicyReportFields.slice(index, index + columnCount));
}

return (
<View style={styles.mb3}>
{sortedPolicyReportFields.map((reportField) => {
return ReportFieldView(reportField, report, styles, pendingAction);
<View style={[styles.ph5, styles.mb3, styles.gap3, style]}>
{fieldRows.map((fieldRow) => {
const rowKey = `reportFieldRow-${fieldRow.at(0)?.fieldKey}`;

return (
<View
key={rowKey}
testID="reportFieldsRow"
// Each cell sizes to its own content and hugs the top of the row, so a field showing an error
// message grows downwards instead of stretching the cells beside it and shifting their inputs.
style={[styles.flexRow, styles.gap3, styles.alignItemsStart]}
>
{fieldRow.map((reportField) => ReportFieldView(reportField, report, policy, styles, saveReportField, deleteField, pendingAction))}
{/* A partly filled last row is padded out so its fields stay the same width as the rows above it. */}
{Array.from({length: columnCount - fieldRow.length}, (_unused, index) => (
<View
key={`${rowKey}-spacer-${index}`}
style={styles.flex1}
/>
))}
</View>
);
})}
</View>
);
Expand Down
Loading
Loading