Skip to content

Commit f042439

Browse files
authored
Merge pull request Expensify#83127 from mohammadjafarinejad/fix/82534
feat: Add inline editing for tables on desktop
2 parents 105f09d + 0fe3426 commit f042439

45 files changed

Lines changed: 2074 additions & 91 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import SafeArea from './components/SafeArea';
3131
import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider';
3232
import SidePanelContextProvider from './components/SidePanel/SidePanelContextProvider';
3333
import SVGDefinitionsProvider from './components/SVGDefinitionsProvider';
34+
import {EditingCellProvider} from './components/Table/EditableCell';
3435
import ThemeIllustrationsProvider from './components/ThemeIllustrationsProvider';
3536
import ThemeProvider from './components/ThemeProvider';
3637
import ThemeStylesProvider from './components/ThemeStylesContextProvider';
@@ -115,6 +116,7 @@ function App() {
115116
FullScreenLoaderContextProvider,
116117
ModalProvider,
117118
SidePanelContextProvider,
119+
EditingCellProvider,
118120
]}
119121
>
120122
<CustomStatusBarAndBackground />

src/CONST/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ const CONST = {
257257
POPOVER_DROPDOWN_WIDTH: 334,
258258
POPOVER_DROPDOWN_MIN_HEIGHT: 0,
259259
POPOVER_DROPDOWN_MAX_HEIGHT: 416,
260+
POPOVER_CATEGORY_PICKER_WIDTH: 350,
261+
POPOVER_CATEGORY_PICKER_MAX_HEIGHT: 450,
260262
POPOVER_MENU_MAX_HEIGHT: 496,
261263
POPOVER_MENU_MAX_HEIGHT_MOBILE: 432,
262264
POPOVER_DATE_WIDTH: 338,
@@ -8933,6 +8935,9 @@ const CONST = {
89338935
SORTABLE_HEADER: 'Search-SortableHeader',
89348936
UNREPORTED_EXPENSE_LIST_ITEM: 'UnreportedExpenseListItem',
89358937
},
8938+
TABLE: {
8939+
EDITABLE_CELL: 'Table-EditableCell',
8940+
},
89368941
REPORT: {
89378942
FLOATING_MESSAGE_COUNTER: 'Report-FloatingMessageCounter',
89388943
LIST_BOUNDARY_LOADER_RETRY: 'Report-ListBoundaryLoaderRetry',
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React, {useRef} from 'react';
2+
import {View} from 'react-native';
3+
import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent';
4+
import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasuredContent/types';
5+
import type {ListItem} from '@components/SelectionList/types';
6+
import useStyleUtils from '@hooks/useStyleUtils';
7+
import useThemeStyles from '@hooks/useThemeStyles';
8+
import CONST from '@src/CONST';
9+
import CategoryPicker from '.';
10+
11+
const DEFAULT_ANCHOR_ALIGNMENT = {
12+
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
13+
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
14+
};
15+
16+
const popoverDimensions = {
17+
width: CONST.POPOVER_DROPDOWN_WIDTH,
18+
height: CONST.POPOVER_DROPDOWN_MAX_HEIGHT,
19+
};
20+
21+
type CategoryPickerModalProps = {
22+
/** Callback to close the modal */
23+
onClose: () => void;
24+
25+
/** The policy whose categories should be shown */
26+
policyID: string | undefined;
27+
28+
/** Currently selected category */
29+
selectedCategory?: string;
30+
31+
/** Called when the user confirms a category selection */
32+
onSelected?: (item: ListItem) => void;
33+
} & Omit<PopoverWithMeasuredContentProps, 'anchorRef' | 'children' | 'onClose'>;
34+
35+
function CategoryPickerModal({
36+
isVisible,
37+
onClose,
38+
anchorPosition,
39+
policyID,
40+
selectedCategory,
41+
onSelected,
42+
anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT,
43+
shouldMeasureAnchorPositionFromTop = false,
44+
}: CategoryPickerModalProps) {
45+
const styles = useThemeStyles();
46+
const StyleUtils = useStyleUtils();
47+
const anchorRef = useRef<View>(null);
48+
49+
const handleCategorySelect = (item: ListItem) => {
50+
// If clicking the same category that's already selected, treat it as deselection
51+
if (item.keyForList === selectedCategory) {
52+
onSelected?.({keyForList: '', searchText: ''});
53+
} else {
54+
onSelected?.(item);
55+
}
56+
onClose();
57+
};
58+
59+
return (
60+
<PopoverWithMeasuredContent
61+
anchorRef={anchorRef}
62+
isVisible={isVisible}
63+
onClose={onClose}
64+
anchorPosition={anchorPosition}
65+
popoverDimensions={popoverDimensions}
66+
anchorAlignment={anchorAlignment}
67+
innerContainerStyle={StyleUtils.getWidthStyle(CONST.POPOVER_DROPDOWN_WIDTH)}
68+
restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE}
69+
shouldSwitchPositionIfOverflow
70+
shouldEnableNewFocusManagement
71+
shouldMeasureAnchorPositionFromTop={shouldMeasureAnchorPositionFromTop}
72+
shouldSkipRemeasurement
73+
shouldDisplayBelowModals
74+
>
75+
<View style={[StyleUtils.getHeight(CONST.POPOVER_DROPDOWN_MAX_HEIGHT), styles.flexColumn, styles.pt4]}>
76+
<CategoryPicker
77+
selectedCategory={selectedCategory}
78+
policyID={policyID}
79+
onSubmit={handleCategorySelect}
80+
/>
81+
</View>
82+
</PopoverWithMeasuredContent>
83+
);
84+
}
85+
86+
export default CategoryPickerModal;
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import React from 'react';
2+
import RadioListItem from '@components/SelectionList/ListItem/RadioListItem';
3+
import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections';
4+
import type {ListItem} from '@components/SelectionList/types';
5+
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
26
import useAutoFocusInput from '@hooks/useAutoFocusInput';
37
import useDebouncedState from '@hooks/useDebouncedState';
48
import useLocalize from '@hooks/useLocalize';
@@ -12,10 +16,6 @@ import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils';
1216
import CONST from '@src/CONST';
1317
import ONYXKEYS from '@src/ONYXKEYS';
1418
import {isEmptyObject} from '@src/types/utils/EmptyObject';
15-
import RadioListItem from './SelectionList/ListItem/RadioListItem';
16-
import SelectionListWithSections from './SelectionList/SelectionListWithSections';
17-
import type {ListItem} from './SelectionList/types';
18-
import type {BaseTextInputRef} from './TextInput/BaseTextInput/types';
1919

2020
type CategoryPickerProps = {
2121
policyID: string | undefined;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React from 'react';
2+
import {View} from 'react-native';
3+
import useLocalize from '@hooks/useLocalize';
4+
import useThemeStyles from '@hooks/useThemeStyles';
5+
import Button from './Button';
6+
7+
type ConfirmCancelButtonRowProps = {
8+
/** Called when the user presses Apply */
9+
onConfirm: () => void;
10+
11+
/** Called when the user presses Cancel */
12+
onCancel: () => void;
13+
14+
/** Whether the Apply button is disabled */
15+
isConfirmDisabled?: boolean;
16+
};
17+
18+
function ConfirmCancelButtonRow({onConfirm, onCancel, isConfirmDisabled = false}: ConfirmCancelButtonRowProps) {
19+
const styles = useThemeStyles();
20+
const {translate} = useLocalize();
21+
22+
return (
23+
<View style={[styles.flexRow, styles.gap2, styles.ph4, styles.pb4]}>
24+
<Button
25+
style={[styles.flex1]}
26+
text={translate('common.cancel')}
27+
onPress={onCancel}
28+
/>
29+
<Button
30+
style={[styles.flex1]}
31+
success
32+
text={translate('common.apply')}
33+
onPress={onConfirm}
34+
isDisabled={isConfirmDisabled}
35+
/>
36+
</View>
37+
);
38+
}
39+
40+
export default ConfirmCancelButtonRow;

src/components/DatePicker/DatePickerModal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const DEFAULT_ANCHOR_ORIGIN = {
1313
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
1414
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
1515
};
16+
1617
const popoverDimensions = {
1718
height: CONST.POPOVER_DATE_MIN_HEIGHT,
1819
width: CONST.POPOVER_DATE_WIDTH,
@@ -31,6 +32,7 @@ function DatePickerModal({
3132
isVisible,
3233
onClose,
3334
anchorPosition,
35+
anchorAlignment = DEFAULT_ANCHOR_ORIGIN,
3436
onSelected,
3537
shouldCloseWhenBrowserNavigationChanged = false,
3638
shouldPositionFromTop = false,
@@ -69,7 +71,8 @@ function DatePickerModal({
6971
popoverDimensions={popoverDimensions}
7072
shouldCloseWhenBrowserNavigationChanged={shouldCloseWhenBrowserNavigationChanged}
7173
innerContainerStyle={isSmallScreenWidth ? styles.w100 : {width: CONST.POPOVER_DATE_WIDTH}}
72-
anchorAlignment={DEFAULT_ANCHOR_ORIGIN}
74+
anchorAlignment={anchorAlignment}
75+
restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE}
7376
shouldSwitchPositionIfOverflow
7477
shouldReturnFocus={false}
7578
shouldMeasureAnchorPositionFromTop={shouldPositionFromTop}

src/components/MoneyRequestAmountInput.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ type MoneyRequestAmountInputProps = {
124124
*/
125125
shouldWrapInputInContainer?: boolean;
126126

127+
/** Style applied to the outer ScrollView inside NumberWithSymbolForm */
128+
scrollViewStyle?: StyleProp<ViewStyle>;
129+
130+
/**
131+
* Whether to refocus the input when clicking on the ScrollView empty space.
132+
* Prevents focus loss when clicking empty space left of the right-aligned input.
133+
*/
134+
shouldRefocusOnScrollViewClick?: boolean;
135+
127136
/** Whether the input is disabled or not */
128137
disabled?: boolean;
129138

@@ -132,7 +141,7 @@ type MoneyRequestAmountInputProps = {
132141

133142
/** Determines which keyboard to open */
134143
keyboardType?: KeyboardTypeOptions;
135-
} & Pick<TextInputWithSymbolProps, 'autoGrowExtraSpace' | 'submitBehavior' | 'shouldUseDefaultLineHeightForPrefix' | 'onFocus' | 'onBlur'>;
144+
} & Pick<TextInputWithSymbolProps, 'autoGrowExtraSpace' | 'submitBehavior' | 'shouldUseDefaultLineHeightForPrefix' | 'onFocus' | 'onBlur' | 'symbolTextStyle'>;
136145

137146
type Selection = {
138147
start: number;
@@ -167,6 +176,8 @@ function MoneyRequestAmountInput({
167176
shouldApplyPaddingToContainer = false,
168177
shouldUseDefaultLineHeightForPrefix = true,
169178
shouldWrapInputInContainer = true,
179+
scrollViewStyle,
180+
shouldRefocusOnScrollViewClick = false,
170181
isNegative = false,
171182
allowFlippingAmount = false,
172183
allowNegativeInput = false,
@@ -245,6 +256,7 @@ function MoneyRequestAmountInput({
245256
currency={currency}
246257
hideSymbol={hideCurrencySymbol}
247258
isSymbolPressable={isCurrencyPressable}
259+
symbolTextStyle={props.symbolTextStyle}
248260
shouldShowBigNumberPad={shouldShowBigNumberPad}
249261
style={inputStyle}
250262
autoGrow={autoGrow}
@@ -254,6 +266,8 @@ function MoneyRequestAmountInput({
254266
shouldApplyPaddingToContainer={shouldApplyPaddingToContainer}
255267
shouldUseDefaultLineHeightForPrefix={shouldUseDefaultLineHeightForPrefix}
256268
shouldWrapInputInContainer={shouldWrapInputInContainer}
269+
scrollViewStyle={scrollViewStyle}
270+
shouldRefocusOnScrollViewClick={shouldRefocusOnScrollViewClick}
257271
containerStyle={props.containerStyle}
258272
prefixStyle={props.prefixStyle}
259273
prefixContainerStyle={props.prefixContainerStyle}

src/components/MoneyRequestReportView/MoneyRequestReportTransactionItem.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import {getButtonRole} from '@components/Button/utils';
55
import OfflineWithFeedback from '@components/OfflineWithFeedback';
66
import {PressableWithFeedback} from '@components/Pressable';
77
import type {SearchColumnType, TableColumnSize} from '@components/Search/types';
8+
import {useEditingCellState} from '@components/Table/EditableCell';
89
import TransactionItemRow from '@components/TransactionItemRow';
910
import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
1011
import useLocalize from '@hooks/useLocalize';
1112
import useResponsiveLayout from '@hooks/useResponsiveLayout';
1213
import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
1314
import useTheme from '@hooks/useTheme';
1415
import useThemeStyles from '@hooks/useThemeStyles';
16+
import useTransactionInlineEdit from '@hooks/useTransactionInlineEdit';
1517
import useTransactionViolations from '@hooks/useTransactionViolations';
1618
import ControlSelection from '@libs/ControlSelection';
1719
import canUseTouchScreen from '@libs/DeviceCapabilities/canUseTouchScreen';
@@ -91,6 +93,7 @@ function MoneyRequestReportTransactionItem({
9193
}: MoneyRequestReportTransactionItemProps) {
9294
const {translate} = useLocalize();
9395
const styles = useThemeStyles();
96+
const {isEditingCell} = useEditingCellState();
9497
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
9598
const {isSmallScreenWidth, isMediumScreenWidth} = useResponsiveLayout();
9699
const {shouldUseNarrowLayout} = useResponsiveLayoutOnWideRHP();
@@ -100,6 +103,22 @@ function MoneyRequestReportTransactionItem({
100103
// Filter violations based on user visibility and dismissal state at the row level.
101104
const filteredViolations = useTransactionViolations(transaction.transactionID);
102105

106+
const {
107+
canEditDate,
108+
canEditMerchant,
109+
canEditDescription,
110+
canEditCategory,
111+
canEditAmount,
112+
canEditTag,
113+
onEditDate,
114+
onEditMerchant,
115+
onEditDescription,
116+
onEditCategory,
117+
onEditAmount,
118+
onEditTag,
119+
wasEditingOnMouseDownRef,
120+
} = useTransactionInlineEdit({transactionID: transaction.transactionID, reportID: transaction.reportID});
121+
103122
const viewRef = useRef<View>(null);
104123

105124
// This useEffect scrolls to this transaction when it is newly added to the report
@@ -124,6 +143,12 @@ function MoneyRequestReportTransactionItem({
124143
<PressableWithFeedback
125144
key={transaction.transactionID}
126145
onPress={() => {
146+
// If a cell was being edited when the user tapped the row, suppress navigation
147+
// so the second tap doesn't immediately open the transaction detail.
148+
if (wasEditingOnMouseDownRef.current) {
149+
wasEditingOnMouseDownRef.current = false;
150+
return;
151+
}
127152
handleOnPress(transaction.transactionID);
128153
}}
129154
accessibilityLabel={translate('iou.viewDetails')}
@@ -134,7 +159,12 @@ function MoneyRequestReportTransactionItem({
134159
style={[styles.transactionListItemStyle]}
135160
hoverStyle={[!isPendingDelete && styles.hoveredComponentBG, isSelected && styles.activeComponentBG]}
136161
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
137-
onPressIn={() => canUseTouchScreen() && ControlSelection.block()}
162+
onPressIn={() => {
163+
wasEditingOnMouseDownRef.current = isEditingCell;
164+
if (canUseTouchScreen()) {
165+
ControlSelection.block();
166+
}
167+
}}
138168
onPressOut={() => ControlSelection.unblock()}
139169
onLongPress={() => {
140170
handleLongPress(transaction.transactionID);
@@ -166,6 +196,18 @@ function MoneyRequestReportTransactionItem({
166196
onArrowRightPress={() => onArrowRightPress?.(transaction.transactionID)}
167197
isHover={hovered}
168198
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
199+
canEditDate={canEditDate}
200+
canEditMerchant={canEditMerchant}
201+
canEditDescription={canEditDescription}
202+
canEditCategory={canEditCategory}
203+
canEditAmount={canEditAmount}
204+
canEditTag={canEditTag}
205+
onEditDate={onEditDate}
206+
onEditMerchant={onEditMerchant}
207+
onEditDescription={onEditDescription}
208+
onEditCategory={onEditCategory}
209+
onEditAmount={onEditAmount}
210+
onEditTag={onEditTag}
169211
/>
170212
)}
171213
</PressableWithFeedback>

0 commit comments

Comments
 (0)