diff --git a/src/CONST/SENTRY_LABEL.ts b/src/CONST/SENTRY_LABEL.ts index 398101f1397a..93f16d8b636c 100644 --- a/src/CONST/SENTRY_LABEL.ts +++ b/src/CONST/SENTRY_LABEL.ts @@ -1244,6 +1244,7 @@ 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', @@ -1251,6 +1252,7 @@ const SENTRY_LABEL = { 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', }, diff --git a/src/components/AmountActionButtons/AmountCurrencyPickerButton.tsx b/src/components/AmountActionButtons/AmountCurrencyPickerButton.tsx index 4a326708f083..328718eb567b 100644 --- a/src/components/AmountActionButtons/AmountCurrencyPickerButton.tsx +++ b/src/components/AmountActionButtons/AmountCurrencyPickerButton.tsx @@ -7,13 +7,16 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -type AmountCurrencyPickerButtonProps = Pick & { +type AmountCurrencyPickerButtonProps = Pick & { /** 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(); @@ -21,6 +24,7 @@ function AmountCurrencyPickerButton({currency, accessibilityLabel, isDisabled = return ( ); } diff --git a/src/components/AmountActionButtons/AmountSignFlipButton.tsx b/src/components/AmountActionButtons/AmountSignFlipButton.tsx index bb64a0df113c..72d93f2faeaf 100644 --- a/src/components/AmountActionButtons/AmountSignFlipButton.tsx +++ b/src/components/AmountActionButtons/AmountSignFlipButton.tsx @@ -10,10 +10,13 @@ import CONST from '@src/CONST'; type AmountSignFlipButtonProps = Pick & { /** 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(); @@ -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} > - {shouldShowLabel && {label}} + {/* Without its fill the label takes the supporting text color, matching the icon beside it. */} + {shouldShowLabel && {label}} ); } diff --git a/src/components/MenuItem/layout/MenuItemContent.tsx b/src/components/MenuItem/layout/MenuItemContent.tsx index 3bc939a2ec39..b0cf92c6c38c 100644 --- a/src/components/MenuItem/layout/MenuItemContent.tsx +++ b/src/components/MenuItem/layout/MenuItemContent.tsx @@ -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; +}>; /** * 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 {children}; + return {children}; } export default MenuItemContent; diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 2f825c5ca7a8..b88addeebf9c 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -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'; @@ -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`). diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx index b96317b6734f..899504d57653 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx @@ -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'; @@ -20,7 +23,7 @@ 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 */ @@ -28,16 +31,22 @@ type AttendeeFieldDropdownProps = ExpenseFieldDropdownRenderProps & { }; /** - * 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(); @@ -55,7 +64,7 @@ function AttendeeFieldDropdown({transactionID, iouType, isVisible, onClose, ...p return; } if (!deepEqual(originalAttendees, attendees)) { - setMoneyRequestAttendees(transactionID, attendees, true); + updateAttendees(attendees); } onClose(); }; diff --git a/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx b/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx index c7755d018d36..be319eb70ae3 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DescriptionField.tsx @@ -115,7 +115,7 @@ function DescriptionField({isDescriptionRequired, policy, descriptionHint = ''}: const readOnlyDescription = shouldUseDropdownRows ? ( {descriptionHTML} : undefined} + valueComponent={descriptionHTML ? {descriptionHTML} : undefined} hasValueComponent={!!descriptionHTML} accessibilityLabel={descriptionHTML ? `${translate('common.description')}, ${Parser.htmlToText(descriptionHTML)}` : undefined} rightLabel={isDescriptionRequired ? translate('common.required') : ''} diff --git a/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx index f2dfb5ef7cb1..22eea8e8f004 100644 --- a/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx @@ -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 @@ -132,6 +135,7 @@ function ExpenseFieldRow({ isExpanded, isDisabled = false, isInteractive = true, + copyValue, backgroundStyle, sentryLabel, testID, @@ -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 = ( - + {/* A text input stacks its label right on its value, so the row does too and both kinds of field are 52px tall */} + {hasValue ? ( <> {name} {valueComponent ?? ( {value} @@ -181,6 +187,7 @@ function ExpenseFieldRow({ )} {isInteractive && } + {shouldShowCopy && } )} @@ -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} diff --git a/src/components/ReceiptAudit.tsx b/src/components/ReceiptAudit.tsx index f0a3750ddf8a..d8672c0ba71d 100644 --- a/src/components/ReceiptAudit.tsx +++ b/src/components/ReceiptAudit.tsx @@ -40,10 +40,12 @@ function ReceiptAudit({notes, shouldShowAuditResult, hasReceiptUploadError = fal } return ( - + // With the receipt's 12px top margin this leaves the 16px gap every section heading has below it + {translate('common.receipt')} diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 4c6fb1b18e14..17aaffbc277c 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -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'; @@ -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) && ( - + <> + {isCombinedReport && ( + + {translate('workspace.common.reportFields')} + + )} + + )} {shouldShowTotalRow && ( @@ -242,7 +256,14 @@ function MoneyReportView({ )} - {(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 + + ) : ( + renderThreadDivider + ))} ); } diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 1d500805f060..f88a8bdea77c 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1,16 +1,22 @@ import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import HighlightableMenuItemWithTopDescription from '@components/HighlightableMenuItemWithTopDescription'; import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; +import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; +import AttendeeFieldDropdown from '@components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown'; +import CategoryFieldDropdown from '@components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown'; +import ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import ExpenseFieldRow from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldRow'; +import ReportFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ReportFieldDropdown'; +import TaxFieldDropdown from '@components/MoneyRequestConfirmationList/sections/TaxFieldDropdown'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import {useSearchResultsContext} from '@components/Search/SearchContext'; +import {SELECTION_LIST_POPOVER_HEADER_HEIGHT} from '@components/SelectionListPopoverHeader'; import Switch from '@components/Switch'; import Text from '@components/Text'; import UserPills from '@components/UserPills'; @@ -26,10 +32,12 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; +import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useEnvironment from '@hooks/useEnvironment'; import useHasMultipleSplitChildren from '@hooks/useHasMultipleSplitChildren'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -54,7 +62,7 @@ import {openExternalLink} from '@libs/actions/Link'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {getBrokenConnectionUrlToFixPersonalCard, getCommercialFeedCardDescription, getCompanyCardDescription} from '@libs/CardUtils'; -import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; +import {getCategoryDescriptionHint, getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils'; @@ -64,6 +72,8 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import { + arePolicyRulesEnabled, + canCreateCategoryInSitu, canShowReimbursableToggleForPolicy, canSubmitPerDiemExpenseFromWorkspace, findVendorByID, @@ -93,6 +103,7 @@ import { isActionCreator, isExpenseReport, isInvoiceReport, + isIOUReport, isOpenReport, isReportApproved, isSettled as isSettledReportUtils, @@ -110,6 +121,7 @@ import { getDetailedExpenseTypeTranslationKey, getDisplayTransactionWithoutInvalidCommuterExclusion, getDistanceInMeters, + getEnabledTaxRateCount, getFormattedCreated, getOriginalAmountForDisplay, getOriginalTransactionWithSplitInfo, @@ -117,6 +129,7 @@ import { getReservationNights, getTagForDisplay, getTaxName, + getTaxRateTitle, hasMissingSmartscanFields, hasReservationList, hasRoute as hasRouteTransactionUtils, @@ -143,12 +156,8 @@ import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import Navigation from '@navigation/Navigation'; -import AnimatedEmptyStateBackground from '@pages/inbox/report/AnimatedEmptyStateBackground'; - import variables from '@styles/variables'; -import {callIfAuthenticated} from '@userActions/Session'; - import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -159,12 +168,10 @@ import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import {useRoute} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {policyTypeSelector} from '@selectors/Policy'; -import {authTokenTypeSelector} from '@selectors/Session'; import {Str} from 'expensify-common'; import React, {useState} from 'react'; import {View} from 'react-native'; @@ -173,6 +180,12 @@ import {View} from 'react-native'; import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; import MoneyRequestReceiptView from './MoneyRequestReceiptView'; +import AmountInputField from './MoneyRequestViewFields/AmountInputField'; +import DateInputField from './MoneyRequestViewFields/DateInputField'; +import DescriptionInputField from './MoneyRequestViewFields/DescriptionInputField'; +import MerchantInputField from './MoneyRequestViewFields/MerchantInputField'; +import TagFieldRow from './MoneyRequestViewFields/TagFieldRow'; +import TaxAmountInputField from './MoneyRequestViewFields/TaxAmountInputField'; type MoneyRequestViewProps = { transactionThreadReport?: OnyxEntry; @@ -181,9 +194,6 @@ type MoneyRequestViewProps = { /** Policy that the report belongs to */ expensePolicy: OnyxEntry; - /** Whether we should display the animated banner above the component */ - shouldShowAnimatedBackground: boolean; - /** Whether we should show Money Request with disabled all fields */ readonly?: boolean; @@ -212,14 +222,12 @@ function MoneyRequestView({ transactionThreadReport, parentReportID, expensePolicy, - shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false, mergeTransactionID, }: MoneyRequestViewProps) { - const [session] = useOnyx(ONYXKEYS.SESSION, {selector: authTokenTypeSelector}); - const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase', 'NewWindow']); + const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Suitcase', 'NewWindow']); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -295,6 +303,9 @@ function MoneyRequestView({ const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${getNonEmptyStringOnyxID(linkedTransactionID)}`); const transactionViolations = useTransactionViolations(transaction?.transactionID, false, distanceOriginalPolicy ?? policy); const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); + // Read once here and handed to every field that saves in place, rather than once per field. + const updateParams = useMoneyRequestUpdateParams(transaction?.transactionID, transactionThreadReport); + const duplicates = useDuplicateTransactionsAndViolations(transaction?.transactionID ? [transaction.transactionID] : []); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const delegateAccountID = useDelegateAccountID(); @@ -313,7 +324,7 @@ function MoneyRequestView({ // Collect/Control (group) policies are always tied to an expense chat, so a group policy type means this is a policy expense chat. const isPolicyExpenseChat = isGroupPolicyByType(reportPolicyType); - let iouType: ValueOf; + let iouType: typeof CONST.IOU.TYPE.TRACK | typeof CONST.IOU.TYPE.INVOICE | typeof CONST.IOU.TYPE.SUBMIT; if (isTrackExpense) { iouType = CONST.IOU.TYPE.TRACK; } else if (isInvoice) { @@ -706,7 +717,7 @@ function MoneyRequestView({ event?.stopPropagation(); openExternalLink(`${CONST.GOOGLE_SEARCH_URL}${encodeURIComponent(originalMerchantForGoogleSearch)}`); }} - style={[styles.flexRow, styles.alignItemsCenter, styles.mt1, styles.alignSelfStart]} + style={[styles.flexRow, styles.alignItemsCenter, styles.alignSelfStart]} > {translate('common.googleThisMerchant', { @@ -1034,10 +1045,11 @@ function MoneyRequestView({ const distanceCopyValue = distanceToDisplay || undefined; const distanceRateCopyValue = rateToDisplay || undefined; - const amountCopyValue = amountTitle || undefined; + // While a receipt is scanning, the amount and merchant show its status, which is not a value to copy. + const amountCopyValue = isTransactionScanning ? undefined : amountTitle || undefined; const descriptionHTML = updatedTransactionDescription ?? transactionDescription; const descriptionCopyValue = descriptionHTML ? Parser.htmlToText(descriptionHTML) : undefined; - const merchantCopyValue = updatedMerchantTitle; + const merchantCopyValue = isTransactionScanning ? undefined : updatedMerchantTitle || undefined; const dateCopyValue = actualTransactionDate; const categoryValue = updatedTransaction?.category ?? categoryForDisplay; const decodedCategoryName = getDecodedLeafCategoryName(categoryValue); @@ -1049,146 +1061,245 @@ function MoneyRequestView({ const taxAmountTitle = formattedTaxAmount ? formattedTaxAmount.toString() : ''; const taxAmountCopyValue = taxAmountTitle || undefined; + const openAmountPage = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (shouldShowSplitIndicator && isSplitAvailable) { + initSplitExpense( + transaction, + transactionThreadReport, + splitEffectivePolicy, + selfDMReportID, + restrictedActionPolicyID, + personalPolicy?.outputCurrency, + getCurrencyDecimals, + getCurrencySymbol, + ); + return; + } + + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID, '', '', getReportRHPActiveRoute()), + ); + }; + + const openDistancePage = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + if (isOdometerDistanceRequest) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)); + return; + } + + if (isManualDistanceRequest) { + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + }; + + const openDistanceRatePage = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + + const distanceRateRoute = createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), + ); + + if (isTrackExpense) { + if (shouldNavigateToUpgradePath) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport.reportID, + upgradePath: CONST.UPGRADE_PATHS.DISTANCE_RATES, + }), + ), + ); + return; + } + if (!policy && shouldSelectPolicy) { + Navigation.navigate(ROUTES.SET_DEFAULT_WORKSPACE.getRoute(distanceRateRoute)); + return; + } + } + + Navigation.navigate(distanceRateRoute); + }; + + const openCategoryPage = () => { + if (!transaction?.transactionID) { + return; + } + + if (shouldShowCategoryDisabledAlert) { + showCategoryDisabledAlert(); + return; + } + + const categoryRoute = createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + }), + ); + + if (shouldNavigateToUpgradePath && transactionThreadReport) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + action: CONST.IOU.ACTION.EDIT, + iouType, + transactionID: transaction.transactionID, + reportID: transactionThreadReport?.reportID, + upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, + upgradeBackTo: categoryRoute, + }), + ), + ); + } else if (!policy && shouldSelectPolicy) { + Navigation.navigate(ROUTES.SET_DEFAULT_WORKSPACE.getRoute(categoryRoute)); + } else { + startOpenCategoryPickerSpan(); + Navigation.navigate(categoryRoute); + } + }; + + const openMerchantPage = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + }; + + const openVendorPage = () => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID, getReportRHPActiveRoute()), + ); + }; + + const openTagPage = (tagListIndex: number, orderWeight: number) => { + if (!transaction?.transactionID || !transactionThreadReport?.reportID) { + return; + } + if (shouldShowTagDisabledAlert) { + showTagDisabledAlert(tagListIndex); + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction.transactionID, transactionThreadReport.reportID)), + ); + }; + + const openTaxRatePage = () => { + if (!transaction?.transactionID) { + return; + } + + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const openTaxAmountPage = () => { + if (!transaction?.transactionID) { + return; + } + + if (shouldShowTaxDisabledAlert) { + showTaxDisabledAlert(); + return; + } + + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID)), + ); + }; + + const openAttendeesPage = () => { + if (!transaction?.transactionID) { + return; + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID))); + }; + + const openReportPage = () => { + if (!canEditReport || !transactionThreadReport || !transaction?.transactionID) { + return; + } + if (shouldNavigateToUpgradePath) { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ + iouType, + action: CONST.IOU.ACTION.EDIT, + transactionID: transaction.transactionID, + reportID: transactionThreadReport.reportID, + upgradePath: CONST.UPGRADE_PATHS.REPORTS, + }), + ), + ); + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_REPORT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID)), + ); + }; + const distanceRequestFields = ( <> - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (isOdometerDistanceRequest) { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE_ODOMETER.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ); - return; - } - - if (isManualDistanceRequest) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_MANUAL.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - ), - ), - ); - return; - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - }, session) - : undefined - } - > - - {!!getErrorForField('waypoints') && } - {canEditDistance && } - {!canEditDistance && !!distanceCopyValue && } - - {!!getErrorForField('waypoints') && ( - - )} - {!!distanceToDisplayHintText && } - + /> - { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (isTrackExpense) { - if (shouldNavigateToUpgradePath && transactionThreadReport) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.DISTANCE_RATES, - }), - ), - ); - return; - } - if (!policy && shouldSelectPolicy) { - Navigation.navigate( - ROUTES.SET_DEFAULT_WORKSPACE.getRoute( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ), - ); - return; - } - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - ), - ), - ); - }, session) - : undefined - } - > - - {!!getErrorForField('customUnitRateID') && } - {canEditDistanceRate && } - {!canEditDistanceRate && !!distanceRateCopyValue && } - - {!!getErrorForField('customUnitRateID') && ( - - )} - + /> ); - const hasDependentTags = !shouldShowFullTransactionTag && hasDependentTagsPolicyUtils(policy, policyTagList); const shouldShowTagList = hasDependentTags ? getDependentTagVisibility(policyTagLists, transactionTag) : []; @@ -1212,7 +1323,7 @@ function MoneyRequestView({ const tagForDisplay = getTagForDisplay(updatedTransaction ?? transaction, shouldShowFullTransactionTag ? undefined : index); const shouldShow = hasDependentTags ? !!shouldShowTagList.at(index) : !!tagForDisplay || (canEditTag && hasEnabledOptions(tags)); - if (!shouldShow) { + if (!shouldShow || !transaction?.transactionID) { return null; } @@ -1225,45 +1336,26 @@ function MoneyRequestView({ hasDependentTags, tagForDisplay, ); - const tagCopyValue = tagForDisplay || undefined; + const shouldHighlightTag = hasDependentTags && shouldShow && !getTagForDisplay(transaction, index) && currentTagLength > previousTagLength; return ( - previousTagLength} - description={name ?? translate('common.tag')} - descriptionTextStyle={[styles.breakWord, styles.userSelectNone]} - title={tagForDisplay} - numberOfLinesTitle={2} - interactive={canEditTag} - shouldShowRightIcon={canEditTag} - titleStyle={styles.flex1} - onPress={() => { - if (!canEditTag || !transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - if (shouldShowTagDisabledAlert) { - showTagDisabledAlert(index); - return; - } - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, iouType, orderWeight, transaction.transactionID, transactionThreadReport.reportID), - ), - ); - }} - brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + openTagPage(index, orderWeight)} /> ); @@ -1285,611 +1377,468 @@ function MoneyRequestView({ // the RHP) must keep their inline receipt, so the check is scoped to this view's own route key. const isInWideRHP = wideRHPRouteKeys.includes(route.key); - // If the view is readonly, we don't need the transactionThread dependency - if ((!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID) { - return ; - } + const shouldShowReceipt = !isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction; - return ( - - {shouldShowAnimatedBackground && } - <> - {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( - 0; + const attendeesPerPersonText = + Array.isArray(actualAttendees) && actualAttendees.length > 1 && formattedPerAttendeeAmount + ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` + : ''; + const attendeePills = Array.isArray(actualAttendees) ? ( + ({ + avatar: a?.avatarUrl, + displayName: a?.displayName ?? a?.email ?? '', + accountID: a?.accountID, + email: a?.email, + }))} + maxVisible={canEdit ? undefined : actualAttendees.length} + isSelectable={areAttendeesCopyable} + /> + ) : undefined; + + const toggleFields = ( + <> + {shouldShowReimbursable && ( + + + + {Str.UCFirst(translate('iou.reimbursable'))} + + + - )} - {isCustomUnitOutOfPolicy && isPerDiemRequest && ( - - + + )} + {shouldShowBillable && ( + + - {translate('violations.customUnitOutOfPolicy')} + {translate('common.billable')} + {!!getErrorForField('billable') && ( + + + + )} - )} - - + + )} + + ); + + // If the view is readonly, we don't need the transactionThread dependency + if ((!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID) { + return ; + } + + // The bordered fields carry a 16px side margin of their own, so the wrapper brings them to the view's 20px. + const fields = ( + + + {canEditAmountInline ? ( + { - if (!transaction?.transactionID || !transactionThreadReport?.reportID) { - return; - } - - if (shouldShowSplitIndicator && isSplitAvailable) { - initSplitExpense( - transaction, - transactionThreadReport, - splitEffectivePolicy, - selfDMReportID, - restrictedActionPolicyID, - personalPolicy?.outputCurrency, - getCurrencyDecimals, - getCurrencySymbol, - ); - return; - } - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - '', - '', - getReportRHPActiveRoute(), - ), - ); - }} - brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('amount')} - errorTextStyle={styles.userSelectNone} + /> + ) : ( + - - {!shouldHideEmptyDescription && ( - - { - 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} + )} + + {!shouldHideEmptyDescription && ( + + {canEdit ? ( + + ) : ( + {descriptionHTML} : undefined} + hasValueComponent={!!descriptionHTML} + accessibilityLabel={[translate('common.description'), descriptionCopyValue].filter(Boolean).join(', ')} copyValue={descriptionCopyValue} - copyable={!canEdit && !!descriptionCopyValue} - isTitleSelectable={!!descriptionCopyValue} + errorText={getErrorForField('comment')} + isInteractive={false} + sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST_VIEW.DESCRIPTION_FIELD} /> - - )} - {isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( - distanceRequestFields - ) : ( - - { - 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} + )} + + )} + {isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( + distanceRequestFields + ) : ( + + {canEditMerchant && !isTransactionScanning ? ( + - - )} - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }, session) - : undefined - } - > - - {!!dateError && } - {canEditDate && } - {!canEditDate && !!dateCopyValue && } - - {!!dateError && ( - + ) : ( + <> + - )} - + {shouldShowGoogleMerchantSearchLink && {renderGoogleMerchantSearchLink()}} + + )} - {!!shouldShowCategory && ( - - { - if (shouldShowCategoryDisabledAlert) { - showCategoryDisabledAlert(); - return; - } - - if (shouldNavigateToUpgradePath && transactionThreadReport) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.CATEGORIES, - upgradeBackTo: createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - }), - ), - ); - } else if (!policy && shouldSelectPolicy) { - Navigation.navigate( - ROUTES.SET_DEFAULT_WORKSPACE.getRoute( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - ), - ); - } else { - startOpenCategoryPickerSpan(); - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - ); - } - }, session) - : undefined - } - > - - {!!getErrorForField('category') && } - {canEdit && } - {!canEdit && !!categoryCopyValue && } - - {!!getErrorForField('category') && ( - - )} - {!!reservationNightsHintText && } - - - )} - {shouldShowVendor && ( - - { - if (!transactionThreadReport?.reportID) { - return; - } - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_VENDOR.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport.reportID, - getReportRHPActiveRoute(), - ), - ); - }, session) - : undefined - } - > - - {!!getErrorForField('vendor') && } - {canEdit && } - {!canEdit && !!vendorCopyValue && } - - {!!getErrorForField('vendor') && ( - - )} - - - )} - {shouldShowTag && tagList} - {!!shouldShowCard && ( - - - - {!!cardCopyValue && } - - - - )} - {shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ); - }, session) - : undefined - } - > - - {!!getErrorForField('tax') && } - {canEditTaxFields && } - {!canEditTaxFields && !!taxRateCopyValue && } - - {!!getErrorForField('tax') && ( - - )} - - - )} - {shouldShowTax && ( - - { - if (shouldShowTaxDisabledAlert) { - showTaxDisabledAlert(); - return; - } - - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ); - }, session) - : undefined - } - > - - {canEditTaxFields && } - {!canEditTaxFields && !!taxAmountCopyValue && } - - - - )} - {shouldShowAttendees && ( - - 1 && formattedPerAttendeeAmount - ? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}` - : '' - }`} - descriptionTextStyle={[styles.textLabelSupportingNormal, styles.userSelectNone]} - titleComponent={ - Array.isArray(actualAttendees) ? ( - ({ - avatar: a?.avatarUrl, - displayName: a?.displayName ?? a?.email ?? '', - accountID: a?.accountID, - email: a?.email, - }))} - maxVisible={canEdit ? undefined : actualAttendees.length} - isSelectable={areAttendeesCopyable} - /> - ) : undefined - } - style={[styles.moneyRequestMenuItem]} - titleStyle={styles.flex1} - onPress={() => { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - brickRoadIndicator={getErrorForField('attendees') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('attendees')} - errorTextStyle={styles.userSelectNone} - interactive={canEdit} - shouldShowRightIcon={canEdit} - copyValue={attendeesCopyValue} - copyable={!canEdit && areAttendeesCopyable} - isTitleSelectable={areAttendeesCopyable} - /> - - )} - {shouldShowReimbursable && ( - - - - {Str.UCFirst(translate('iou.reimbursable'))} - - - - - )} - {shouldShowBillable && ( - - - - {translate('common.billable')} - - {!!getErrorForField('billable') && ( - - - - )} - - - + )} + + {canEditDate ? ( + + ) : ( + )} - {shouldShowReport && ( - - { - if (!canEditReport || !transactionThreadReport) { - return; - } - if (shouldNavigateToUpgradePath) { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_UPGRADE.getRoute({ - iouType, - action: CONST.IOU.ACTION.EDIT, - transactionID: transaction?.transactionID, - reportID: transactionThreadReport?.reportID, - upgradePath: CONST.UPGRADE_PATHS.REPORTS, - }), - ), - ); - return; - } - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_REPORT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - interactive={canEditReport} - shouldRenderAsHTML - copyValue={reportCopyValue} - copyable={!canEditReport && !!reportCopyValue} - isTitleSelectable={!!reportCopyValue} + + {!!shouldShowCategory && ( + + ( + + )} + sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST_VIEW.CATEGORY_FIELD} + /> + + )} + {shouldShowVendor && ( + + + + )} + {shouldShowTag && tagList} + {!!shouldShowCard && ( + + + + )} + {shouldShowTax && ( + + 0} + listHeaderHeight={SELECTION_LIST_POPOVER_HEADER_HEIGHT} + renderDropdown={(dropdownProps) => ( + + )} + sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST_VIEW.TAX_RATE_FIELD} + /> + + )} + {shouldShowTax && ( + + {canEditTaxAmountInline ? ( + - - )} - {shouldShowTripRoomLink && ( - <> - { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(tripRoomReportID, undefined, undefined, Navigation.getActiveRoute())); - }} + onPress={openTaxAmountPage} + copyValue={taxAmountCopyValue} + isInteractive={canEditTaxFields} + sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST_VIEW.TAX_AMOUNT_FIELD} /> - - - )} - {/* Note: "View trip details" should be always the last item */} - {shouldShowViewTripDetails && ( - + )} + {shouldShowAttendees && ( + + ( + + )} + sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST_VIEW.ATTENDEES_FIELD} + /> + + )} + {toggleFields} + {shouldShowReport && ( + + ( + + )} + sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST_VIEW.REPORT_FIELD} + /> + + )} + + ); + + return ( + + {shouldShowReceipt && ( + + )} + {isCustomUnitOutOfPolicy && isPerDiemRequest && ( + + + + {translate('violations.customUnitOutOfPolicy')} + + + )} + {!shouldShowReceipt && ( + // The heading pairs with the "Receipt" heading of the wide RHP's receipt pane. An inline receipt has no heading. + + {translate('common.details')} + + )} + {transactionThreadReport?.reportID ? ( + + {fields} + + ) : ( + // Without a thread report the view is read-only, so no field opens a list and nothing reads the context. + fields + )} + {shouldShowTripRoomLink && ( + <> + { - const reservations = transaction?.receipt?.reservationList?.length ?? 0; - if (reservations > 1) { - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(transactionThreadReport?.reportID, transaction.transactionID))); - } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.getRoute(transactionThreadReport?.reportID, transaction.transactionID, '0', 0))); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(tripRoomReportID, undefined, undefined, Navigation.getActiveRoute())); }} /> - )} - - {hasRequiredCompanyCardViolation && ( - - - - )} - + + + )} + {/* Note: "View trip details" should be always the last item */} + {shouldShowViewTripDetails && ( + { + const reservations = transaction?.receipt?.reservationList?.length ?? 0; + if (reservations > 1) { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(transactionThreadReport?.reportID, transaction.transactionID))); + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.TRAVEL_TRIP_DETAILS.getRoute(transactionThreadReport?.reportID, transaction.transactionID, '0', 0))); + }} + /> + )} + + {hasRequiredCompanyCardViolation && ( + + + + )} ); } diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx new file mode 100644 index 000000000000..96a63017a865 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx @@ -0,0 +1,171 @@ +/** + * The amount of a saved expense, edited in place on the expense view with the input the create expense form uses. The + * amount is saved when the input loses focus and the currency as soon as one is picked, through the same write the + * amount page uses. + */ +import AmountFormField from '@components/AmountFormField'; +import FormHelpMessage from '@components/FormHelpMessage'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import type {DuplicateTransactionsAndViolations} from '@hooks/useDuplicateTransactionsAndViolations'; +import useLocalize from '@hooks/useLocalize'; +import type {MoneyRequestUpdateParams} from '@hooks/useMoneyRequestUpdateParams'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionAmount from '@hooks/useUpdateTransactionAmount'; + +import {getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; +import {getBackendAmountFromInput, isValidMoneyRequestAmount, stripDecimalsFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; + +import IOURequestStepCurrencyModal from '@pages/iou/request/step/IOURequestStepCurrencyModal'; + +import type CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; + +import React, {useRef, useState} from 'react'; +import {View} from 'react-native'; + +import useSavedAmountInput from './useSavedAmountInput'; + +type AmountInputFieldProps = { + /** Expense whose amount is edited */ + transaction: OnyxEntry; + + /** What a save needs to recompute the expense's violations, read once by the expense view */ + updateParams: MoneyRequestUpdateParams; + + /** The expense's duplicates, whose violations a save can change, read once by the expense view */ + duplicates: DuplicateTransactionsAndViolations; + + /** Policy the tax of the new amount is worked out against */ + policy: OnyxEntry; + + /** Categories of `policy` */ + policyCategories: OnyxEntry; + + /** Saved amount as the expense displays it, signed and in the smallest currency unit */ + amount: number; + + /** Saved currency of the expense */ + currency: string; + + /** Label of the input, e.g. the amount with its expense type */ + label: string; + + iouType: ValueOf; + + /** Whether the amount may be negative */ + allowNegative: boolean; + + /** Whether the expense is between two people, which rules out a zero amount */ + isP2P: boolean; + + /** Whether the currency can be changed. Off for an unreported distance expense, whose rate sets it */ + isCurrencyEditable: boolean; + + /** Explanatory message rendered below the input, e.g. the converted amount */ + hintText?: string; + + /** Violation message for the amount, shown while the typed value has no error of its own */ + errorText?: string; +}; + +function AmountInputField({ + transaction, + updateParams, + duplicates, + policy, + policyCategories, + amount, + currency, + label, + iouType, + allowNegative, + isP2P, + isCurrencyEditable, + hintText = '', + errorText = '', +}: AmountInputFieldProps) { + const styles = useThemeStyles(); + const {translate, preferredLocale} = useLocalize(); + const {getCurrencyDecimals} = useCurrencyListActions(); + const {updateAmount} = useUpdateTransactionAmount({transaction, updateParams, duplicates, policy, policyCategories}); + const currencyButtonRef = useRef(null); + + const decimals = getCurrencyDecimals(currency); + const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); + + const getAmountError = (newAmount: number) => (isValidMoneyRequestAmount(newAmount, iouType, allowNegative, isP2P) ? '' : translate('iou.error.invalidAmount')); + const {inputValue, inputError, inputKey, startEditing, applyTypedAmount, saveTypedAmount, inputRef, blurInput} = useSavedAmountInput({ + amount, + decimals, + getAmountError, + onSave: (newAmount) => updateAmount(newAmount, currency), + }); + + const updateCurrency = (newCurrency: string) => { + setIsCurrencyPickerVisible(false); + if (newCurrency === currency) { + return; + } + // The typed amount goes along with the currency, cut to the decimals the new currency has, the way the amount + // page's input cuts it. A typed amount the field refused, or none at all, leaves the saved amount as it is. + const newDecimals = getCurrencyDecimals(newCurrency); + const typedAmountForCurrency = validateAmount(inputValue, newDecimals, undefined, allowNegative) ? inputValue : stripDecimalsFromAmount(inputValue); + const typedAmount = inputError ? null : getBackendAmountFromInput(typedAmountForCurrency); + updateAmount(typedAmount !== null && !getAmountError(typedAmount) ? typedAmount : amount, newCurrency); + }; + + return ( + <> + setIsCurrencyPickerVisible(false)} + headerText={translate('common.selectCurrency')} + value={currency} + onInputChange={updateCurrency} + launcherRef={currencyButtonRef} + /> + + + + + {isCurrencyEditable && ( + setIsCurrencyPickerVisible(true)} + shouldUseBorderlessStyle + /> + )} + + + {!!hintText && ( + + )} + + + ); +} + +export default AmountInputField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/DateInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/DateInputField.tsx new file mode 100644 index 000000000000..db15995ddec1 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/DateInputField.tsx @@ -0,0 +1,82 @@ +/** + * The date of a saved expense, picked in place on the expense view. A pick is saved right away, through the same write + * the date page uses. + */ +import DatePicker from '@components/DatePicker'; + +import type {DuplicateTransactionsAndViolations} from '@hooks/useDuplicateTransactionsAndViolations'; +import type {MoneyRequestUpdateParams} from '@hooks/useMoneyRequestUpdateParams'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionDate from '@hooks/useUpdateTransactionDate'; + +import {getFormattedCreated} from '@libs/TransactionUtils'; + +import CONST from '@src/CONST'; +import INPUT_IDS from '@src/types/form/MoneyRequestDateForm'; +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 DateInputFieldProps = { + /** Expense whose date is edited */ + transaction: OnyxEntry; + + /** What a save needs to recompute the expense's violations, read once by the expense view */ + updateParams: MoneyRequestUpdateParams; + + /** The expense's duplicates, whose violations a save can change, read once by the expense view */ + duplicates: DuplicateTransactionsAndViolations; + + /** Whether the expense is tracked rather than reported */ + isTrackExpense: boolean; + + /** Label of the picker, which can carry extra detail such as the posted date */ + label: string; + + /** Violation message for the date */ + errorText?: string; +}; + +function DateInputField({transaction, updateParams, duplicates, isTrackExpense, label, errorText = ''}: DateInputFieldProps) { + const styles = useThemeStyles(); + const {updateDate} = useUpdateTransactionDate({transaction, updateParams, duplicates, isTrackExpense}); + const savedDate = getFormattedCreated(transaction); + const [isCleared, setIsCleared] = useState(false); + + const saveDate = (newDate: string) => { + // An expense always has a date, so clearing only empties the picker until the next pick. + setIsCleared(!newDate); + if (!newDate || newDate === savedDate) { + return; + } + updateDate(newDate); + }; + + return ( + // `DatePicker` carries the vertical margin every field has, so this only adds the side margins. + + { + // Closing the calendar without a pick brings the saved date back. + if (isVisible || !isCleared) { + return; + } + setIsCleared(false); + }} + /> + + ); +} + +export default DateInputField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx new file mode 100644 index 000000000000..66fe5cfb381d --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx @@ -0,0 +1,99 @@ +/** + * The description of a saved expense, edited in place as markdown on the expense view. The value is saved when the + * input loses focus, through the same write the description page uses. + */ +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useMirroredInputValue from '@hooks/useMirroredInputValue'; +import type {MoneyRequestUpdateParams} from '@hooks/useMoneyRequestUpdateParams'; +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 = { + /** Expense whose description is edited */ + transaction: OnyxEntry; + + /** What a save needs to recompute the expense's violations, read once by the expense view */ + updateParams: MoneyRequestUpdateParams; + + /** Whether the expense is on a group workspace, where a description can mention a room */ + isReportInGroupPolicy: boolean; + + /** What the expense's category asks the description to say */ + hint?: string; + + /** Violation message for the description, shown while the typed value has no error of its own */ + errorText?: string; +}; + +function DescriptionInputField({transaction, updateParams, isReportInGroupPolicy, hint, errorText = ''}: DescriptionInputFieldProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {updateDescription} = useUpdateTransactionDescription({transaction, updateParams}); + + // The saved description is HTML, so it is compared and edited as markdown. + const savedDescription = Parser.htmlToMarkdown(getDescription(transaction)); + const {inputValue, setInputValue, startEditing, finishEditing} = useMirroredInputValue(savedDescription); + const [inputError, setInputError] = useState(''); + + const saveDescription = () => { + const typedDescription = finishEditing(); + // Leaving the input untouched is not an edit. Comparing against the saved markdown instead would save a + // description that only differs by the whitespace the conversion from HTML leaves at its end. + if (typedDescription === undefined) { + setInputError(''); + return; + } + // The description page saves the value after its form strips invisible characters and trims it. + const newDescription = StringUtils.removeInvisibleCharacters(typedDescription).trim(); + const descriptionError = getInlineDescriptionErrorMessage(translate, newDescription); + if (descriptionError) { + setInputError(descriptionError); + return; + } + setInputError(''); + setInputValue(newDescription); + if (newDescription === savedDescription) { + return; + } + updateDescription(newDescription); + }; + + return ( + + + + ); +} + +export default DescriptionInputField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx new file mode 100644 index 000000000000..889d2893ea89 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx @@ -0,0 +1,90 @@ +/** + * The merchant of a saved expense, edited in place on the expense view. The value is saved when the input loses focus, + * through the same write the merchant page uses. + */ +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useMirroredInputValue from '@hooks/useMirroredInputValue'; +import type {MoneyRequestUpdateParams} from '@hooks/useMoneyRequestUpdateParams'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionMerchant from '@hooks/useUpdateTransactionMerchant'; + +import {isMerchantRequired} from '@libs/MoneyRequestUtils'; +import {getTransactionDetails} from '@libs/ReportUtils'; +import StringUtils from '@libs/StringUtils'; +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 = { + /** Expense whose merchant is edited */ + transaction: OnyxEntry; + + /** What a save needs to recompute the expense's violations, read once by the expense view */ + updateParams: MoneyRequestUpdateParams; + + /** 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 */ + children?: ReactNode; +}; + +function MerchantInputField({transaction, updateParams, errorText = '', children}: MerchantInputFieldProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {updateMerchant} = useUpdateTransactionMerchant({transactionID: transaction?.transactionID, transaction, updateParams}); + + const merchant = getTransactionDetails(transaction)?.merchant; + const savedMerchant = isInvalidMerchantValue(merchant) ? '' : (merchant ?? ''); + const {inputValue, setInputValue, startEditing, finishEditing} = useMirroredInputValue(savedMerchant); + const [inputError, setInputError] = useState(''); + + const saveMerchant = () => { + const typedMerchant = finishEditing(); + // Leaving the input untouched is not an edit, so it neither validates nor saves. + if (typedMerchant === undefined) { + setInputError(''); + return; + } + // The merchant page saves the value after its form strips invisible characters, and then trims it. + const newMerchant = StringUtils.removeInvisibleCharacters(typedMerchant).trim(); + const merchantError = getInlineMerchantErrorMessage(translate, newMerchant, isMerchantRequired(updateParams.transactionThreadReport, transaction)); + if (merchantError) { + setInputError(merchantError); + return; + } + setInputError(''); + setInputValue(newMerchant); + if (newMerchant === savedMerchant) { + return; + } + updateMerchant(newMerchant); + }; + + return ( + + + {!!children && {children}} + + ); +} + +export default MerchantInputField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/TagFieldRow.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/TagFieldRow.tsx new file mode 100644 index 000000000000..c16ab09b7709 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/TagFieldRow.tsx @@ -0,0 +1,77 @@ +/** + * One tag list of a saved expense on the expense view, as a bordered field whose list opens anchored to it. It is a + * component of its own because the highlight of a tag list that just appeared is a hook, which runs once per list. + */ +import ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import TagFieldDropdown from '@components/MoneyRequestConfirmationList/sections/TagFieldDropdown'; +import useFieldRowHighlightStyle from '@components/MoneyRequestConfirmationList/sections/useFieldRowHighlightStyle'; +import {SELECTION_LIST_POPOVER_HEADER_HEIGHT} from '@components/SelectionListPopoverHeader'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; + +type TagFieldRowProps = { + /** ID of the expense the picked tag is written to */ + transactionID: string; + + /** Policy the tags belong to */ + policy: OnyxEntry; + + /** Index of the tag list this row picks from */ + tagListIndex: number; + + /** Name of the tag list */ + name: string; + + /** Tag the expense holds in this list */ + value: string; + + /** Violation message for this tag list */ + errorText: string; + + /** Whether the row flashes, for a dependent tag list that just appeared */ + shouldHighlight: boolean; + + /** Whether the tag can be changed */ + isInteractive: boolean; + + /** Whether the list opens anchored to the row. Otherwise the press goes to `onPress` */ + shouldOpenInDropdown: boolean; + + /** Opens the full-page tag selector, or the alert for a tag that can no longer be picked */ + onPress: () => void; +}; + +function TagFieldRow({transactionID, policy, tagListIndex, name, value, errorText, shouldHighlight, isInteractive, shouldOpenInDropdown, onPress}: TagFieldRowProps) { + const highlightStyle = useFieldRowHighlightStyle(shouldHighlight); + + return ( + ( + + )} + sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST_VIEW.TAG_FIELD} + /> + ); +} + +export default TagFieldRow; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx new file mode 100644 index 000000000000..2ca74d379c6b --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx @@ -0,0 +1,84 @@ +/** + * The tax amount of a saved expense, edited in place on the expense view with the input the create expense form uses. + * The amount is saved when the input loses focus, through the same write the tax amount page uses. + */ +import AmountFormField from '@components/AmountFormField'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useLocalize from '@hooks/useLocalize'; +import type {MoneyRequestUpdateParams} from '@hooks/useMoneyRequestUpdateParams'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionTaxAmount from '@hooks/useUpdateTransactionTaxAmount'; + +import {getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; +import {getCalculatedTaxAmount} from '@libs/TransactionUtils'; + +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +import useSavedAmountInput from './useSavedAmountInput'; + +type TaxAmountInputFieldProps = { + /** Expense whose tax amount is edited */ + transaction: OnyxEntry; + + /** What a save needs to recompute the expense's violations, read once by the expense view */ + updateParams: MoneyRequestUpdateParams; + + /** Policy the tax rate belongs to, which caps the tax amount */ + policy: OnyxEntry; + + /** Saved tax amount, unsigned and in the smallest currency unit */ + taxAmount: number; + + /** Currency of the expense */ + currency: string; + + /** Label of the input, which can carry the converted tax amount */ + label: string; +}; + +function TaxAmountInputField({transaction, updateParams, policy, taxAmount, currency, label}: TaxAmountInputFieldProps) { + const styles = useThemeStyles(); + const {translate, preferredLocale} = useLocalize(); + const {getCurrencyDecimals, convertToDisplayString} = useCurrencyListActions(); + const {updateTaxAmount} = useUpdateTransactionTaxAmount({transactionID: transaction?.transactionID, updateParams, policy}); + + const decimals = getCurrencyDecimals(currency); + // The tax amount page caps the tax at what the expense's tax rate works out to, as the create form does. + const maxTaxAmount = getCalculatedTaxAmount(policy, transaction, currency, decimals); + const getAmountError = (newTaxAmount: number) => (newTaxAmount > maxTaxAmount ? translate('iou.error.invalidTaxAmount', convertToDisplayString(maxTaxAmount, currency)) : ''); + const {inputValue, inputError, inputKey, startEditing, applyTypedAmount, saveTypedAmount, inputRef, blurInput} = useSavedAmountInput({ + amount: taxAmount, + decimals, + getAmountError, + onSave: updateTaxAmount, + }); + + return ( + + + + + + ); +} + +export default TaxAmountInputField; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput.ts b/src/components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput.ts new file mode 100644 index 000000000000..da108034e7fc --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput.ts @@ -0,0 +1,124 @@ +/** + * The state of an amount input on the expense view that edits a saved amount in place: it shows the saved amount, + * keeps what the user types while they type, and saves it when the input is left. Shared by the amount and tax amount + * inputs so both treat an untouched, cleared or invalid input the same way. + */ +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + +import useMirroredInputValue from '@hooks/useMirroredInputValue'; + +import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; +import {getBackendAmountFromInput} from '@libs/MoneyRequestUtils'; + +import type {RefObject} from 'react'; + +import {useRef, useState} from 'react'; + +type UseSavedAmountInputParams = { + /** Saved amount, signed and in the smallest currency unit */ + amount: number; + + /** Decimals of the amount's currency */ + decimals: number; + + /** Error for an amount the user typed, or an empty string when it can be saved */ + getAmountError: (newAmount: number) => string; + + /** Saves a valid typed amount that differs from the saved one */ + onSave: (newAmount: number) => void; +}; + +type UseSavedAmountInputResult = { + /** What the input holds */ + inputValue: string; + + /** Error for what the user typed, or an empty string */ + inputError: string; + + /** Key for the input, which keeps what is typed in its own state and so is remounted to show an amount the user didn't type */ + inputKey: string; + + /** For the input's focus */ + startEditing: () => void; + + /** For the input's change: takes what the user typed, saving it right away when the input is not focused, as with the flip button */ + applyTypedAmount: (typedAmount: string) => void; + + /** For the input's blur: validates and saves what the user typed */ + saveTypedAmount: () => void; + + /** For the input's ref, which `blurInput` acts on */ + inputRef: RefObject; + + /** For the input's submit: the input submits rather than blurs on Enter, so this blurs it to save the typed amount */ + blurInput: () => void; +}; + +function useSavedAmountInput({amount, decimals, getAmountError, onSave}: UseSavedAmountInputParams): UseSavedAmountInputResult { + const savedValue = `${amount < 0 ? '-' : ''}${convertToFrontendAmountAsString(Math.abs(amount), decimals)}`; + const {inputValue, setInputValue, isFocused, startEditing, finishEditing} = useMirroredInputValue(savedValue); + const [inputError, setInputError] = useState(''); + const [shownSavedValue, setShownSavedValue] = useState(savedValue); + const [resetCount, setResetCount] = useState(0); + if (!isFocused && shownSavedValue !== savedValue) { + setShownSavedValue(savedValue); + setInputError(''); + } + + const showSavedAmount = () => { + setInputValue(savedValue); + setResetCount((count) => count + 1); + }; + + const saveAmount = (typedAmount: string) => { + const newAmount = getBackendAmountFromInput(typedAmount); + // A saved expense always has the amount, so a cleared input goes back to the saved one. + if (newAmount === null) { + setInputError(''); + showSavedAmount(); + return; + } + const amountError = getAmountError(newAmount); + if (amountError) { + setInputError(amountError); + return; + } + setInputError(''); + if (newAmount === amount) { + showSavedAmount(); + return; + } + onSave(newAmount); + }; + + const saveTypedAmount = () => { + const typedAmount = finishEditing(); + // Leaving the input untouched is not an edit, so it neither validates nor saves. + if (typedAmount === undefined) { + if (inputError) { + setInputError(''); + showSavedAmount(); + } + return; + } + saveAmount(typedAmount); + }; + + const applyTypedAmount = (typedAmount: string) => { + setInputValue(typedAmount); + setInputError(''); + // The flip button changes the sign without focusing the input, so that change has no blur to save it. + if (!isFocused) { + saveAmount(typedAmount); + } + }; + + const inputRef = useRef(null); + const blurInput = () => { + inputRef.current?.blur(); + }; + + return {inputValue, inputError, inputKey: `${shownSavedValue}-${resetCount}`, startEditing, applyTypedAmount, saveTypedAmount, inputRef, blurInput}; +} + +export default useSavedAmountInput; diff --git a/src/hooks/useDuplicateTransactionsAndViolations.ts b/src/hooks/useDuplicateTransactionsAndViolations.ts index 3f1bce1156d5..818455075c16 100644 --- a/src/hooks/useDuplicateTransactionsAndViolations.ts +++ b/src/hooks/useDuplicateTransactionsAndViolations.ts @@ -165,4 +165,5 @@ function useLiveDuplicateTransactionsAndViolations(transactionIDs: string[]): Du } export {useLiveDuplicateTransactionsAndViolations}; +export type {DuplicateTransactionsAndViolations}; export default useDuplicateTransactionsAndViolations; diff --git a/src/hooks/useMirroredInputValue.ts b/src/hooks/useMirroredInputValue.ts new file mode 100644 index 000000000000..d6e729549782 --- /dev/null +++ b/src/hooks/useMirroredInputValue.ts @@ -0,0 +1,62 @@ +/** + * 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 until the user types in it. + */ +import {useState} from 'react'; + +type UseMirroredInputValueResult = { + /** 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; + + /** Starts an edit. Outside changes to the stored value are held off once the user types */ + startEditing: () => void; + + /** + * Ends the edit. Returns what the input holds when the user changed it, and `undefined` when they left it as it + * was, in which case the input goes back to showing the stored value. + */ + finishEditing: () => string | undefined; +}; + +function useMirroredInputValue(storedValue: string): UseMirroredInputValueResult { + const [isFocused, setIsFocused] = useState(false); + const [inputValue, setInputValue] = useState(storedValue); + const [prevStoredValue, setPrevStoredValue] = useState(storedValue); + const [valueAtFocus, setValueAtFocus] = useState(); + + // Synced during render rather than in an effect, to avoid a render pass that shows the stale value. A focused input + // the user has not typed in yet holds no edit, so it follows the stored value too rather than show an outdated one. + if (storedValue !== prevStoredValue) { + setPrevStoredValue(storedValue); + if (!isFocused || inputValue === valueAtFocus) { + setInputValue(storedValue); + setValueAtFocus(storedValue); + } + } + + const startEditing = () => { + setIsFocused(true); + setValueAtFocus(inputValue); + }; + + const finishEditing = () => { + setIsFocused(false); + if (inputValue !== valueAtFocus) { + return inputValue; + } + // The user may have typed the value back to what it was while an outside change was held off, and nothing else brings that change in. + setInputValue(storedValue); + return undefined; + }; + + return {inputValue, setInputValue, isFocused, startEditing, finishEditing}; +} + +export default useMirroredInputValue; diff --git a/src/hooks/useMoneyRequestUpdateParams.ts b/src/hooks/useMoneyRequestUpdateParams.ts new file mode 100644 index 000000000000..012c26a61377 --- /dev/null +++ b/src/hooks/useMoneyRequestUpdateParams.ts @@ -0,0 +1,65 @@ +/** + * The policy and user data every write to a saved expense needs to recompute its violations and build its report + * action. Each field's save hook reads it from here, so they all hand the same data to their write. + */ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {isTrackIntentUserSelector} from '@selectors/Onboarding'; +import {loginSelector} from '@selectors/PersonalDetails'; + +import useAllTransactionViolations from './useAllTransactionViolations'; +import {useCurrencyListActions} from './useCurrencyList'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useDelegateAccountID from './useDelegateAccountID'; +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; +import {usePersonalDetail} from './usePersonalDetails'; +import usePolicy from './usePolicy'; + +function useMoneyRequestUpdateParams(transactionID: string | undefined, transactionThreadReport: OnyxEntry) { + const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); + const policyID = transactionThreadReport?.policyID; + const policy = usePolicy(policyID); + const violations = useAllTransactionViolations(transactionID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); + const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(policyID)}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transactionThreadReport?.parentReportID)}`); + const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); + const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); + const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); + const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + + return { + isVendorMatchingBetaEnabled: isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING), + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + policy, + policyTagList, + policyCategories, + currentUserAccountIDParam: currentUserPersonalDetails.accountID, + currentUserEmailParam: currentUserPersonalDetails.login ?? '', + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + violations, + getCurrencyDecimals, + getCurrencySymbol, + rules, + }; +} + +type MoneyRequestUpdateParams = ReturnType; + +export default useMoneyRequestUpdateParams; +export type {MoneyRequestUpdateParams}; diff --git a/src/hooks/useUpdateTransactionAmount.ts b/src/hooks/useUpdateTransactionAmount.ts new file mode 100644 index 000000000000..42b8e8572cf7 --- /dev/null +++ b/src/hooks/useUpdateTransactionAmount.ts @@ -0,0 +1,76 @@ +/** + * Saves the amount and currency of a saved expense from the inline input on the expense view, through the same write + * the amount page uses, so an edit in either leaves the expense in the same state. + */ +import {updateSavedExpenseAmount} from '@libs/IOUAmountSubmission'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {DuplicateTransactionsAndViolations} from './useDuplicateTransactionsAndViolations'; +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; + +import useOnyx from './useOnyx'; + +type UseUpdateTransactionAmountParams = { + /** Saved expense being written to */ + transaction: OnyxEntry; + + /** What the write needs to recompute the expense's violations, read once by the caller */ + updateParams: MoneyRequestUpdateParams; + + /** The expense's duplicates, whose violations a new amount can change, read once by the caller */ + duplicates: DuplicateTransactionsAndViolations; + + /** Policy the tax of the new amount is worked out against. For an unreported expense it is the workspace the expense would move to */ + policy: OnyxEntry; + + /** Categories of `policy`, needed to recompute violations after the write */ + policyCategories: OnyxEntry; +}; + +type UseUpdateTransactionAmountResult = { + /** Saves `amount`, signed and in the smallest currency unit, and `currency` to the expense */ + updateAmount: (amount: number, currency: string) => void; +}; + +function useUpdateTransactionAmount({transaction, updateParams, duplicates, policy, policyCategories}: UseUpdateTransactionAmountParams): UseUpdateTransactionAmountResult { + const transactionID = transaction?.transactionID; + const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); + + const updateAmount = (amount: number, currency: string) => { + if (!transactionID) { + return; + } + updateSavedExpenseAmount({ + policy, + transactionID, + transaction, + transactionThreadReport: updateParams.transactionThreadReport, + parentReport: updateParams.parentReport, + iouReportOwnerLogin: updateParams.iouReportOwnerLogin, + amount, + currency, + duplicateTransactions: duplicates.duplicateTransactions, + duplicateTransactionViolations: duplicates.duplicateTransactionViolations, + policyCategories, + currentUserAccountID: updateParams.currentUserAccountIDParam, + currentUserEmail: updateParams.currentUserEmailParam, + isASAPSubmitBetaEnabled: updateParams.isASAPSubmitBetaEnabled, + policyRecentlyUsedCurrencies, + delegateAccountID: updateParams.delegateAccountID, + reportPolicyTags: updateParams.reportPolicyTags, + isTrackIntentUser: updateParams.isTrackIntentUser, + getCurrencyDecimals: updateParams.getCurrencyDecimals, + getCurrencySymbol: updateParams.getCurrencySymbol, + rules: updateParams.rules, + isVendorMatchingBetaEnabled: updateParams.isVendorMatchingBetaEnabled, + }); + }; + + return {updateAmount}; +} + +export default useUpdateTransactionAmount; diff --git a/src/hooks/useUpdateTransactionAttendees.ts b/src/hooks/useUpdateTransactionAttendees.ts new file mode 100644 index 000000000000..6ebe51b0897c --- /dev/null +++ b/src/hooks/useUpdateTransactionAttendees.ts @@ -0,0 +1,45 @@ +/** + * Writes the attendees of an expense, from wherever they are picked. A draft takes them straight away, and a saved + * expense saves them through the API. Both the attendees page and the anchored dropdown save through here, so a pick + * in either leaves the expense in the same state. + */ +import {setMoneyRequestAttendees} from '@libs/actions/IOU/MoneyRequest'; +import {updateMoneyRequestAttendees} from '@libs/actions/IOU/UpdateMoneyRequest'; + +import type {Attendee} from '@src/types/onyx/IOU'; + +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; + +import useNetwork from './useNetwork'; + +type UseUpdateTransactionAttendeesParams = { + /** ID of the expense being written to, and of the draft a not-yet-created expense lives in */ + transactionID: string; + + /** What a write to a saved expense needs to recompute its violations, read once by the caller */ + updateParams: MoneyRequestUpdateParams; + + /** Whether a saved expense is being edited rather than a draft being filled in */ + isEditing: boolean; +}; + +type UseUpdateTransactionAttendeesResult = { + /** Writes `attendees` to the expense */ + updateAttendees: (attendees: Attendee[]) => void; +}; + +function useUpdateTransactionAttendees({transactionID, updateParams, isEditing}: UseUpdateTransactionAttendeesParams): UseUpdateTransactionAttendeesResult { + const {isOffline} = useNetwork(); + + const updateAttendees = (attendees: Attendee[]) => { + if (isEditing) { + updateMoneyRequestAttendees({...updateParams, transactionID, attendees, isOffline}); + return; + } + setMoneyRequestAttendees(transactionID, attendees, true); + }; + + return {updateAttendees}; +} + +export default useUpdateTransactionAttendees; diff --git a/src/hooks/useUpdateTransactionDate.ts b/src/hooks/useUpdateTransactionDate.ts new file mode 100644 index 000000000000..8630e848a860 --- /dev/null +++ b/src/hooks/useUpdateTransactionDate.ts @@ -0,0 +1,77 @@ +/** + * Saves the date of a saved expense, from wherever it is edited. Both the date page and the inline picker on the + * expense view save through here, so an edit in either leaves the expense in the same state. + */ +import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; +import {isDistanceRequest} from '@libs/TransactionUtils'; + +import {updateMoneyRequestDate} from '@userActions/IOU/UpdateMoneyRequest'; + +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {DuplicateTransactionsAndViolations} from './useDuplicateTransactionsAndViolations'; +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; + +import useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; +import useNetwork from './useNetwork'; +import usePersonalPolicy from './usePersonalPolicy'; +import usePolicy from './usePolicy'; +import usePolicyForMovingExpenses from './usePolicyForMovingExpenses'; + +type UseUpdateTransactionDateParams = { + /** Saved expense being written to */ + transaction: OnyxEntry; + + /** What the write needs to recompute the expense's violations, read once by the caller */ + updateParams: MoneyRequestUpdateParams; + + /** The expense's duplicates, whose violations a new date can change, read once by the caller */ + duplicates: DuplicateTransactionsAndViolations; + + /** Whether the expense is tracked rather than reported, which takes its distance rates from the workspace for moving expenses */ + isTrackExpense: boolean; +}; + +type UseUpdateTransactionDateResult = { + /** Saves `created`, a `yyyy-MM-dd` date, to the expense */ + updateDate: (created: string) => void; +}; + +function useUpdateTransactionDate({transaction, updateParams: allUpdateParams, duplicates, isTrackExpense}: UseUpdateTransactionDateParams): UseUpdateTransactionDateResult { + const transactionID = transaction?.transactionID; + const {policy, policyTagList, violations, ...updateParams} = allUpdateParams; + const {isOffline} = useNetwork(); + const personalPolicy = usePersonalPolicy(); + const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); + const policyForTrackExpense = usePolicy(isTrackExpense ? policyForMovingExpensesID : undefined); + const effectivePolicy = isTrackExpense ? policyForTrackExpense : policy; + const customUnitRateID = isDistanceRequest(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; + const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(effectivePolicy, customUnitRateID); + const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); + + const updateDate = (created: string) => { + if (!transactionID) { + return; + } + updateMoneyRequestDate({ + ...updateParams, + transactionID, + transaction, + transactions: duplicates.duplicateTransactions, + transactionViolations: duplicates.duplicateTransactionViolations, + value: created, + policy: effectivePolicy, + policyForTrackExpense, + policyTags: policyTagList, + isOffline, + distanceOriginalPolicy, + personalPolicyOutputCurrency: personalPolicy?.outputCurrency, + }); + }; + + return {updateDate}; +} + +export default useUpdateTransactionDate; diff --git a/src/hooks/useUpdateTransactionDescription.ts b/src/hooks/useUpdateTransactionDescription.ts new file mode 100644 index 000000000000..d6c482b1a2aa --- /dev/null +++ b/src/hooks/useUpdateTransactionDescription.ts @@ -0,0 +1,45 @@ +/** + * Saves the description of a saved expense, from wherever it is edited. Both the description page and the inline input + * on the expense view save through here, so an edit in either leaves the expense in the same state. + */ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import {updateMoneyRequestDescription} from '@userActions/IOU/UpdateMoneyRequest'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; + +import useOnyx from './useOnyx'; + +type UseUpdateTransactionDescriptionParams = { + /** Saved expense being written to */ + transaction: OnyxEntry; + + /** What the write needs to recompute the expense's violations, read once by the caller */ + updateParams: MoneyRequestUpdateParams; +}; + +type UseUpdateTransactionDescriptionResult = { + /** Saves `description`, written as markdown, to the expense */ + updateDescription: (description: string) => void; +}; + +function useUpdateTransactionDescription({transaction, updateParams}: UseUpdateTransactionDescriptionParams): UseUpdateTransactionDescriptionResult { + const transactionID = transaction?.transactionID; + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${getNonEmptyStringOnyxID(updateParams.transactionThreadReport?.policyID)}`); + + const updateDescription = (description: string) => { + if (!transactionID) { + return; + } + updateMoneyRequestDescription({...updateParams, transactionID, transaction, comment: description, policyVendors}); + }; + + return {updateDescription}; +} + +export default useUpdateTransactionDescription; diff --git a/src/hooks/useUpdateTransactionMerchant.ts b/src/hooks/useUpdateTransactionMerchant.ts new file mode 100644 index 000000000000..c4cde6658798 --- /dev/null +++ b/src/hooks/useUpdateTransactionMerchant.ts @@ -0,0 +1,45 @@ +/** + * Saves the merchant of a saved expense, from wherever it is edited. Both the merchant page and the inline input on the + * expense view save through here, so an edit in either leaves the expense in the same state. + */ +import {updateMoneyRequestMerchant} from '@userActions/IOU/UpdateMoneyRequest'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; + +import useNetwork from './useNetwork'; + +type UseUpdateTransactionMerchantParams = { + /** ID of the saved expense being written to */ + transactionID: string | undefined; + + /** The saved expense being written to, whose current values the write builds on */ + transaction: OnyxEntry; + + /** What the write needs to recompute the expense's violations, read once by the caller */ + updateParams: MoneyRequestUpdateParams; +}; + +type UseUpdateTransactionMerchantResult = { + /** Saves `merchant` to the expense. An empty merchant is saved as the partial-merchant placeholder */ + updateMerchant: (merchant: string) => void; +}; + +function useUpdateTransactionMerchant({transactionID, transaction, updateParams}: UseUpdateTransactionMerchantParams): UseUpdateTransactionMerchantResult { + const {isOffline} = useNetwork(); + + const updateMerchant = (merchant: string) => { + if (!transactionID) { + return; + } + updateMoneyRequestMerchant({...updateParams, transactionID, transaction, value: merchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, isOffline}); + }; + + return {updateMerchant}; +} + +export default useUpdateTransactionMerchant; diff --git a/src/hooks/useUpdateTransactionTaxAmount.ts b/src/hooks/useUpdateTransactionTaxAmount.ts new file mode 100644 index 000000000000..122dfac960c8 --- /dev/null +++ b/src/hooks/useUpdateTransactionTaxAmount.ts @@ -0,0 +1,48 @@ +/** + * Saves the tax amount of a saved expense, from wherever it is edited. Both the tax amount page and the inline input on + * the expense view save through here, so an edit in either leaves the expense in the same state. + */ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import {updateMoneyRequestTaxAmount} from '@userActions/IOU/UpdateMoneyRequest'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; + +import useOnyx from './useOnyx'; + +type UseUpdateTransactionTaxAmountParams = { + /** ID of the saved expense being written to */ + transactionID: string | undefined; + + /** What the write needs to recompute the expense's violations, read once by the caller */ + updateParams: MoneyRequestUpdateParams; + + /** Policy the tax belongs to. For an unreported expense it is the workspace the expense would move to */ + policy: OnyxEntry; +}; + +type UseUpdateTransactionTaxAmountResult = { + /** Saves `taxAmount`, in the smallest currency unit, to the expense */ + updateTaxAmount: (taxAmount: number) => void; +}; + +function useUpdateTransactionTaxAmount({transactionID, updateParams, policy}: UseUpdateTransactionTaxAmountParams): UseUpdateTransactionTaxAmountResult { + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policy?.id)}`); + const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(policy?.id)}`); + + const updateTaxAmount = (taxAmount: number) => { + if (!transactionID) { + return; + } + updateMoneyRequestTaxAmount({...updateParams, transactionID, taxAmount, policy, policyCategories, policyTagList}); + }; + + return {updateTaxAmount}; +} + +export default useUpdateTransactionTaxAmount; diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index 31781c50dce8..1533d7c96747 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -633,6 +633,116 @@ function submitCreateAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): v submitGlobalCreate(args, ctx); } +/** + * The tax a saved expense takes once its amount or currency changes. A currency change re-applies the new currency's + * default tax rate, but only while the current rate is still the auto-applied default for the previous currency, so a + * rate the user picked is kept (mirrors the create-flow guard in `submitCreateAmount`). + */ +function getTaxForEditedAmount(policy: OnyxEntry, transaction: OnyxEntry, newAmount: number, newCurrency: string, decimals: number) { + const transactionCurrency = getCurrency(transaction); + const transactionTaxCode = getTransactionDetails(transaction)?.taxCode; + const defaultTaxCode = getDefaultTaxCode(policy, transaction, newCurrency) ?? ''; + const isCurrentTaxAutoDefault = isTaxCodeAutoDefaultForCurrency(policy, transaction, transactionCurrency, transactionTaxCode); + const isTransactionTaxCodeValid = transactionTaxCode === '' || getTaxValue(policy, transaction, transactionTaxCode ?? '') !== undefined; + const taxCode = ((newCurrency !== transactionCurrency && isCurrentTaxAutoDefault) || !isTransactionTaxCodeValid ? defaultTaxCode : transactionTaxCode) ?? defaultTaxCode; + const taxValue = getTaxValue(policy, transaction, taxCode) ?? ''; + const taxAmount = convertToBackendAmount(calculateTaxAmount(taxValue, newAmount, decimals)); + return {taxCode, taxValue, taxAmount}; +} + +type UpdateSavedExpenseAmountParams = Pick< + SubmitAmountArgs, + | 'policy' + | 'transactionID' + | 'transaction' + | 'duplicateTransactions' + | 'duplicateTransactionViolations' + | 'policyCategories' + | 'delegateAccountID' + | 'policyRecentlyUsedCurrencies' + | 'isTrackIntentUser' + | 'reportPolicyTags' + | 'getCurrencyDecimals' + | 'getCurrencySymbol' + | 'rules' + | 'isASAPSubmitBetaEnabled' + | 'isVendorMatchingBetaEnabled' +> & { + transactionThreadReport: OnyxEntry; + parentReport: OnyxEntry; + iouReportOwnerLogin: string | undefined; + + /** New amount, signed and in the smallest currency unit */ + amount: number; + currency: string; + currentUserAccountID: number; + currentUserEmail: string; +}; + +/** + * Saves a new amount and currency to a saved expense, along with the tax they work out to. Both the amount page and + * the inline amount input on the expense view save through here, so an edit in either leaves the expense in the same state. + */ +function updateSavedExpenseAmount({ + policy, + transactionID, + transaction, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + amount, + currency, + duplicateTransactions, + duplicateTransactionViolations, + policyCategories, + currentUserAccountID, + currentUserEmail, + isASAPSubmitBetaEnabled, + policyRecentlyUsedCurrencies, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + rules, + isVendorMatchingBetaEnabled, +}: UpdateSavedExpenseAmountParams): void { + const {taxCode, taxValue, taxAmount} = getTaxForEditedAmount(policy, transaction, amount, currency, getCurrencyDecimals(currency)); + + // Reset split shares for non-split-bill edits (split-bill share recalculation is handled by the confirmation list). + if (transaction?.splitShares) { + resetSplitShares(transaction, amount, currency, currentUserAccountID, getCurrencyDecimals, false); + } + + updateMoneyRequestAmountAndCurrency({ + isVendorMatchingBetaEnabled, + transactionID, + transaction, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + transactions: duplicateTransactions, + transactionViolations: duplicateTransactionViolations, + currency, + amount, + taxAmount, + policy, + taxCode, + taxValue, + policyCategories, + currentUserAccountIDParam: currentUserAccountID, + currentUserEmailParam: currentUserEmail, + isASAPSubmitBetaEnabled, + policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + getCurrencyDecimals, + getCurrencySymbol, + rules, + }); +} + function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): void { const { policy, @@ -642,19 +752,11 @@ function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): voi splitDraftTransaction, transaction, report, - duplicateTransactions, - duplicateTransactionViolations, - policyCategories, - delegateAccountID, - policyRecentlyUsedCurrencies, allReports, allPersonalDetails, navigateBack, - isTrackIntentUser, - reportPolicyTags, getCurrencyDecimals, getCurrencySymbol, - rules, } = args; const {currentTransaction, allowNegative, disableOppositeConversion, isSplitBill, currentUserAccountID, currentUserEmail, isASAPSubmitBetaEnabled, newAmount} = ctx; @@ -666,18 +768,8 @@ function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): voi return; } - // When the currency changes we re-apply the new currency's default tax rate, but only when the current tax rate is - // still the auto-applied default for the previous currency. A tax rate the user picked manually is preserved across - // the currency change (mirrors the create-flow guard in `submitCreateAmount`). - const transactionTaxCode = getTransactionDetails(currentTransaction)?.taxCode; - const defaultTaxCode = getDefaultTaxCode(policy, currentTransaction, selectedCurrency) ?? ''; - const isCurrentTaxAutoDefault = isTaxCodeAutoDefaultForCurrency(policy, currentTransaction, transactionCurrency, transactionTaxCode); - const isTransactionTaxCodeValid = transactionTaxCode === '' || getTaxValue(policy, currentTransaction, transactionTaxCode ?? '') !== undefined; - const taxCode = ((selectedCurrency !== transactionCurrency && isCurrentTaxAutoDefault) || !isTransactionTaxCodeValid ? defaultTaxCode : transactionTaxCode) ?? defaultTaxCode; - const taxPercentage = getTaxValue(policy, currentTransaction, taxCode) ?? ''; - const taxAmount = convertToBackendAmount(calculateTaxAmount(taxPercentage, newAmount, decimals)); - if (isSplitBill) { + const {taxCode, taxAmount} = getTaxForEditedAmount(policy, currentTransaction, newAmount, selectedCurrency, decimals); setDraftSplitTransaction( transactionID, splitDraftTransaction ?? transaction, @@ -689,39 +781,31 @@ function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): voi return; } - // Reset split shares for non-split-bill edits (split-bill share recalculation is handled by the confirmation list). - if (transaction?.splitShares) { - resetSplitShares(transaction, newAmount, selectedCurrency, currentUserAccountID, getCurrencyDecimals, false); - } - const parentReport = report?.parentReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`] : undefined; - updateMoneyRequestAmountAndCurrency({ - isVendorMatchingBetaEnabled: ctx.isVendorMatchingBetaEnabled, + updateSavedExpenseAmount({ + policy, transactionID, - transaction: currentTransaction, + transaction, transactionThreadReport: report, parentReport, iouReportOwnerLogin: getLoginByAccountID(parentReport?.ownerAccountID, allPersonalDetails), - transactions: duplicateTransactions, - transactionViolations: duplicateTransactionViolations, - currency: selectedCurrency, amount: newAmount, - taxAmount, - policy, - taxCode, - taxValue: taxPercentage, - policyCategories, - currentUserAccountIDParam: currentUserAccountID, - currentUserEmailParam: currentUserEmail, + currency: selectedCurrency, + duplicateTransactions: args.duplicateTransactions, + duplicateTransactionViolations: args.duplicateTransactionViolations, + policyCategories: args.policyCategories, + currentUserAccountID, + currentUserEmail, isASAPSubmitBetaEnabled, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, + policyRecentlyUsedCurrencies: args.policyRecentlyUsedCurrencies, + delegateAccountID: args.delegateAccountID, + reportPolicyTags: args.reportPolicyTags, + isTrackIntentUser: args.isTrackIntentUser, getCurrencyDecimals, getCurrencySymbol, - rules, + rules: args.rules, + isVendorMatchingBetaEnabled: ctx.isVendorMatchingBetaEnabled, }); navigateBack(); } @@ -741,5 +825,5 @@ function submitAmount(args: SubmitAmountArgs): void { submitEditAmount(args, ctx); } -export {submitAmount, getIsP2PForAmount}; +export {submitAmount, getIsP2PForAmount, updateSavedExpenseAmount}; export type {SubmitAmountArgs}; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 865478e05009..58344e49dddd 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -51,6 +51,23 @@ function addLeadingZero(amount: string, shouldAllowNegative = false): string { return amount.startsWith('.') ? `0${amount}` : amount; } +/** The backend amount for what was typed in an inline amount field, or null when the field is empty or holds only a sign. */ +function getBackendAmountFromInput(value: string): number | null { + const isNegative = value.startsWith('-'); + const cleanAmount = value.replace('-', '').trim(); + if (!cleanAmount) { + return null; + } + + const numericAmount = Number.parseFloat(cleanAmount); + if (Number.isNaN(numericAmount)) { + return null; + } + + const absoluteBackendAmount = convertToBackendAmount(numericAmount); + return isNegative ? -absoluteBackendAmount : absoluteBackendAmount; +} + /** * Check if amount is a decimal up to 3 digits */ @@ -290,6 +307,7 @@ function isConfirmationDateMissing( } export { + getBackendAmountFromInput, addLeadingZero, isConfirmationAmountMissing, isConfirmationDateMissing, diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index bd35e8830789..52f67370ef2c 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -890,6 +890,34 @@ function getMerchantError(merchant: string | undefined, isMerchantRequired: bool return undefined; } +/** + * The error for a merchant edited outside the merchant form, checked the way the form checks it, or `undefined` when + * the merchant can be saved. + */ +function getInlineMerchantErrorMessage(translate: LocalizedTranslate, merchant: string, isMerchantRequired: boolean): string | undefined { + // The merchant form uses strict HTML validation, which also rejects values like <✓>. FormProvider runs that + // check after validate and overwrites any length error, so a value that fails both must show invalid character. + if (containsHtmlTag(merchant, true)) { + return translate('common.error.invalidCharacter'); + } + const merchantError = getMerchantError(merchant, isMerchantRequired); + return merchantError ? getMerchantErrorMessage(translate, merchantError) : undefined; +} + +/** + * The error for a description edited outside the description form, checked the way the form checks it, or `undefined` + * when the description can be saved. HTML is checked first so a value that fails both shows the invalid character error. + */ +function getInlineDescriptionErrorMessage(translate: LocalizedTranslate, description: string): string | undefined { + if (containsHtmlTag(description)) { + return translate('common.error.invalidCharacter'); + } + if (description.length > CONST.DESCRIPTION_LIMIT) { + return translate('common.error.characterLimitExceedCounter', description.length, CONST.DESCRIPTION_LIMIT); + } + return undefined; +} + /** * Checks if a merchant is a placeholder the user never typed: the flow seeded the "Expense" / "(none)" value, * so it should be treated as empty rather than as an invalid entry the user is responsible for. @@ -971,6 +999,8 @@ export { isInvalidMerchantValue, getMerchantError, getMerchantErrorMessage, + getInlineMerchantErrorMessage, + getInlineDescriptionErrorMessage, isUntypedPlaceholderMerchant, isValidPIN, containsHtmlTag, diff --git a/src/libs/actions/TransactionInlineEdit.ts b/src/libs/actions/TransactionInlineEdit.ts index 2952c4370799..07ac0261c141 100644 --- a/src/libs/actions/TransactionInlineEdit.ts +++ b/src/libs/actions/TransactionInlineEdit.ts @@ -3,7 +3,6 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import {isCategoryMissing} from '@libs/CategoryUtils'; -import containsHtmlTag from '@libs/containsHtmlTag'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import {isMerchantRequired, isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; @@ -23,7 +22,7 @@ import { isPerDiemRequest, isScanning, } from '@libs/TransactionUtils'; -import {getMerchantError, getMerchantErrorMessage} from '@libs/ValidationUtils'; +import {getInlineDescriptionErrorMessage, getInlineMerchantErrorMessage} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -281,15 +280,9 @@ function editTransactionDateInline(params: TransactionInlineEditParams, newDate: /** Updates the merchant of an expense from the Search results table or the Expense Report page. */ function editTransactionMerchantInline(translate: LocalizedTranslate, params: TransactionInlineEditParams, newMerchant: string): string | undefined { - // The merchant form uses strict HTML validation, which also rejects values like <✓>. FormProvider runs that - // check after validate and overwrites any length error, so a value that fails both must show invalid character. - if (containsHtmlTag(newMerchant, true)) { - return translate('common.error.invalidCharacter'); - } - - const merchantError = getMerchantError(newMerchant, isMerchantRequired(params.parentReport, params.transaction)); - if (merchantError) { - return getMerchantErrorMessage(translate, merchantError); + const merchantErrorMessage = getInlineMerchantErrorMessage(translate, newMerchant, isMerchantRequired(params.parentReport, params.transaction)); + if (merchantErrorMessage) { + return merchantErrorMessage; } // Building params can create a transaction thread, so do it only once the merchant is valid. @@ -308,13 +301,10 @@ function editTransactionMerchantInline(translate: LocalizedTranslate, params: Tr function editTransactionDescriptionInline(translate: LocalizedTranslate, params: TransactionInlineEditParams, newDescription: string): string | undefined { // The description step validates and saves the value after FormProvider strips invisible characters and trims it. // The cell passes the raw text, so prepare it the same way. Otherwise trailing spaces past the limit toast a value the step would save. - // HTML is checked first so a value that fails both shows the invalid character error. const description = StringUtils.removeInvisibleCharacters(newDescription); - if (containsHtmlTag(description)) { - return translate('common.error.invalidCharacter'); - } - if (description.length > CONST.DESCRIPTION_LIMIT) { - return translate('common.error.characterLimitExceedCounter', description.length, CONST.DESCRIPTION_LIMIT); + const descriptionErrorMessage = getInlineDescriptionErrorMessage(translate, description); + if (descriptionErrorMessage) { + return descriptionErrorMessage; } const iouParams = getIouParamsForTransaction(params); diff --git a/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx b/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx index 6200ee03e3e0..bd8b32005ecc 100644 --- a/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx @@ -204,7 +204,6 @@ function DynamicConfirmationPage() { transactionThreadReport={report} parentReportID={report?.parentReportID} expensePolicy={policy} - shouldShowAnimatedBackground={false} readonly updatedTransaction={newTransaction as OnyxEntry} isFromReviewDuplicates diff --git a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx index 3945dddc5c3c..f007ef43f503 100644 --- a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx +++ b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx @@ -241,7 +241,6 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { } mergeTransactionID={transactionID} diff --git a/src/pages/inbox/report/MoneyReportContentCreated.tsx b/src/pages/inbox/report/MoneyReportContentCreated.tsx index efd9f93c761b..64233ccbe7fa 100644 --- a/src/pages/inbox/report/MoneyReportContentCreated.tsx +++ b/src/pages/inbox/report/MoneyReportContentCreated.tsx @@ -16,8 +16,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; import {View} from 'react-native'; -import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; - type MoneyReportContentCreatedProps = { /** The IOU/Expense/Invoice report */ report: OnyxEntry; @@ -61,7 +59,6 @@ function MoneyReportContentCreated({report, policy, transaction, transactionThre {hasThread ? ( - {threadDivider} diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 2667f82357c6..e7a5b95d7d6e 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -139,6 +139,9 @@ type ReportActionItemProps = { shouldHideThreadDividerLine?: boolean; + /** Whether the report shows anything below its created action, such as a comment or a system message */ + hasActionsAfterCreated?: boolean; + /** Report action ID that was referenced in the deeplink to report */ linkedReportActionID?: string; @@ -187,6 +190,7 @@ function ReportActionItem({ parentReportAction, shouldDisplayNewMarker, shouldHideThreadDividerLine = false, + hasActionsAfterCreated = true, onPress = undefined, isFirstVisibleReportAction = false, isThreadReportParentAction = false, @@ -503,6 +507,7 @@ function ReportActionItem({ transactionID={createdTransactionID} draftMessage={draftMessage} shouldHideThreadDividerLine={shouldHideThreadDividerLine} + hasActionsAfterCreated={hasActionsAfterCreated} /> @@ -567,6 +572,7 @@ function ReportActionItem({ transactionID={createdTransactionID} draftMessage={draftMessage} shouldHideThreadDividerLine={shouldHideThreadDividerLine} + hasActionsAfterCreated={hasActionsAfterCreated} /> )} ); + // The comments below an expense get a heading like the expense's other sections, once there is anything to head. + const expenseThreadDivider = hasActionsAfterCreated && ( + <> + {/* An unread comment's "New" line takes the rule's place, as it does below every other created action. */} + {!shouldHideThreadDividerLine && ( + + )} + + {translate('common.comments')} + + {shouldHideThreadDividerLine && ( + + )} + + ); + const disabledStateValue = {...contextMenuStateValue, isDisabled: true}; if (isTransactionThread(parentReportAction)) { @@ -101,9 +130,8 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf transactionThreadReport={report} parentReportID={report?.parentReportID} expensePolicy={policy} - shouldShowAnimatedBackground /> - {renderThreadDivider} + {expenseThreadDivider} @@ -168,7 +196,7 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf transactionThreadReport={transactionThreadReport} action={action} shouldHideThreadDividerLine={shouldHideThreadDividerLine} - threadDivider={renderThreadDivider} + threadDivider={expenseThreadDivider} /> ); } diff --git a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx index 854742522e92..95f6f588ec5a 100644 --- a/src/pages/inbox/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/inbox/report/ReportActionsListItemRenderer.tsx @@ -150,6 +150,7 @@ function ReportActionsListItemRenderer({ return ( (() => getOriginalAttendees(transaction, reportOwnerAsAttendee)); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); - const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const previousAttendees = usePrevious(attendees); const {translate} = useLocalize(); - const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const allTransactionViolations = useAllTransactionViolations(transactionID); useRestartOnReceiptFailure(transaction, reportID, iouType, action); - const currentUserAccountIDParam = currentUserPersonalDetails.accountID; - const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const delegateAccountID = useDelegateAccountID(); - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); - const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const {isOffline} = useNetwork(); // backPath briefly holds a transient redirect value before settling; MoneyRequestAttendeeSelector's // memo comparator ignores onFinish, so a stale closure can outlive the settle. Read via ref instead. @@ -85,32 +58,7 @@ function DynamicIOURequestStepAttendees({ return; } if (!deepEqual(previousAttendees, attendees)) { - if (isEditing) { - updateMoneyRequestAttendees({ - isVendorMatchingBetaEnabled, - transactionID, - transactionThreadReport: report, - parentReport, - iouReportOwnerLogin, - attendees, - policy, - policyTagList: policyTags, - policyCategories, - violations: allTransactionViolations, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - rules, - }); - } else { - setMoneyRequestAttendees(transactionID, attendees, !isEditing); - } + updateAttendees(attendees); } Navigation.goBack(backPathRef.current, {shouldSkipFocusRestore: true}); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx index 468738b67edc..3e08a4594ceb 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx @@ -4,33 +4,26 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDelegateAccountID from '@hooks/useDelegateAccountID'; -import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; +import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; -import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionDate from '@hooks/useUpdateTransactionDate'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {getDistanceRateCustomUnitRate} from '@libs/PolicyUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatReportUtil} from '@libs/ReportUtils'; -import {getFormattedCreated, hasReceipt, isDistanceRequest} from '@libs/TransactionUtils'; +import {getFormattedCreated, hasReceipt} from '@libs/TransactionUtils'; import {setMoneyRequestCreated, updateDistanceRateOnExpenseDateChange} from '@userActions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; -import {updateMoneyRequestDate} from '@userActions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -41,8 +34,6 @@ import type {Report, Transaction} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {loginSelector} from '@selectors/PersonalDetails'; import lodashIsEmpty from 'lodash/isEmpty'; import React from 'react'; @@ -76,26 +67,10 @@ function DynamicIOURequestStepDate({ const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); const policyForTrackExpense = usePolicy(isTrackExpense ? policyForMovingExpensesID : undefined); - const effectivePolicy = isTrackExpense ? policyForTrackExpense : policy; - const customUnitRateID = isDistanceRequest(transaction) ? transaction?.comment?.customUnit?.customUnitRateID : undefined; - const shouldLookupDistancePolicy = !!customUnitRateID && !getDistanceRateCustomUnitRate(effectivePolicy, customUnitRateID); - const distanceOriginalPolicy = useDistanceRateOriginalPolicy(customUnitRateID, shouldLookupDistancePolicy); - const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); - const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); - const delegateAccountID = useDelegateAccountID(); - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); - const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const {isOffline} = useNetwork(); + const updateParams = useMoneyRequestUpdateParams(transactionID, report); + const duplicates = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []); + const {updateDate: updateSavedExpenseDate} = useUpdateTransactionDate({transaction, updateParams, duplicates, isTrackExpense}); const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); const isEditing = action === CONST.IOU.ACTION.EDIT; const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT; @@ -134,33 +109,7 @@ function DynamicIOURequestStepDate({ const isTransactionDraft = shouldUseTransactionDraft(action); if (isEditing) { - updateMoneyRequestDate({ - isVendorMatchingBetaEnabled, - transactionID, - transaction, - transactionThreadReport: report, - parentReport, - iouReportOwnerLogin, - transactions: duplicateTransactions, - transactionViolations: duplicateTransactionViolations, - value: newCreated, - policy: effectivePolicy, - policyForTrackExpense, - policyTags, - policyCategories, - reportPolicyTags, - currentUserAccountIDParam: currentUserPersonalDetails.accountID, - currentUserEmailParam: currentUserPersonalDetails.login ?? '', - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - distanceOriginalPolicy, - isTrackIntentUser, - personalPolicyOutputCurrency: personalPolicy?.outputCurrency, - getCurrencyDecimals, - getCurrencySymbol, - rules, - }); + updateSavedExpenseDate(newCreated); } else { setMoneyRequestCreated(transactionID, newCreated, isTransactionDraft, hasReceipt(transaction)); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx index a3154eebcc0a..9fad7206d847 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx @@ -3,27 +3,23 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import TextInput from '@components/TextInput'; -import useAllTransactionViolations from '@hooks/useAllTransactionViolations'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams'; import useNavigateBackOnSave from '@hooks/useNavigateBackOnSave'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; -import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicy from '@hooks/usePolicy'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionDescription from '@hooks/useUpdateTransactionDescription'; import {getCategoryDescriptionHint} from '@libs/CategoryUtils'; import {addErrorMessage} from '@libs/ErrorUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Parser from '@libs/Parser'; import {arePolicyRulesEnabled} from '@libs/PolicyUtils'; @@ -34,7 +30,6 @@ import variables from '@styles/variables'; import {setMoneyRequestDescription} from '@userActions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; -import {updateMoneyRequestDescription} from '@userActions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -45,8 +40,6 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {loginSelector} from '@selectors/PersonalDetails'; import lodashIsEmpty from 'lodash/isEmpty'; import React, {useState} from 'react'; import {View} from 'react-native'; @@ -73,18 +66,10 @@ function DynamicIOURequestStepDescription({ const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.path); const policy = usePolicy(report?.policyID); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); - const allTransactionViolations = useAllTransactionViolations(transaction?.transactionID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); - const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${report?.policyID}`); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); - const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); - const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const updateParams = useMoneyRequestUpdateParams(transaction?.transactionID, report); + const {updateDescription} = useUpdateTransactionDescription({transaction, updateParams}); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -108,13 +93,6 @@ function DynamicIOURequestStepDescription({ const [isSaved, setIsSaved] = useState(false); const [isDiscardModalVisible, setIsDiscardModalVisible] = useState(false); useRestartOnReceiptFailure(transaction, reportID, iouType, action); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserAccountIDParam = currentUserPersonalDetails.accountID; - const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const delegateAccountID = useDelegateAccountID(); - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); - const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); /** * @returns - An object containing the errors for each inputID @@ -156,29 +134,7 @@ function DynamicIOURequestStepDescription({ } if (action === CONST.IOU.ACTION.EDIT) { - updateMoneyRequestDescription({ - isVendorMatchingBetaEnabled, - transactionID: transaction?.transactionID, - transaction, - transactionThreadReport: report, - parentReport, - iouReportOwnerLogin, - comment: newComment, - policy, - policyTagList: policyTags, - policyCategories, - policyVendors, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - violations: allTransactionViolations, - getCurrencyDecimals, - getCurrencySymbol, - rules, - }); + updateDescription(newComment); } else { setMoneyRequestDescription(transaction?.transactionID, newComment, isTransactionDraft, hasReceipt(transaction)); } diff --git a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx index 59a204fb7e27..f3def1458d6e 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx @@ -3,26 +3,20 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import TextInput from '@components/TextInput'; -import useAllTransactionViolations from '@hooks/useAllTransactionViolations'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams'; import useNavigateBackOnSave from '@hooks/useNavigateBackOnSave'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; -import {usePersonalDetail} from '@hooks/usePersonalDetails'; -import usePolicy from '@hooks/usePolicy'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionMerchant from '@hooks/useUpdateTransactionMerchant'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {isMerchantRequired} from '@libs/MoneyRequestUtils'; import {getTransactionDetails} from '@libs/ReportUtils'; import {hasReceipt} from '@libs/TransactionUtils'; @@ -30,7 +24,6 @@ import {getMerchantError, getMerchantErrorMessage, isInvalidMerchantValue} from import {clearMoneyRequestMerchant, setMoneyRequestMerchant} from '@userActions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; -import {updateMoneyRequestMerchant} from '@userActions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -39,8 +32,6 @@ import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/MoneyRequestMerchantForm'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {loginSelector} from '@selectors/PersonalDetails'; import React, {useState} from 'react'; import {View} from 'react-native'; @@ -62,15 +53,9 @@ function DynamicIOURequestStepMerchant({ report, }: DynamicIOURequestStepMerchantProps) { const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.path); - const policy = usePolicy(report?.policyID); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); - const allTransactionViolations = useAllTransactionViolations(transactionID); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const updateParams = useMoneyRequestUpdateParams(transactionID, report); + const {updateMerchant: updateSavedExpenseMerchant} = useUpdateTransactionMerchant({transactionID, transaction, updateParams}); const styles = useThemeStyles(); const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -87,14 +72,6 @@ function DynamicIOURequestStepMerchant({ const [currentMerchant, setCurrentMerchant] = useState(initialMerchant); const [isSaved, setIsSaved] = useState(false); const [isDiscardModalVisible, setIsDiscardModalVisible] = useState(false); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const delegateAccountID = useDelegateAccountID(); - const currentUserAccountIDParam = currentUserPersonalDetails.accountID; - const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); - const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const {isOffline} = useNetwork(); const isMerchantFieldRequired = isMerchantRequired(report, transaction); @@ -115,8 +92,6 @@ function DynamicIOURequestStepMerchant({ setCurrentMerchant(value); }; - const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - const updateMerchant = (value: FormOnyxValues) => { const newMerchant = value.moneyRequestMerchant?.trim(); @@ -142,29 +117,7 @@ function DynamicIOURequestStepMerchant({ // also calling setMoneyRequestMerchant would trigger a redundant Onyx commit and // re-render every subscriber of that key for nothing. if (isEditing) { - updateMoneyRequestMerchant({ - isVendorMatchingBetaEnabled, - transactionID, - transaction, - transactionThreadReport: report, - parentReport, - iouReportOwnerLogin, - value: newMerchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - policy, - policyTagList: policyTags, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - violations: allTransactionViolations, - getCurrencyDecimals, - getCurrencySymbol, - rules, - }); + updateSavedExpenseMerchant(newMerchant); } else if (!newMerchant) { clearMoneyRequestMerchant(transactionID); } else { diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx index 59f2e31d7bc8..5e431860508a 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx @@ -1,21 +1,17 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; -import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; +import useUpdateTransactionTaxAmount from '@hooks/useUpdateTransactionTaxAmount'; import {setMoneyRequestCurrency, setMoneyRequestTaxAmount} from '@libs/actions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; -import {updateMoneyRequestTaxAmount} from '@libs/actions/IOU/UpdateMoneyRequest'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import {getTransactionDetails} from '@libs/ReportUtils'; import {calculateTaxAmount, getAmount, getDefaultTaxCode, getTaxValue, getTaxAmount as getTransactionTaxAmount} from '@libs/TransactionUtils'; @@ -33,8 +29,6 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import {useFocusEffect} from '@react-navigation/native'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {loginSelector} from '@selectors/PersonalDetails'; import React, {useCallback, useRef} from 'react'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -72,14 +66,9 @@ function DynamicIOURequestStepTaxAmountPage({ const {policy} = usePolicyForTransaction({transaction, reportPolicyID: report?.policyID, action, iouType}); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.path); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(parentReport?.policyID)}`); - const [iouReportOwnerLogin] = usePersonalDetail(parentReport?.ownerAccountID, loginSelector); - const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const updateParams = useMoneyRequestUpdateParams(transactionID, report); + const {updateTaxAmount: updateSavedExpenseTaxAmount} = useUpdateTransactionTaxAmount({transactionID, updateParams, policy}); const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -88,13 +77,6 @@ function DynamicIOURequestStepTaxAmountPage({ const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT; const focusTimeoutRef = useRef(undefined); useRestartOnReceiptFailure(transaction, reportID, iouType, action); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserAccountIDParam = currentUserPersonalDetails.accountID; - const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const delegateAccountID = useDelegateAccountID(); - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); - const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const currentTransaction = isEditingSplitBill && !isEmptyObject(splitDraftTransaction) ? splitDraftTransaction : transaction; const transactionDetails = getTransactionDetails(currentTransaction); @@ -135,26 +117,7 @@ function DynamicIOURequestStepTaxAmountPage({ saveAndNavigateBack(); return; } - updateMoneyRequestTaxAmount({ - isVendorMatchingBetaEnabled, - transactionID, - transactionThreadReport: report, - parentReport, - iouReportOwnerLogin, - taxAmount: taxAmountInSmallestCurrencyUnits, - policy, - policyTagList: policyTags, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - getCurrencyDecimals, - getCurrencySymbol, - rules, - }); + updateSavedExpenseTaxAmount(taxAmountInSmallestCurrencyUnits); saveAndNavigateBack(); return; } diff --git a/src/styles/index.ts b/src/styles/index.ts index 1e4e8026fcde..53f53c40daca 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6944,7 +6944,7 @@ const staticStyles = (theme: ThemeColors) => }, wideRHPMoneyRequestReceiptViewScrollViewContainer: { - ...spacing.pt3, + ...spacing.pt4, ...spacing.pb2, minHeight: '100%', }, diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 383ac771e1ff..e214374b7b3c 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -831,6 +831,10 @@ export default { paddingBottom: '10%', }, + gap0: { + gap: 0, + }, + gap1: { gap: 4, }, diff --git a/tests/ui/AmountActionButtonsTest.tsx b/tests/ui/AmountActionButtonsTest.tsx index cbf986eac1cb..5444bad7bb66 100644 --- a/tests/ui/AmountActionButtonsTest.tsx +++ b/tests/ui/AmountActionButtonsTest.tsx @@ -1,4 +1,4 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; +import {fireEvent, render, renderHook, screen} from '@testing-library/react-native'; import AmountFormField from '@components/AmountFormField'; import ComposeProviders from '@components/ComposeProviders'; @@ -6,7 +6,10 @@ import KeypadAmountEditor from '@components/KeypadAmountEditor'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import useTheme from '@hooks/useTheme'; + import type * as NativeNavigation from '@react-navigation/native'; +import type {View} from 'react-native'; import React from 'react'; @@ -270,4 +273,59 @@ describe('Amount action buttons', () => { expect(input.props.selection).toEqual({start: 2, end: 2}); }); }); + + describe('borderless style', () => { + const renderButtons = (shouldUseBorderlessStyle?: boolean, currencyButtonRef?: React.RefObject) => + renderWithProviders( + + + + , + ); + + it('mutes the button labels when the buttons sit inside a field without their fill', async () => { + // Given the flip and currency buttons drawn borderless, as part of the field they sit in + const mutedColor = renderHook(() => useTheme()).result.current.textSupporting; + + // When they render + renderButtons(true); + await waitForBatchedUpdatesWithAct(); + + // Then their labels take the supporting color, matching the icons beside them + expect(screen.getByText('Flip')).toHaveStyle({color: mutedColor}); + expect(screen.getByText('USD')).toHaveStyle({color: mutedColor}); + }); + + it('keeps the button labels in their own color by default', async () => { + // Given the flip and currency buttons with their default fill + const mutedColor = renderHook(() => useTheme()).result.current.textSupporting; + + // When they render + renderButtons(); + await waitForBatchedUpdatesWithAct(); + + // Then their labels keep the button's text color, so existing callers look the same + expect(screen.getByText('Flip')).not.toHaveStyle({color: mutedColor}); + expect(screen.getByText('USD')).not.toHaveStyle({color: mutedColor}); + }); + + it('hands the currency button to its ref, so focus can return to it once the picker closes', async () => { + // Given a ref for the currency button + const currencyButtonRef = React.createRef(); + + // When the button renders + renderButtons(true, currencyButtonRef); + await waitForBatchedUpdatesWithAct(); + + // Then the ref points at it + expect(currencyButtonRef.current).not.toBeNull(); + }); + }); }); diff --git a/tests/ui/MoneyReportContentCreatedTest.tsx b/tests/ui/MoneyReportContentCreatedTest.tsx index 8e930bc518e7..0112af62b316 100644 --- a/tests/ui/MoneyReportContentCreatedTest.tsx +++ b/tests/ui/MoneyReportContentCreatedTest.tsx @@ -1,4 +1,4 @@ -import {act, render} from '@testing-library/react-native'; +import {act, render, screen} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type {ShowContextMenuActionsContextType, ShowContextMenuStateContextType} from '@components/ShowContextMenuContext'; @@ -129,6 +129,23 @@ describe('MoneyReportContentCreated', () => { expect(calls.every((p) => p?.isTotalPending !== true)).toBe(true); }); + it('shows no empty-state background above a one-expense report, which opens on the expense fields', async () => { + // Given a report holding one expense, whose thread is loaded so the report shows that expense's fields + const report = buildExpenseReport(); + const transaction = LHNTestUtils.getFakeTransaction(report.reportID, 1500); + const threadReport = buildExpenseReport({reportID: 'thread_1'}); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction); + }); + + // When the top of the report renders + renderWithProps({report, transactionThreadReport: threadReport}); + await waitForBatchedUpdatesWithAct(); + + // Then the chat's empty-state illustration doesn't peek through above the fields + expect(screen.queryByTestId('animated-bg')).not.toBeOnTheScreen(); + }); + it('forwards isTotalPending=false when there are zero transactions', async () => { const report = buildExpenseReport(); diff --git a/tests/ui/MoneyRequestViewInlineFieldsTest.tsx b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx new file mode 100644 index 000000000000..e1224a592bc2 --- /dev/null +++ b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx @@ -0,0 +1,492 @@ +import {act, fireEvent, render, renderHook, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import AmountInputField from '@components/ReportActionItem/MoneyRequestViewFields/AmountInputField'; +import DescriptionInputField from '@components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField'; +import MerchantInputField from '@components/ReportActionItem/MoneyRequestViewFields/MerchantInputField'; +import TaxAmountInputField from '@components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField'; + +import type {DuplicateTransactionsAndViolations} from '@hooks/useDuplicateTransactionsAndViolations'; +import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams'; +import type {MoneyRequestUpdateParams} from '@hooks/useMoneyRequestUpdateParams'; +import useUpdateTransactionDate from '@hooks/useUpdateTransactionDate'; + +import { + updateMoneyRequestAmountAndCurrency, + updateMoneyRequestDate, + updateMoneyRequestDescription, + updateMoneyRequestMerchant, + updateMoneyRequestTaxAmount, +} from '@userActions/IOU/UpdateMoneyRequest'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, Report, Transaction} from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +// Only what each field asks to be saved matters here, so the writes are stubbed to record it. +jest.mock('@userActions/IOU/UpdateMoneyRequest', () => ({ + ...jest.requireActual>('@userActions/IOU/UpdateMoneyRequest'), + updateMoneyRequestMerchant: jest.fn(), + updateMoneyRequestDescription: jest.fn(), + updateMoneyRequestAmountAndCurrency: jest.fn(), + updateMoneyRequestDate: jest.fn(), + updateMoneyRequestTaxAmount: jest.fn(), +})); +// The amount input asks the navigator whether its screen is focused, and these fields render outside one. +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual>('@react-navigation/native'), + useIsFocused: jest.fn(() => true), +})); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListActions: jest.fn(() => ({ + getCurrencyDecimals: jest.fn(() => 2), + getCurrencySymbol: jest.fn(() => '$'), + convertToDisplayString: jest.fn((amountInCents = 0) => `USD ${(amountInCents / 100).toFixed(2)}`), + })), +})); + +const TRANSACTION_ID = 'transaction1'; +const EXPENSE_REPORT_ID = 'expenseReport1'; +const THREAD_REPORT_ID = 'thread1'; +const POLICY_ID = 'policy1'; +const TAX_CODE = 'id_TAX_10'; + +const transaction: Transaction = { + transactionID: TRANSACTION_ID, + reportID: EXPENSE_REPORT_ID, + amount: 5000, + currency: CONST.CURRENCY.USD, + created: '2025-06-01', + merchant: 'Coffee Shop', + comment: {comment: 'Team lunch'}, +}; +const expenseReport: Report = {reportID: EXPENSE_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, policyID: POLICY_ID}; +const threadReport: Report = {reportID: THREAD_REPORT_ID, type: CONST.REPORT.TYPE.CHAT, parentReportID: EXPENSE_REPORT_ID, parentReportActionID: 'action1', policyID: POLICY_ID}; + +// The expense has no duplicates, so a save has none of their violations to recompute. +const NO_DUPLICATES: DuplicateTransactionsAndViolations = {duplicateTransactions: {}, duplicateTransactionViolations: {}}; + +function Providers({children}: {children: React.ReactNode}) { + return {children}; +} + +const renderField = (field: React.ReactElement) => render({field}); + +describe('saved expense inline fields', () => { + // What each field saves with, read the way the expense view reads it and hands it down + let updateParams: MoneyRequestUpdateParams; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, expenseReport); + await waitForBatchedUpdatesWithAct(); + const {result} = renderHook(() => useMoneyRequestUpdateParams(TRANSACTION_ID, threadReport), {wrapper: Providers}); + await waitForBatchedUpdatesWithAct(); + updateParams = result.current; + }); + + describe('MerchantInputField', () => { + const renderMerchant = (errorText?: string) => + renderField( + , + ); + + it('saves the typed merchant when the input loses focus', async () => { + // Given a saved expense whose merchant is shown in the input + renderMerchant(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Merchant'); + expect(input.props.value).toBe('Coffee Shop'); + + // When a new merchant is typed with spaces around it and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, ' Tea House '); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the trimmed merchant is saved to that expense, as the merchant page would save it + expect(updateMoneyRequestMerchant).toHaveBeenCalledTimes(1); + expect(updateMoneyRequestMerchant).toHaveBeenCalledWith(expect.objectContaining({transactionID: TRANSACTION_ID, value: 'Tea House', transactionThreadReport: threadReport})); + }); + + it('strips invisible characters from the typed merchant before saving it', async () => { + // Given a saved expense whose merchant is shown in the input + renderMerchant(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Merchant'); + + // When a merchant pasted with zero-width spaces around it is typed and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, '\u200BTea House\u200B'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then only the visible merchant is saved, as the merchant page would save it + expect(updateMoneyRequestMerchant).toHaveBeenCalledTimes(1); + expect(updateMoneyRequestMerchant).toHaveBeenCalledWith(expect.objectContaining({value: 'Tea House'})); + }); + + it('does not save a merchant that did not change', async () => { + // Given a saved expense whose merchant is shown in the input + renderMerchant(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Merchant'); + + // When only a trailing space is typed and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, 'Coffee Shop '); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then nothing is sent, so no pointless edit shows up on the expense + expect(updateMoneyRequestMerchant).not.toHaveBeenCalled(); + }); + + it('does not write the old merchant back when it changed while the untouched input was focused', async () => { + // Given the merchant input focused but not typed in + const {rerender} = renderMerchant(); + await waitForBatchedUpdatesWithAct(); + fireEvent(screen.getByLabelText('Merchant'), 'focus'); + + // When the merchant is changed elsewhere, such as on another device, and the input is then left + rerender( + + + , + ); + fireEvent(screen.getByLabelText('Merchant'), 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the input shows the new merchant and saves nothing, rather than overwriting it with the stale one + expect(screen.getByLabelText('Merchant').props.value).toBe('Tea House'); + expect(updateMoneyRequestMerchant).not.toHaveBeenCalled(); + }); + + it('shows an error instead of saving a merchant the merchant page would reject', async () => { + // Given a saved expense whose merchant is shown in the input + renderMerchant(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Merchant'); + + // When the placeholder for a missing merchant is typed and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the field reports it under the input and the expense keeps its merchant + expect(screen.getByText('Please enter a valid merchant')).toBeOnTheScreen(); + expect(updateMoneyRequestMerchant).not.toHaveBeenCalled(); + }); + + it('shows the violation it is handed while the typed value is fine', async () => { + // Given a saved expense with a merchant violation + // When the field renders + renderMerchant('Merchant is missing'); + await waitForBatchedUpdatesWithAct(); + + // Then the violation shows under the input + expect(screen.getByText('Merchant is missing')).toBeOnTheScreen(); + }); + }); + + describe('DescriptionInputField', () => { + const renderDescription = () => + renderField( + , + ); + + it('saves the typed description when the input loses focus', async () => { + // Given a saved expense whose description is shown in the input + renderDescription(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Description'); + expect(input.props.value).toBe('Team lunch'); + + // When a new description is typed with spaces around it and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, ' Client dinner '); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the trimmed description is saved to that expense, as the description page would save it + expect(updateMoneyRequestDescription).toHaveBeenCalledTimes(1); + expect(updateMoneyRequestDescription).toHaveBeenCalledWith( + expect.objectContaining({transactionID: TRANSACTION_ID, comment: 'Client dinner', transactionThreadReport: threadReport}), + ); + }); + + it('does not save a description that did not change', async () => { + // Given a saved expense whose description is shown in the input + renderDescription(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Description'); + + // When the input is focused and left as it was + fireEvent(input, 'focus'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then nothing is sent + expect(updateMoneyRequestDescription).not.toHaveBeenCalled(); + }); + + it('does not save a description whose saved HTML ends in a line break when the input is only focused', async () => { + // Given a saved description stored as HTML with a trailing line break, which reads back as markdown with a + // trailing newline that trimming would turn into a change + renderField( + '}}} + updateParams={updateParams} + isReportInGroupPolicy + />, + ); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Description'); + + // When the input is focused and left without typing + fireEvent(input, 'focus'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then nothing is sent, as the user changed nothing + expect(updateMoneyRequestDescription).not.toHaveBeenCalled(); + }); + + it('shows an error instead of saving a description over the length limit', async () => { + // Given a saved expense whose description is shown in the input + renderDescription(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Description'); + + // When a description longer than the limit is typed and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, 'a'.repeat(CONST.DESCRIPTION_LIMIT + 1)); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the field reports it under the input and the expense keeps its description + expect(screen.getByText(/Character limit exceeded/)).toBeOnTheScreen(); + expect(updateMoneyRequestDescription).not.toHaveBeenCalled(); + }); + }); + describe('AmountInputField', () => { + const policy: Policy = { + id: POLICY_ID, + name: 'Workspace', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@test.com', + outputCurrency: CONST.CURRENCY.USD, + }; + const renderAmount = (amount = 5000) => + renderField( + , + ); + + it('saves the typed amount when the input loses focus', async () => { + // Given a saved expense whose amount is shown in the input + renderAmount(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Amount'); + expect(input.props.value).toBe('50.00'); + + // When a new amount is typed and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, '72.5'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the amount is saved to that expense in the smallest currency unit, with its currency unchanged + expect(updateMoneyRequestAmountAndCurrency).toHaveBeenCalledTimes(1); + expect(updateMoneyRequestAmountAndCurrency).toHaveBeenCalledWith( + expect.objectContaining({transactionID: TRANSACTION_ID, amount: 7250, currency: CONST.CURRENCY.USD, transactionThreadReport: threadReport}), + ); + }); + + it('does not save an amount that is worth the same as the saved one', async () => { + // Given a saved expense whose amount is shown in the input + renderAmount(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Amount'); + + // When the same amount is typed without its trailing zeros and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, '50'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then nothing is sent, and the input goes back to showing the amount the way the expense holds it + expect(updateMoneyRequestAmountAndCurrency).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Amount').props.value).toBe('50.00'); + }); + + it('brings the saved amount back when the input is cleared', async () => { + // Given a saved expense whose amount is shown in the input + renderAmount(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Amount'); + + // When the amount is deleted and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, ''); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then nothing is sent and the saved amount shows again, since an expense always has an amount + expect(updateMoneyRequestAmountAndCurrency).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Amount').props.value).toBe('50.00'); + }); + + it('shows an error instead of saving an amount the amount page would reject', async () => { + // Given a saved expense between two people, which cannot be for nothing + renderAmount(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Amount'); + + // When zero is typed and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, '0'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the field reports it under the input and the expense keeps its amount + expect(screen.getByText('Please enter a valid amount before continuing')).toBeOnTheScreen(); + expect(updateMoneyRequestAmountAndCurrency).not.toHaveBeenCalled(); + }); + }); + + describe('TaxAmountInputField', () => { + // A 10% tax included in a 55.00 expense works out to 5.00, which is the most the tax amount can be + const taxPolicy: Policy = { + id: POLICY_ID, + name: 'Workspace', + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + owner: 'owner@test.com', + outputCurrency: CONST.CURRENCY.USD, + taxRates: {name: 'Tax', defaultExternalID: TAX_CODE, defaultValue: '10%', foreignTaxDefault: TAX_CODE, taxes: {[TAX_CODE]: {name: 'VAT', value: '10%'}}}, + }; + const taxedTransaction: Transaction = {...transaction, amount: 5500, taxCode: TAX_CODE, taxAmount: 300}; + const renderTaxAmount = () => + renderField( + , + ); + + it('saves the typed tax amount when the input loses focus', async () => { + // Given a saved expense whose tax amount is shown in the input + renderTaxAmount(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Tax amount'); + expect(input.props.value).toBe('3.00'); + + // When a new tax amount within what the tax rate allows is typed and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, '4.5'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then it is saved to that expense in the smallest currency unit, as the tax amount page would save it + expect(updateMoneyRequestTaxAmount).toHaveBeenCalledTimes(1); + expect(updateMoneyRequestTaxAmount).toHaveBeenCalledWith(expect.objectContaining({transactionID: TRANSACTION_ID, taxAmount: 450, transactionThreadReport: threadReport})); + }); + + it('shows an error instead of saving a tax amount above what the tax rate works out to', async () => { + // Given a saved expense whose tax rate allows at most 5.00 of tax + renderTaxAmount(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Tax amount'); + + // When a larger tax amount is typed and the input is left + fireEvent(input, 'focus'); + fireEvent.changeText(input, '9'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then the field reports the limit under the input, as the tax amount page does, and nothing is saved + expect(screen.getByText(/maximum tax amount/i)).toBeOnTheScreen(); + expect(updateMoneyRequestTaxAmount).not.toHaveBeenCalled(); + }); + + it('does not save when the input is left without a change', async () => { + // Given a saved expense whose tax amount is shown in the input + renderTaxAmount(); + await waitForBatchedUpdatesWithAct(); + const input = screen.getByLabelText('Tax amount'); + + // When the input is focused and left without typing + fireEvent(input, 'focus'); + fireEvent(input, 'blur'); + await waitForBatchedUpdatesWithAct(); + + // Then nothing is sent, so no pointless edit shows up on the expense + expect(updateMoneyRequestTaxAmount).not.toHaveBeenCalled(); + }); + }); + + describe('useUpdateTransactionDate', () => { + it('saves the picked date to the saved expense', async () => { + // Given the save hook for a reported expense + const {result} = renderHook( + () => useUpdateTransactionDate({transaction, updateParams: useMoneyRequestUpdateParams(TRANSACTION_ID, threadReport), duplicates: NO_DUPLICATES, isTrackExpense: false}), + { + wrapper: Providers, + }, + ); + await waitForBatchedUpdatesWithAct(); + + // When a date is saved through it + act(() => result.current.updateDate('2025-07-15')); + + // Then the date is written to that expense with its parent report, which the write needs to recompute violations + expect(updateMoneyRequestDate).toHaveBeenCalledTimes(1); + expect(updateMoneyRequestDate).toHaveBeenCalledWith( + expect.objectContaining({transactionID: TRANSACTION_ID, value: '2025-07-15', transactionThreadReport: threadReport, parentReport: expenseReport}), + ); + }); + }); +}); diff --git a/tests/ui/MoneyRequestViewReceiptTest.tsx b/tests/ui/MoneyRequestViewReceiptTest.tsx index 6f27576349b9..7ff86fabaf1d 100644 --- a/tests/ui/MoneyRequestViewReceiptTest.tsx +++ b/tests/ui/MoneyRequestViewReceiptTest.tsx @@ -43,6 +43,8 @@ jest.mock('@react-navigation/native', () => ({ addListener: jest.fn(() => jest.fn()), })), useIsFocused: jest.fn(() => true), + // Inline field inputs subscribe to screen focus, and these tests render the view outside a navigator + useFocusEffect: jest.fn(), useRoute: jest.fn(() => ({key: 'central-report', name: '', params: {reportID: '1'}})), })); @@ -62,16 +64,6 @@ jest.mock('@components/ReportActionItem/MoneyRequestReceiptView', () => { return () => ; }); -jest.mock('@pages/inbox/report/AnimatedEmptyStateBackground', () => { - const RN = jest.requireActual>>('react-native'); - return () => ; -}); - -jest.mock('@components/MenuItemWithTopDescription', () => { - const RN = jest.requireActual>>('react-native'); - return ({description}: {description?: string}) => ; -}); - jest.mock('@components/MenuItem', () => { const RN = jest.requireActual>>('react-native'); const {default: actualMenuItem} = jest.requireActual<{default: Record}>('@components/MenuItem'); @@ -140,7 +132,6 @@ const renderMoneyRequestView = () => transactionThreadReport={threadReport} parentReportID={expenseReportID} expensePolicy={expensePolicy} - shouldShowAnimatedBackground={false} /> , ); @@ -246,7 +237,7 @@ describe('MoneyRequestView receipt visibility with a wide RHP open', () => { await waitFor(() => { // Anchor on an always-rendered field so the absence below reflects the hidden receipt, not an unrendered view. - expect(screen.getByTestId('menu-item-common.merchant')).toBeOnTheScreen(); + expect(screen.getByLabelText('common.merchant')).toBeOnTheScreen(); expect(screen.queryByTestId('money-request-receipt-view')).not.toBeOnTheScreen(); }); }); diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 87ba6e099779..dce3246cccd2 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -1,11 +1,11 @@ -import {act, render, screen, waitFor} from '@testing-library/react-native'; +import {act, render, renderHook, screen, waitFor} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import MoneyRequestView from '@components/ReportActionItem/MoneyRequestView'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; -import useThemeStyles from '@hooks/useThemeStyles'; +import useTheme from '@hooks/useTheme'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -14,7 +14,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Transaction} from '@src/types/onyx'; import type * as NativeNavigation from '@react-navigation/native'; -import type {StyleProp, TextStyle} from 'react-native'; import type {PartialDeep} from 'type-fest'; import escapeRegExp from 'lodash/escapeRegExp'; @@ -29,42 +28,6 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' // The component renders outside a NavigationContainer here, so the screen-bound hook cannot read focus. jest.mock('@hooks/useScreenBoundDynamicRoute', () => () => (suffix: string) => suffix); -type MockMenuItemWithTopDescriptionProps = { - description?: string; - title?: string; - interactive?: boolean; - descriptionTextStyle?: StyleProp; - errorText?: string; - brickRoadIndicator?: string; -}; - -const mockMenuItemWithTopDescription = jest.fn(({description, title, interactive, errorText, brickRoadIndicator}: MockMenuItemWithTopDescriptionProps) => { - const RN = jest.requireActual>>('react-native'); - - return ( - <> - - {interactive ? 'editable' : 'readonly'} - - {title !== undefined && ( - - {title} - - )} - {errorText !== undefined && ( - - {errorText} - - )} - {brickRoadIndicator !== undefined && ( - - {brickRoadIndicator} - - )} - - ); -}); - jest.mock('@expensify/react-native-hybrid-app', () => ({ isHybridApp: jest.fn(() => false), })); @@ -86,6 +49,7 @@ jest.mock('@react-navigation/native', () => ({ addListener: jest.fn(() => jest.fn()), })), useIsFocused: jest.fn(() => true), + useFocusEffect: jest.fn(), useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: '1'}})), })); @@ -100,13 +64,6 @@ jest.mock('@pages/inbox/report/AnimatedEmptyStateBackground', () => { return () => ; }); -// Mock MenuItemWithTopDescription to expose interactive state via text and title via a sibling testID. -// Title lives in a sibling element so existing toHaveTextContent('editable'|'readonly') assertions on -// the menu-item testID stay strict-equal — they don't pick up the title text. -jest.mock('@components/MenuItemWithTopDescription', () => { - return (props: MockMenuItemWithTopDescriptionProps) => mockMenuItemWithTopDescription(props); -}); - // Mock the legacy MenuItem (used for some fields like billable), but keep the real compound parts the migrated rows render with jest.mock('@components/MenuItem', () => { const RN = jest.requireActual>>('react-native'); @@ -133,6 +90,18 @@ TestHelper.setupGlobalFetchMock(); /** Matches the accessibility label a compound field row builds from its name, followed by its value when it has one */ const fieldLabel = (name: string) => new RegExp(`^${escapeRegExp(name)}(,|$)`); +/** Matches the Amount row, whose name also carries the expense type, e.g. `iou.amount • iou.cash • Iou.reimbursable` */ +const amountLabel = /^iou\.amount/; + +/** An editable amount is the create expense form's amount input, holding the amount of the test expense */ +const expectEditableAmountInput = () => expect(screen.getByDisplayValue(/^-?50\.00$/)).toBeOnTheScreen(); + +/** A read-only row keeps its label but is not pressable */ +const expectReadOnlyRow = (label: RegExp) => { + expect(screen.getByLabelText(label)).toBeOnTheScreen(); + expect(screen.queryByRole('button', {name: label})).not.toBeOnTheScreen(); +}; + const currentUserAccountID = 10; const currentUserEmail = 'test@test.com'; const policyID = 'policy_mrv_test'; @@ -141,23 +110,10 @@ const parentReportActionID = 'parent_action_mrv'; const transactionID = 'txn_mrv_test'; const SCREEN_WRAPPER_STATUS = {didScreenTransitionEnd: true, shouldUseNarrowLayoutOnWideRHP: false, isSafeAreaTopPaddingApplied: true, isSafeAreaBottomPaddingApplied: true}; -let renderedThemeStyles: ReturnType | undefined; - -function ThemeStylesObserver() { - const styles = useThemeStyles(); - - React.useEffect(() => { - renderedThemeStyles = styles; - }, [styles]); - - return null; -} - const renderMoneyRequestView = (threadReport: ReturnType, policy?: PartialDeep, readonly = false) => render( - @@ -190,7 +145,6 @@ describe('MoneyRequestView edit fields', () => { await act(async () => { await Onyx.clear(); }); - mockMenuItemWithTopDescription.mockClear(); }); const setupTestData = async (isSettledReport = false) => { @@ -263,26 +217,141 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - const amountItem = screen.getByTestId(/^menu-item-iou\.amount/); - expect(amountItem).toBeOnTheScreen(); - expect(amountItem).toHaveTextContent('editable'); + expectEditableAmountInput(); }); + // An editable merchant is a text input holding the merchant await waitFor(() => { - expect(screen.getByTestId('menu-item-common.merchant')).toBeOnTheScreen(); - expect(screen.getByTestId('menu-item-common.merchant')).toHaveTextContent('editable'); + expect(screen.getByDisplayValue('Coffee Shop')).toBeOnTheScreen(); }); }); - it('shows the Category and Tag rows from the report policy when nothing is selected yet', async () => { + it('shows the fields of an editable expense as inputs edited in place', async () => { + // Given an open expense the user can edit const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID, }; + await setupTestData(); + + // When the expense view renders + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then the amount, description, merchant and date are inputs holding the saved values, and none of the rows + // that open a page are left + await waitFor(() => { + expect(screen.getByLabelText('common.merchant').props.value).toBe('Coffee Shop'); + }); + expect(screen.getByLabelText('common.description')).toBeOnTheScreen(); + expect(screen.getByLabelText('common.date').props.value).toBe('2025-06-01'); + // The fixture stores a positive amount on an expense report, which the view shows as a negative one + expect(screen.getByLabelText(amountLabel).props.value).toBe('-50.00'); + }); + it('shows no empty-state background behind the fields', async () => { + // Given an open expense, whose view starts with its fields rather than an empty chat + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + await setupTestData(); + + // When the expense view renders + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then the chat's empty-state illustration doesn't peek through above the fields + await waitFor(() => { + expect(screen.getByLabelText('common.merchant')).toBeOnTheScreen(); + }); + expect(screen.queryByTestId('animated-bg')).not.toBeOnTheScreen(); + }); + + it('keeps the merchant of a receipt being scanned as a field that opens its page', async () => { + // Given an open expense whose receipt is still being scanned, so its merchant is not known yet + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + amount: 0, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + receipt: {state: CONST.IOU.RECEIPT_STATE.SCANNING, source: 'receipt.jpg'}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + // When the expense view renders + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then the merchant shows the scan's status in a field that can still be pressed to open the merchant page, as it + // could before, instead of an input that would invite typing over a value the scan is about to fill in + await waitFor(() => { + expect(screen.getByRole('button', {name: fieldLabel('common.merchant')})).toHaveTextContent(/iou\.receiptStatusTitle$/); + }); + expect(screen.queryByDisplayValue('iou.receiptStatusTitle')).not.toBeOnTheScreen(); + }); + + it('locks the fields of a settled expense', async () => { + // Given a settled expense, whose amount, merchant and date can no longer be edited + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + await setupTestData(true); + // When the expense view renders + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then each of them is a locked field showing its value, as an input would invite an edit that cannot be saved + await waitFor(() => { + expect(screen.getByLabelText(fieldLabel('common.merchant'))).toHaveTextContent('common.merchantCoffee Shop'); + }); + expect(screen.getByLabelText(fieldLabel('common.date'))).toHaveTextContent('common.date2025-06-01'); + expect(screen.getByLabelText(amountLabel).props.value).toBeUndefined(); + expect(screen.queryByLabelText('common.merchant')).toBeNull(); + expect(screen.queryByLabelText('common.date')).toBeNull(); + }); + + it('shows the value of a locked field in the standard text color', async () => { + // Given a settled expense, whose merchant is a locked field + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + await setupTestData(true); + // Read before the view renders, as a later render would replace the view on the screen + const standardColor = renderHook(() => useTheme()).result.current.text; + + // When the expense view renders + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then the merchant takes the standard text color rather than the muted one, since the field's disabled fill already marks it as locked + await waitFor(() => { + expect(screen.getByText('Coffee Shop')).toBeOnTheScreen(); + }); + expect(screen.getByText('Coffee Shop')).toHaveStyle({color: standardColor}); + }); + + it('shows the category and tag from the report policy as dropdown fields when nothing is selected yet', async () => { + // Given an open expense with no category or tag yet, on a workspace with categories and tags + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + await setupTestData(); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, { Travel: {name: 'Travel', enabled: true}, @@ -292,51 +361,37 @@ describe('MoneyRequestView edit fields', () => { }); }); + // When the expense view renders renderMoneyRequestView(threadReport, {areCategoriesEnabled: true, areTagsEnabled: true}); await waitForBatchedUpdatesWithAct(); + // Then both show from the report's workspace, as fields that report a closed list, which is how a field that + // opens its list in place reads await waitFor(() => { - expect(screen.getByLabelText(fieldLabel('common.category'))).toBeOnTheScreen(); - expect(screen.getByTestId('menu-item-Location')).toBeOnTheScreen(); + expect(screen.getByLabelText(fieldLabel('common.category')).props.accessibilityState).toMatchObject({expanded: false}); }); + expect(screen.getByLabelText(fieldLabel('Location')).props.accessibilityState).toMatchObject({expanded: false}); }); - it('preserves word wrapping when expense detail labels are made non-selectable', async () => { - // Given an expense with all labels affected by the non-selectable text styling + it('shows the attendees and report of an editable expense as dropdown fields', async () => { + // Given an open expense on a Control workspace that tracks attendees const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID, }; - const longTagName = 'LongTagNameWithoutSpaces'; - await setupTestData(); - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { - [longTagName]: {name: longTagName, orderWeight: 0, required: false, tags: {Berlin: {name: 'Berlin', enabled: true}}}, - }); - }); - // When the expense details are rendered - renderMoneyRequestView(threadReport, {areTagsEnabled: true}); + // When the expense view renders + renderMoneyRequestView(threadReport, {type: CONST.POLICY.TYPE.CORPORATE, isAttendeeTrackingEnabled: true}); await waitForBatchedUpdatesWithAct(); - // Then each affected description keeps both breakWord and userSelectNone + // Then both are fields that report a closed list, which is how a field that opens its list in place reads, + // instead of rows that open a page of their own await waitFor(() => { - const renderedRows = mockMenuItemWithTopDescription.mock.calls.map(([props]) => props); - const affectedRows = [ - renderedRows.find(({description}) => description === longTagName), - renderedRows.find(({description}) => description?.startsWith('iou.amount')), - renderedRows.find(({description}) => description === 'common.description'), - renderedRows.find(({description}) => description === 'common.merchant'), - renderedRows.find(({description}) => description === 'common.report'), - ]; - - expect(affectedRows).not.toContain(undefined); - for (const row of affectedRows) { - expect(row?.descriptionTextStyle).toEqual([renderedThemeStyles?.breakWord, renderedThemeStyles?.userSelectNone]); - } + expect(screen.getByLabelText(fieldLabel('iou.attendees')).props.accessibilityState).toMatchObject({expanded: false}); }); + expect(screen.getByLabelText(fieldLabel('common.report')).props.accessibilityState).toMatchObject({expanded: false}); }); it('marks selectable toggle labels for multi-field copying', async () => { @@ -355,7 +410,7 @@ describe('MoneyRequestView edit fields', () => { // Then both selectable labels carry the marker used by the multi-field selection scraper await waitFor(() => { - const toggleLabels = [screen.getByText(/reimbursable$/i, {includeHiddenElements: true}), screen.getByText('common.billable', {includeHiddenElements: true})]; + const toggleLabels = [screen.getByText('Iou.reimbursable', {includeHiddenElements: true}), screen.getByText('common.billable', {includeHiddenElements: true})]; for (const toggleLabel of toggleLabels) { expect(toggleLabel).toHaveProp('dataSet', expect.objectContaining({[CONST.COPYABLE_TEXT_ELEMENT]: true})); @@ -386,10 +441,10 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - expect(screen.getByTestId('menu-item-common.merchant')).toBeOnTheScreen(); + expect(screen.getByDisplayValue('Coffee Shop')).toBeOnTheScreen(); }); expect(screen.queryByLabelText(fieldLabel('common.category'))).not.toBeOnTheScreen(); - expect(screen.queryByTestId('menu-item-Location')).not.toBeOnTheScreen(); + expect(screen.queryByLabelText(fieldLabel('Location'))).not.toBeOnTheScreen(); }); const renderOtherUsersUnreportedExpenseWithTag = async (transactionOverrides: Partial = {}) => { @@ -446,7 +501,6 @@ describe('MoneyRequestView edit fields', () => { transactionThreadReport={threadReport} parentReportID={selfDMReportID} expensePolicy={undefined} - shouldShowAnimatedBackground={false} /> , @@ -461,10 +515,10 @@ describe('MoneyRequestView edit fields', () => { // Then a single generic tag row shows every level of the expense's tag, since the viewer can't see the owner's tag list names await waitFor(() => { - expect(screen.getAllByTestId('menu-item-common.tag')).toHaveLength(1); + expect(screen.getAllByLabelText(fieldLabel('common.tag'))).toHaveLength(1); }); expect(screen.getByText('Engineering, Berlin, Floor 2')).toBeOnTheScreen(); - expect(screen.queryByTestId('menu-item-Tag')).not.toBeOnTheScreen(); + expect(screen.queryByLabelText(fieldLabel('Tag'))).not.toBeOnTheScreen(); }); it("keeps the full tag row read-only when a domain admin views another user's unreported managed card expense", async () => { @@ -474,7 +528,7 @@ describe('MoneyRequestView edit fields', () => { // Then the full tag row can't be pressed, because editing it would use the admin's own unrelated tag lists and could clear the owner's tag await waitFor(() => { - expect(screen.getByTestId('menu-item-common.tag')).toHaveTextContent('readonly'); + expectReadOnlyRow(fieldLabel('common.tag')); }); expect(screen.getByText('Engineering, Berlin, Floor 2')).toBeOnTheScreen(); }); @@ -566,14 +620,12 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - const amountItem = screen.getByTestId(/^menu-item-iou\.amount/); - expect(amountItem).toBeOnTheScreen(); - expect(amountItem).toHaveTextContent('readonly'); + expectReadOnlyRow(amountLabel); }); await waitFor(() => { - expect(screen.getByTestId('menu-item-common.merchant')).toBeOnTheScreen(); - expect(screen.getByTestId('menu-item-common.merchant')).toHaveTextContent('readonly'); + expectReadOnlyRow(fieldLabel('common.merchant')); + expect(screen.queryByDisplayValue('Coffee Shop')).not.toBeOnTheScreen(); }); }); @@ -610,9 +662,9 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount/)).toHaveTextContent('editable'); - expect(screen.getByTestId('menu-item-common.merchant')).toHaveTextContent('editable'); - expect(screen.getByRole('button', {name: fieldLabel('common.date')})).toBeOnTheScreen(); + expectEditableAmountInput(); + expect(screen.getByDisplayValue('Coffee Shop')).toBeOnTheScreen(); + expect(screen.getByDisplayValue('2025-06-01')).toBeOnTheScreen(); }); }); @@ -651,10 +703,10 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount/)).toHaveTextContent('readonly'); - expect(screen.getByTestId('menu-item-common.merchant')).toHaveTextContent('readonly'); - expect(screen.getByLabelText(fieldLabel('common.date'))).toBeOnTheScreen(); - expect(screen.queryByRole('button', {name: fieldLabel('common.date')})).not.toBeOnTheScreen(); + expectReadOnlyRow(amountLabel); + expectReadOnlyRow(fieldLabel('common.merchant')); + expectReadOnlyRow(fieldLabel('common.date')); + expect(screen.queryByDisplayValue('2025-06-01')).not.toBeOnTheScreen(); }); }); @@ -675,7 +727,7 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); + expect(screen.getByLabelText(/^iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); }); }); @@ -709,7 +761,7 @@ describe('MoneyRequestView edit fields', () => { // Then the Amount description flags it as non-reimbursable, the same as in a single-expense report await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); + expect(screen.getByLabelText(/^iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); }); }); @@ -743,8 +795,8 @@ describe('MoneyRequestView edit fields', () => { // Then the Amount description flags it as reimbursable, the same as in a single-expense report await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.reimbursable/i)).toBeOnTheScreen(); - expect(screen.queryByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); + expect(screen.getByLabelText(/^iou\.amount.*iou\.reimbursable/i)).toBeOnTheScreen(); + expect(screen.queryByLabelText(/^iou\.amount.*iou\.nonReimbursable/i)).not.toBeOnTheScreen(); }); }); @@ -776,7 +828,30 @@ describe('MoneyRequestView edit fields', () => { await waitForBatchedUpdatesWithAct(); await waitFor(() => { - expect(screen.getByTestId(/^menu-item-iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); + expect(screen.getByLabelText(/^iou\.amount.*iou\.nonReimbursable/i)).toBeOnTheScreen(); + }); + }); + + it('shows the tax amount of an editable expense as an input edited in place', async () => { + // Given an open expense with tax on a workspace that tracks tax + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {taxCode: 'TAX_10', taxAmount: 1110}); + }); + await waitForBatchedUpdatesWithAct(); + + // When the expense view renders + renderMoneyRequestView(threadReport, {tax: {trackingEnabled: true}}); + await waitForBatchedUpdatesWithAct(); + + // Then the tax amount is an input holding the saved amount, instead of a row that opens its page + await waitFor(() => { + expect(screen.getByLabelText(/^iou\.taxAmount/).props.value).toBe('11.10'); }); }); @@ -1246,7 +1321,6 @@ describe('MoneyRequestView edit fields', () => { await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent('violations.inactiveVendor.'); - expect(screen.getByTestId('menu-item-brick-road-indicator')).toBeOnTheScreen(); }); }); @@ -1360,7 +1434,6 @@ describe('MoneyRequestView edit fields', () => { transactionThreadReport={threadReport} parentReportID={selfDMReportID} expensePolicy={undefined} - shouldShowAnimatedBackground={false} /> , ); @@ -1368,7 +1441,8 @@ describe('MoneyRequestView edit fields', () => { await waitFor(() => { expect(screen.getByLabelText(fieldLabel('common.distance'))).toBeOnTheScreen(); - expect(screen.getByTestId(/^menu-item-title-iou\.amount/)).toHaveTextContent('USD-268'); + // A distance amount with a route is edited in place, so it shows in the amount input + expect(screen.getByDisplayValue('-2.68')).toBeOnTheScreen(); }); expect(screen.queryByLabelText(fieldLabel(commuterDistanceDescription))).not.toBeOnTheScreen(); }); diff --git a/tests/ui/ReportActionItemContentCreatedTest.tsx b/tests/ui/ReportActionItemContentCreatedTest.tsx new file mode 100644 index 000000000000..a6876d61bf84 --- /dev/null +++ b/tests/ui/ReportActionItemContentCreatedTest.tsx @@ -0,0 +1,115 @@ +import {render, screen} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {ShowContextMenuActionsContextType, ShowContextMenuStateContextType} from '@components/ShowContextMenuContext'; +import {ShowContextMenuActionsContext, ShowContextMenuStateContext} from '@components/ShowContextMenuContext'; + +import {buildOptimisticIOUReportAction} from '@libs/ReportUtils'; + +import ReportActionItemContentCreated from '@pages/inbox/report/ReportActionItemContentCreated'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +// Only what renders between the expense and its comments matters here, so the expense and the "New" line are stand-ins. +jest.mock('@components/ReportActionItem/MoneyRequestView', () => { + const RN = jest.requireActual>>('react-native'); + return () => ; +}); +jest.mock('@components/UnreadActionIndicator', () => { + const RN = jest.requireActual>>('react-native'); + return () => ; +}); + +const threadReport: OnyxTypes.Report = {...LHNTestUtils.getFakeReport(), parentReportID: 'expenseReport1'}; +const expenseAction: OnyxTypes.ReportAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 5000, + currency: CONST.CURRENCY.USD, + comment: '', + participants: [], + transactionID: 'transaction1', + iouReportID: 'expenseReport1', + getCurrencyDecimals: () => 2, +}); + +const contextMenuState: ShowContextMenuStateContextType = { + anchor: null, + report: threadReport, + action: undefined, + transactionThreadReport: undefined, + isDisabled: true, + shouldDisplayContextMenu: false, +}; +const contextMenuActions: ShowContextMenuActionsContextType = { + checkIfContextMenuActive: () => {}, + onShowContextMenu: () => {}, +}; + +const renderExpenseThreadTop = (shouldHideThreadDividerLine: boolean, hasActionsAfterCreated: boolean) => + render( + + + + + + + + + , + ); + +describe('ReportActionItemContentCreated below an expense', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + it('separates the expense from its read comments with a rule and a heading', async () => { + // Given an expense whose comments have all been read + // When the top of its thread renders + renderExpenseThreadTop(false, true); + await waitForBatchedUpdatesWithAct(); + + // Then a rule and the "Comments" heading sit between the expense and the comments, with no "New" line + expect(screen.getByTestId('expense-comments-rule')).toBeOnTheScreen(); + expect(screen.getByText('Comments')).toBeOnTheScreen(); + expect(screen.queryByTestId('unread-indicator')).not.toBeOnTheScreen(); + }); + + it('shows the "New" line in place of the rule when the first comment is unread', async () => { + // Given an expense whose first comment has not been read + // When the top of its thread renders + renderExpenseThreadTop(true, true); + await waitForBatchedUpdatesWithAct(); + + // Then the "New" line marks the unread comment under the heading and takes the rule's place, so the two don't stack + expect(screen.getByTestId('unread-indicator')).toBeOnTheScreen(); + expect(screen.getByText('Comments')).toBeOnTheScreen(); + expect(screen.queryByTestId('expense-comments-rule')).not.toBeOnTheScreen(); + }); + + it('shows no heading while the expense has nothing below it', async () => { + // Given an expense with no comments or system messages yet + // When the top of its thread renders + renderExpenseThreadTop(false, false); + await waitForBatchedUpdatesWithAct(); + + // Then there is nothing to head, so neither the heading nor the rule shows + expect(screen.queryByText('Comments')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('expense-comments-rule')).not.toBeOnTheScreen(); + }); +}); diff --git a/tests/ui/components/ReceiptAuditTest.tsx b/tests/ui/components/ReceiptAuditTest.tsx index 65abce7b733c..920f2c7daa73 100644 --- a/tests/ui/components/ReceiptAuditTest.tsx +++ b/tests/ui/components/ReceiptAuditTest.tsx @@ -1,8 +1,10 @@ -import {render, screen} from '@testing-library/react-native'; +import {render, renderHook, screen} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import ReceiptAudit from '@components/ReceiptAudit'; +import useTheme from '@hooks/useTheme'; + import CONST from '@src/CONST'; import React from 'react'; @@ -32,4 +34,23 @@ describe('ReceiptAudit', () => { expect(receiptLabel).toHaveStyle({userSelect: 'none'}); expect(receiptLabel).toHaveProp('dataSet', {[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}); }); + + it('heads the receipt like the other sections, with the audit result muted beside it', () => { + // Given a receipt the audit found an issue with + const {text: standardColor, textSupporting: mutedColor} = renderHook(() => useTheme()).result.current; + + // When the receipt audit is rendered + render( + + + , + ); + + // Then "Receipt" reads as a heading like "Details", and only the audit result stays muted + expect(screen.getByText(translateLocal('common.receipt'))).toHaveStyle({color: standardColor}); + expect(screen.getByText(new RegExp(translateLocal('iou.receiptIssuesFound', {count: 1})))).toHaveStyle({color: mutedColor}); + }); }); diff --git a/tests/unit/components/ExpenseFieldRow.test.tsx b/tests/unit/components/ExpenseFieldRow.test.tsx index 8d840fc3267c..c82d1936f92e 100644 --- a/tests/unit/components/ExpenseFieldRow.test.tsx +++ b/tests/unit/components/ExpenseFieldRow.test.tsx @@ -280,6 +280,28 @@ describe('ExpenseFieldRow', () => { expect(row).toHaveStyle({borderWidth: result.current.moneyRequestFieldRow.borderWidth}); expect(row).toHaveStyle(result.current.moneyRequestFieldRowDisabled); }); + + it('stacks the field name right on its value, as a text input does', () => { + // Given a field holding a value + render( + , + ); + + // When the form renders it + let contentCell = screen.getByText(FIELD_NAME).parent; + while (contentCell && String(contentCell.type) !== 'View') { + contentCell = contentCell.parent; + } + + // Then nothing separates the name from the value, so the row is as tall as a text input beside it + expect(contentCell).toHaveStyle({gap: 0}); + }); }); describe('layout reporting', () => { diff --git a/tests/unit/components/MoneyRequestConfirmationList/AttendeeFieldDropdown.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/AttendeeFieldDropdown.test.tsx index d6897204a3ab..185a17625632 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/AttendeeFieldDropdown.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/AttendeeFieldDropdown.test.tsx @@ -1,12 +1,15 @@ import {act, render} from '@testing-library/react-native'; import type {AttendeePickerModalProps} from '@components/AttendeePicker/AttendeePickerModal'; +import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; import AttendeeFieldDropdown from '@components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {setMoneyRequestAttendees} from '@libs/actions/IOU/MoneyRequest'; +import {updateMoneyRequestAttendees} from '@libs/actions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; +import type {IOUAction} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Attendee} from '@src/types/onyx/IOU'; @@ -20,6 +23,10 @@ jest.mock('@libs/actions/IOU/MoneyRequest', () => ({ ...jest.requireActual>('@libs/actions/IOU/MoneyRequest'), setMoneyRequestAttendees: jest.fn(), })); +jest.mock('@libs/actions/IOU/UpdateMoneyRequest', () => ({ + ...jest.requireActual>('@libs/actions/IOU/UpdateMoneyRequest'), + updateMoneyRequestAttendees: jest.fn(), +})); // The list is covered by the AttendeePickerModal tests; here only what the dropdown hands it, and what it does when the selection is applied, matter. type MockModalProps = Pick; @@ -36,20 +43,27 @@ const CURRENT_USER_LOGIN = 'creator@example.com'; const creator: Attendee = {email: CURRENT_USER_LOGIN, displayName: 'Creator', avatarUrl: ''}; const guest: Attendee = {email: 'guest@example.com', displayName: 'Guest', avatarUrl: ''}; -const buildDropdown = (onClose = jest.fn(), isVisible = true) => ( +const buildDropdown = (onClose = jest.fn(), isVisible = true, action: IOUAction = CONST.IOU.ACTION.CREATE) => ( - + > + + ); @@ -63,6 +77,7 @@ describe('AttendeeFieldDropdown', () => { beforeEach(async () => { mockModalProps = undefined; jest.mocked(setMoneyRequestAttendees).mockClear(); + jest.mocked(updateMoneyRequestAttendees).mockClear(); await Onyx.clear(); // The expense sits on a report the creator owns, which is where the list finds the creator to start from. await Onyx.multiSet({ @@ -183,4 +198,30 @@ describe('AttendeeFieldDropdown', () => { expect(onClose).not.toHaveBeenCalled(); expect(mockModalProps?.attendees).toEqual([]); }); + + it('saves the selection to a saved expense through the API when the expense is edited', async () => { + // Given a saved expense with the creator as its only attendee, opened from its expense view + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + comment: {attendees: [creator]}, + }); + await waitForBatchedUpdates(); + const onClose = jest.fn(); + + // When the dropdown opens for the saved expense, the user adds a guest and applies it + render(buildDropdown(onClose, true, CONST.IOU.ACTION.EDIT)); + await waitForBatchedUpdates(); + expect(mockModalProps?.attendees).toEqual([creator]); + act(() => mockModalProps?.onAttendeesChanged([creator, guest])); + act(() => mockModalProps?.onApply()); + + // Then both are saved to the expense the way the attendees page saves them, no draft is written, and the list closes + expect(updateMoneyRequestAttendees).toHaveBeenCalledTimes(1); + expect(updateMoneyRequestAttendees).toHaveBeenCalledWith( + expect.objectContaining({transactionID: TRANSACTION_ID, attendees: [creator, guest], transactionThreadReport: expect.objectContaining({reportID: REPORT_ID})}), + ); + expect(setMoneyRequestAttendees).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/hooks/useMirroredInputValueTest.tsx b/tests/unit/hooks/useMirroredInputValueTest.tsx new file mode 100644 index 000000000000..24db4cbf378b --- /dev/null +++ b/tests/unit/hooks/useMirroredInputValueTest.tsx @@ -0,0 +1,88 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useMirroredInputValue from '@hooks/useMirroredInputValue'; + +describe('useMirroredInputValue', () => { + it('follows the stored value while the input is not being edited', () => { + // Given an input showing a stored value that nobody is editing + const {result, rerender} = renderHook(({storedValue}: {storedValue: string}) => useMirroredInputValue(storedValue), {initialProps: {storedValue: 'Coffee'}}); + expect(result.current.inputValue).toBe('Coffee'); + + // When the stored value changes from somewhere else, such as SmartScan or another device + rerender({storedValue: 'Coffee Shop'}); + + // Then the input shows the new value + expect(result.current.inputValue).toBe('Coffee Shop'); + }); + + it('keeps what the user typed when the stored value changes mid-edit', () => { + // Given an input the user is typing in + const {result, rerender} = renderHook(({storedValue}: {storedValue: string}) => useMirroredInputValue(storedValue), {initialProps: {storedValue: 'Coffee'}}); + act(() => result.current.startEditing()); + act(() => result.current.setInputValue('Coffee and cake')); + + // When a write lands on the stored value before the user is done + rerender({storedValue: 'Coffee Shop'}); + + // Then the typed text stays, since replacing it would throw away the edit and move the caret + expect(result.current.isFocused).toBe(true); + expect(result.current.inputValue).toBe('Coffee and cake'); + }); + + it('reports what was typed when the edit ends, and nothing when the input was left as it was', () => { + // Given an input the user focuses twice, typing only the second time + const {result} = renderHook(({storedValue}: {storedValue: string}) => useMirroredInputValue(storedValue), {initialProps: {storedValue: 'Coffee'}}); + let untouchedResult: string | undefined = 'not called'; + act(() => result.current.startEditing()); + act(() => { + untouchedResult = result.current.finishEditing(); + }); + let typedResult: string | undefined; + act(() => result.current.startEditing()); + act(() => result.current.setInputValue('Tea')); + act(() => { + typedResult = result.current.finishEditing(); + }); + + // Then only the second edit hands back a value, so a caller never saves a field the user merely tabbed through + expect(untouchedResult).toBeUndefined(); + expect(typedResult).toBe('Tea'); + }); + + it('follows a stored value that changes while the untouched input is focused', () => { + // Given an input that is focused but not typed in + const {result, rerender} = renderHook(({storedValue}: {storedValue: string}) => useMirroredInputValue(storedValue), {initialProps: {storedValue: 'Coffee'}}); + act(() => result.current.startEditing()); + + // When the stored value changes meanwhile, and the input is then left + rerender({storedValue: 'Coffee Shop'}); + const showsWhileFocused = result.current.inputValue; + let editResult: string | undefined; + act(() => { + editResult = result.current.finishEditing(); + }); + + // Then the input shows the new value at once, since there is no edit to protect, and leaving it reports no edit + // so the old value is never written back over the new one + expect(showsWhileFocused).toBe('Coffee Shop'); + expect(editResult).toBeUndefined(); + expect(result.current.inputValue).toBe('Coffee Shop'); + }); + + it('follows the stored value again once the input is left', () => { + // Given an input that was edited and then left + const {result, rerender} = renderHook(({storedValue}: {storedValue: string}) => useMirroredInputValue(storedValue), {initialProps: {storedValue: 'Coffee'}}); + act(() => result.current.startEditing()); + act(() => result.current.setInputValue('Tea')); + act(() => { + result.current.finishEditing(); + }); + + // When the stored value changes afterwards + rerender({storedValue: 'Tea House'}); + + // Then the input picks it up + expect(result.current.isFocused).toBe(false); + expect(result.current.inputValue).toBe('Tea House'); + }); +}); diff --git a/tests/unit/hooks/useSavedAmountInputTest.tsx b/tests/unit/hooks/useSavedAmountInputTest.tsx new file mode 100644 index 000000000000..0b96a49a1636 --- /dev/null +++ b/tests/unit/hooks/useSavedAmountInputTest.tsx @@ -0,0 +1,21 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useSavedAmountInput from '@components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; + +import createMock from '../../utils/createMock'; + +describe('useSavedAmountInput', () => { + it('blurs the input on Enter, so the typed amount is saved the way leaving the input saves it', () => { + // Given an amount input the user is typing in, which submits rather than blurs on Enter + const blur = jest.fn(); + const {result} = renderHook(() => useSavedAmountInput({amount: 1000, decimals: 2, getAmountError: () => '', onSave: jest.fn()})); + result.current.inputRef.current = createMock({blur}); + + // When Enter submits the input + act(() => result.current.blurInput()); + + // Then the input is blurred, which runs the save on blur + expect(blur).toHaveBeenCalledTimes(1); + }); +});