From df224ebf736aa507aa0dbf6a17087252761eccc9 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 6 Nov 2026 18:10:41 +0700 Subject: [PATCH 01/14] saved-expense-text-inputs --- src/CONST/index.ts | 3 + .../ReportActionItem/MoneyRequestView.tsx | 127 +++++++----- .../DescriptionInputField.tsx | 88 ++++++++ .../MerchantInputField.tsx | 82 ++++++++ src/hooks/useMirroredInputValue.ts | 41 ++++ src/hooks/useUpdateTransactionDescription.ts | 91 ++++++++ src/hooks/useUpdateTransactionMerchant.ts | 90 ++++++++ src/libs/ValidationUtils.ts | 30 +++ src/libs/actions/TransactionInlineEdit.ts | 24 +-- .../step/DynamicIOURequestStepDescription.tsx | 52 +---- .../step/DynamicIOURequestStepMerchant.tsx | 54 +---- tests/ui/MoneyRequestViewInlineFieldsTest.tsx | 195 ++++++++++++++++++ tests/ui/MoneyRequestViewTest.tsx | 48 +++++ .../unit/hooks/useMirroredInputValueTest.tsx | 46 +++++ 14 files changed, 806 insertions(+), 165 deletions(-) create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx create mode 100644 src/hooks/useMirroredInputValue.ts create mode 100644 src/hooks/useUpdateTransactionDescription.ts create mode 100644 src/hooks/useUpdateTransactionMerchant.ts create mode 100644 tests/ui/MoneyRequestViewInlineFieldsTest.tsx create mode 100644 tests/unit/hooks/useMirroredInputValueTest.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3fd5385a775d..ae66c3cecdda 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1525,6 +1525,7 @@ const CONST = { NL_FILTERS: 'nlFilters', SUPPORT_TICKET: 'supportTicket', ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', + EXPENSE_VIEW_INLINE_FIELDS: 'expenseViewInlineFields', RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns', COLLECT_DEPOSIT_ACCOUNTS: 'collectDepositAccounts', RESIZABLE_RHP: 'resizableRHP', @@ -11161,6 +11162,8 @@ const CONST = { GOOGLE_MERCHANT_SEARCH_BUTTON: 'MoneyRequest-GoogleMerchantSearchButton', REIMBURSABLE_SWITCH: 'MoneyRequest-ReimbursableSwitch', BILLABLE_SWITCH: 'MoneyRequest-BillableSwitch', + MERCHANT_INPUT: 'MoneyRequest-MerchantInput', + DESCRIPTION_INPUT: 'MoneyRequest-DescriptionInput', }, SPLIT_EXPENSE: { ADD_SPLIT_BUTTON: 'SplitExpense-AddSplitButton', diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 6ca1248f7a18..f0ce5c38be80 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -171,6 +171,8 @@ import {View} from 'react-native'; import {useOnyx as useOnyxWithoutSnapshots} from 'react-native-onyx'; import MoneyRequestReceiptView from './MoneyRequestReceiptView'; +import DescriptionInputField from './MoneyRequestViewFields/DescriptionInputField'; +import MerchantInputField from './MoneyRequestViewFields/MerchantInputField'; type MoneyRequestViewProps = { transactionThreadReport?: OnyxEntry; @@ -468,6 +470,12 @@ function MoneyRequestView({ rules, }); + // Users on the beta edit the saved expense's text fields in place. A value shown for review, from the duplicates + // or merge flows, is not saved yet, so those keep the rows that open their page. + const canUseInlineFields = isBetaEnabled(CONST.BETAS.EXPENSE_VIEW_INLINE_FIELDS) && !isFromReviewDuplicates && !isFromMergeTransaction; + const shouldUseInlineDescriptionInput = canUseInlineFields && canEdit; + const shouldUseInlineMerchantInput = canUseInlineFields && canEditMerchant && !isTransactionScanning; + const canEditDate = isEditable && canEditFieldOfMoneyRequest({ @@ -1390,60 +1398,83 @@ function MoneyRequestView({ {!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} - errorText={getErrorForField('comment')} - errorTextStyle={styles.userSelectNone} - numberOfLinesTitle={0} - copyValue={descriptionCopyValue} - copyable={!canEdit && !!descriptionCopyValue} - isTitleSelectable={!!descriptionCopyValue} - /> + {shouldUseInlineDescriptionInput ? ( + + ) : ( + { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + transactionThreadReport?.reportID, + ), + ), + ); + }} + wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} + brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('comment')} + errorTextStyle={styles.userSelectNone} + numberOfLinesTitle={0} + copyValue={descriptionCopyValue} + copyable={!canEdit && !!descriptionCopyValue} + isTitleSelectable={!!descriptionCopyValue} + /> + )} )} {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} - errorText={getErrorForField('merchant')} - errorTextStyle={styles.userSelectNone} - numberOfLinesTitle={0} - copyValue={merchantCopyValue} - copyable={!canEditMerchant && !!merchantCopyValue} - isTitleSelectable={!!merchantCopyValue} - /> + {shouldUseInlineMerchantInput ? ( + + ) : ( + { + Navigation.navigate( + createDynamicRoute( + DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), + ), + ); + }} + wrapperStyle={[styles.taskDescriptionMenuItem]} + furtherDetailsComponent={shouldShowGoogleMerchantSearchLink ? renderGoogleMerchantSearchLink() : undefined} + brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={getErrorForField('merchant')} + errorTextStyle={styles.userSelectNone} + numberOfLinesTitle={0} + copyValue={merchantCopyValue} + copyable={!canEditMerchant && !!merchantCopyValue} + isTitleSelectable={!!merchantCopyValue} + /> + )} )} diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx new file mode 100644 index 000000000000..4285bf6afa4b --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx @@ -0,0 +1,88 @@ +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useMirroredInputValue from '@hooks/useMirroredInputValue'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionDescription from '@hooks/useUpdateTransactionDescription'; + +import Parser from '@libs/Parser'; +import StringUtils from '@libs/StringUtils'; +import {getDescription} from '@libs/TransactionUtils'; +import {getInlineDescriptionErrorMessage} from '@libs/ValidationUtils'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type DescriptionInputFieldProps = { + /** Expense whose description is edited */ + transaction: OnyxEntry; + + /** Transaction thread report of the expense */ + transactionThreadReport: OnyxEntry; + + /** Whether the expense is on a group workspace, where a description can mention a room */ + isReportInGroupPolicy: boolean; + + /** Violation message for the description, shown while the typed value has no error of its own */ + errorText?: string; +}; + +/** + * The description of a saved expense, edited in place as markdown. The value is saved when the input loses focus, + * through the same write the description page uses. + */ +function DescriptionInputField({transaction, transactionThreadReport, isReportInGroupPolicy, errorText = ''}: DescriptionInputFieldProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {updateDescription} = useUpdateTransactionDescription({transaction, transactionThreadReport}); + + // The saved description is HTML, so it is compared and edited as markdown. + const savedDescription = Parser.htmlToMarkdown(getDescription(transaction)); + const {inputValue, setInputValue, markFocused, markBlurred} = useMirroredInputValue(savedDescription); + const [inputError, setInputError] = useState(''); + + const saveDescription = () => { + markBlurred(); + // The description page saves the value after its form strips invisible characters and trims it. + const newDescription = StringUtils.removeInvisibleCharacters(inputValue).trim(); + const descriptionError = getInlineDescriptionErrorMessage(translate, newDescription); + if (descriptionError) { + setInputError(descriptionError); + return; + } + setInputError(''); + setInputValue(newDescription); + if (newDescription === savedDescription) { + return; + } + updateDescription(newDescription); + }; + + return ( + + + + ); +} + +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..ec0636f6d0d9 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx @@ -0,0 +1,82 @@ +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useMirroredInputValue from '@hooks/useMirroredInputValue'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionMerchant from '@hooks/useUpdateTransactionMerchant'; + +import {isMerchantRequired} from '@libs/MoneyRequestUtils'; +import {getTransactionDetails} from '@libs/ReportUtils'; +import {getInlineMerchantErrorMessage, isInvalidMerchantValue} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {ReactNode} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useState} from 'react'; +import {View} from 'react-native'; + +type MerchantInputFieldProps = { + /** Expense whose merchant is edited */ + transaction: OnyxEntry; + + /** Transaction thread report of the expense */ + transactionThreadReport: OnyxEntry; + + /** Violation message for the merchant, shown while the typed value has no error of its own */ + errorText?: string; + + /** Rendered below the input, e.g. the Google search link for a card merchant */ + footer?: ReactNode; +}; + +/** + * The merchant of a saved expense, edited in place. The value is saved when the input loses focus, through the same + * write the merchant page uses. + */ +function MerchantInputField({transaction, transactionThreadReport, errorText = '', footer}: MerchantInputFieldProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {updateMerchant} = useUpdateTransactionMerchant({transactionID: transaction?.transactionID, transactionThreadReport}); + + const merchant = getTransactionDetails(transaction)?.merchant; + const savedMerchant = isInvalidMerchantValue(merchant) ? '' : (merchant ?? ''); + const {inputValue, setInputValue, markFocused, markBlurred} = useMirroredInputValue(savedMerchant); + const [inputError, setInputError] = useState(''); + + const saveMerchant = () => { + markBlurred(); + const newMerchant = inputValue.trim(); + const merchantError = getInlineMerchantErrorMessage(translate, newMerchant, isMerchantRequired(transactionThreadReport, transaction)); + if (merchantError) { + setInputError(merchantError); + return; + } + setInputError(''); + setInputValue(newMerchant); + if (newMerchant === savedMerchant) { + return; + } + updateMerchant(newMerchant); + }; + + return ( + + + {!!footer && {footer}} + + ); +} + +export default MerchantInputField; diff --git a/src/hooks/useMirroredInputValue.ts b/src/hooks/useMirroredInputValue.ts new file mode 100644 index 000000000000..c0bdb1a92f36 --- /dev/null +++ b/src/hooks/useMirroredInputValue.ts @@ -0,0 +1,41 @@ +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; + + /** Marks the input as being edited, which holds off outside changes */ + markFocused: () => void; + + /** Marks the input as no longer being edited */ + markBlurred: () => void; +}; + +/** + * Mirrors a value stored in Onyx in local state for a controlled text input. Feeding the stored value straight to the + * input lets a write that lands mid-typing overwrite what the user typed and move the caret, so the input shows the + * mirror instead, which picks up outside changes to the stored value only while the input is not being edited. + */ +function useMirroredInputValue(storedValue: string): UseMirroredInputValueResult { + const [isFocused, setIsFocused] = useState(false); + const [inputValue, setInputValue] = useState(storedValue); + const [prevStoredValue, setPrevStoredValue] = useState(storedValue); + + // Synced during render rather than in an effect, to avoid a render pass that shows the stale value. + if (storedValue !== prevStoredValue) { + setPrevStoredValue(storedValue); + if (!isFocused) { + setInputValue(storedValue); + } + } + + return {inputValue, setInputValue, isFocused, markFocused: () => setIsFocused(true), markBlurred: () => setIsFocused(false)}; +} + +export default useMirroredInputValue; diff --git a/src/hooks/useUpdateTransactionDescription.ts b/src/hooks/useUpdateTransactionDescription.ts new file mode 100644 index 000000000000..5b3db9240d21 --- /dev/null +++ b/src/hooks/useUpdateTransactionDescription.ts @@ -0,0 +1,91 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import {updateMoneyRequestDescription} from '@userActions/IOU/UpdateMoneyRequest'; + +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'; + +type UseUpdateTransactionDescriptionParams = { + /** Saved expense being written to */ + transaction: OnyxEntry; + + /** Transaction thread report of that expense */ + transactionThreadReport: OnyxEntry; +}; + +type UseUpdateTransactionDescriptionResult = { + /** Saves `description`, written as markdown, to the expense */ + updateDescription: (description: string) => void; +}; + +/** + * Saves the description of a saved expense, from wherever it is edited. The write needs a wide set of policy and user + * data to recompute violations. 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. + */ +function useUpdateTransactionDescription({transaction, transactionThreadReport}: UseUpdateTransactionDescriptionParams): UseUpdateTransactionDescriptionResult { + const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); + const transactionID = transaction?.transactionID; + const policyID = transactionThreadReport?.policyID; + const policy = usePolicy(policyID); + const allTransactionViolations = useAllTransactionViolations(transactionID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(policyID)}`); + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${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); + + const updateDescription = (description: string) => { + if (!transactionID) { + return; + } + updateMoneyRequestDescription({ + isVendorMatchingBetaEnabled: isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING), + transactionID, + transaction, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + comment: description, + policy, + policyTagList: policyTags, + policyCategories, + policyVendors, + currentUserAccountIDParam: currentUserPersonalDetails.accountID, + currentUserEmailParam: currentUserPersonalDetails.login ?? '', + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + violations: allTransactionViolations, + getCurrencyDecimals, + getCurrencySymbol, + rules, + }); + }; + + return {updateDescription}; +} + +export default useUpdateTransactionDescription; diff --git a/src/hooks/useUpdateTransactionMerchant.ts b/src/hooks/useUpdateTransactionMerchant.ts new file mode 100644 index 000000000000..7d21ae586abe --- /dev/null +++ b/src/hooks/useUpdateTransactionMerchant.ts @@ -0,0 +1,90 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import {updateMoneyRequestMerchant} from '@userActions/IOU/UpdateMoneyRequest'; + +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 useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; +import {usePersonalDetail} from './usePersonalDetails'; +import usePolicy from './usePolicy'; + +type UseUpdateTransactionMerchantParams = { + /** ID of the saved expense being written to */ + transactionID: string | undefined; + + /** Transaction thread report of that expense */ + transactionThreadReport: OnyxEntry; +}; + +type UseUpdateTransactionMerchantResult = { + /** Saves `merchant` to the expense. An empty merchant is saved as the partial-merchant placeholder */ + updateMerchant: (merchant: string) => void; +}; + +/** + * Saves the merchant of a saved expense, from wherever it is edited. The write needs a wide set of policy and user + * data to recompute violations. 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. + */ +function useUpdateTransactionMerchant({transactionID, transactionThreadReport}: UseUpdateTransactionMerchantParams): UseUpdateTransactionMerchantResult { + const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const {isOffline} = useNetwork(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const delegateAccountID = useDelegateAccountID(); + const policyID = transactionThreadReport?.policyID; + const policy = usePolicy(policyID); + const allTransactionViolations = useAllTransactionViolations(transactionID); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); + const [policyTags] = 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); + + const updateMerchant = (merchant: string) => { + if (!transactionID) { + return; + } + updateMoneyRequestMerchant({ + isVendorMatchingBetaEnabled: isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING), + transactionID, + transactionThreadReport, + parentReport, + iouReportOwnerLogin, + value: merchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + policy, + policyTagList: policyTags, + policyCategories, + currentUserAccountIDParam: currentUserPersonalDetails.accountID, + currentUserEmailParam: currentUserPersonalDetails.login ?? '', + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + isOffline, + delegateAccountID, + reportPolicyTags, + isTrackIntentUser, + violations: allTransactionViolations, + getCurrencyDecimals, + getCurrencySymbol, + rules, + }); + }; + + return {updateMerchant}; +} + +export default useUpdateTransactionMerchant; 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 32921bc26256..72ca17f7f4b5 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/iou/request/step/DynamicIOURequestStepDescription.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx index 0976dd3c7674..82d05ab1ebfe 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx @@ -3,27 +3,22 @@ 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 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 +29,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 +39,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 +65,9 @@ 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 {updateDescription} = useUpdateTransactionDescription({transaction, transactionThreadReport: report}); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -108,13 +91,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 +132,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 02efa44875f2..ae97c28e1f54 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx @@ -3,26 +3,19 @@ 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 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 +23,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 +31,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 +52,8 @@ 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 {updateMerchant: updateSavedExpenseMerchant} = useUpdateTransactionMerchant({transactionID, transactionThreadReport: report}); const styles = useThemeStyles(); const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -87,14 +70,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 +90,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,28 +115,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, - 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/tests/ui/MoneyRequestViewInlineFieldsTest.tsx b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx new file mode 100644 index 000000000000..5b3c9ba662a6 --- /dev/null +++ b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx @@ -0,0 +1,195 @@ +import {fireEvent, render, 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 DescriptionInputField from '@components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField'; +import MerchantInputField from '@components/ReportActionItem/MoneyRequestViewFields/MerchantInputField'; + +import {updateMoneyRequestDescription, updateMoneyRequestMerchant} from '@userActions/IOU/UpdateMoneyRequest'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {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(), +})); +jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListActions: jest.fn(() => ({getCurrencyDecimals: jest.fn(() => 2), getCurrencySymbol: jest.fn(() => '$')}))})); + +const TRANSACTION_ID = 'transaction1'; +const EXPENSE_REPORT_ID = 'expenseReport1'; +const THREAD_REPORT_ID = 'thread1'; +const POLICY_ID = 'policy1'; + +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}; + +const renderField = (field: React.ReactElement) => + render({field}); + +describe('saved expense inline fields', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${EXPENSE_REPORT_ID}`, expenseReport); + await waitForBatchedUpdatesWithAct(); + }); + + 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('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 the input is focused and left without a real change + 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('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('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(); + }); + }); +}); diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 87ba6e099779..e0d0f3a122fc 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -274,6 +274,54 @@ describe('MoneyRequestView edit fields', () => { }); }); + it('shows the merchant and description as inline inputs for a user on the inline fields beta', async () => { + // Given an open expense the user can edit, for a user on the inline fields beta + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + await setupTestData(); + await act(async () => { + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.EXPENSE_VIEW_INLINE_FIELDS]); + }); + + // When the expense view renders + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then both text fields are inputs edited in place, in place of the rows that open their page + await waitFor(() => { + expect(screen.getByLabelText('common.merchant')).toBeOnTheScreen(); + }); + expect(screen.getByLabelText('common.description')).toBeOnTheScreen(); + expect(screen.queryByTestId('menu-item-common.merchant')).toBeNull(); + expect(screen.queryByTestId('menu-item-common.description')).toBeNull(); + }); + + it('keeps the merchant a read-only row when it cannot be edited, even on the beta', async () => { + // Given a settled expense, whose merchant can no longer be edited, for a user on the inline fields beta + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + await setupTestData(true); + await act(async () => { + await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.EXPENSE_VIEW_INLINE_FIELDS]); + }); + + // When the expense view renders + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then the merchant stays a read-only row, as an input would invite an edit that cannot be saved + await waitFor(() => { + expect(screen.getByTestId('menu-item-common.merchant')).toHaveTextContent('readonly'); + }); + expect(screen.queryByLabelText('common.merchant')).toBeNull(); + }); + it('shows the Category and Tag rows from the report policy when nothing is selected yet', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), diff --git a/tests/unit/hooks/useMirroredInputValueTest.tsx b/tests/unit/hooks/useMirroredInputValueTest.tsx new file mode 100644 index 000000000000..28d99c2c7a49 --- /dev/null +++ b/tests/unit/hooks/useMirroredInputValueTest.tsx @@ -0,0 +1,46 @@ +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.markFocused()); + 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('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.markFocused()); + act(() => result.current.setInputValue('Tea')); + act(() => result.current.markBlurred()); + + // 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'); + }); +}); From d276574dae7c7a1688c729e060130dde8e560820 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Mon, 9 Nov 2026 15:46:59 +0700 Subject: [PATCH 02/14] saved-expense-text-inputs --- src/CONST/index.ts | 12 +- .../sections/AmountField.tsx | 19 +- .../sections/ExpenseFieldRow.tsx | 10 +- .../ReportActionItem/MoneyRequestView.tsx | 1471 ++++++++--------- .../AmountInputField.tsx | 209 +++ .../MoneyRequestViewFields/DateInputField.tsx | 76 + .../DescriptionInputField.tsx | 31 +- .../MerchantInputField.tsx | 30 +- .../MoneyRequestViewFields/TagFieldRow.tsx | 77 + src/hooks/useMirroredInputValue.ts | 39 +- src/hooks/useMoneyRequestUpdateParams.ts | 62 + src/hooks/useUpdateTransactionAmount.ts | 73 + src/hooks/useUpdateTransactionDate.ts | 72 + src/hooks/useUpdateTransactionDescription.ts | 63 +- src/hooks/useUpdateTransactionMerchant.ts | 63 +- src/libs/IOUAmountSubmission.ts | 168 +- src/libs/MoneyRequestUtils.ts | 18 + src/pages/inbox/report/ReportActionItem.tsx | 6 + .../report/ReportActionItemContentCreated.tsx | 32 +- .../report/ReportActionsListItemRenderer.tsx | 1 + .../step/DynamicIOURequestStepDate.tsx | 62 +- tests/ui/MoneyRequestViewInlineFieldsTest.tsx | 181 +- tests/ui/MoneyRequestViewReceiptTest.tsx | 9 +- tests/ui/MoneyRequestViewTest.tsx | 200 +-- .../unit/hooks/useMirroredInputValueTest.tsx | 44 +- 25 files changed, 1804 insertions(+), 1224 deletions(-) create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/DateInputField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/TagFieldRow.tsx create mode 100644 src/hooks/useMoneyRequestUpdateParams.ts create mode 100644 src/hooks/useUpdateTransactionAmount.ts create mode 100644 src/hooks/useUpdateTransactionDate.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ae66c3cecdda..334b5e58e3b3 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1525,7 +1525,6 @@ const CONST = { NL_FILTERS: 'nlFilters', SUPPORT_TICKET: 'supportTicket', ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', - EXPENSE_VIEW_INLINE_FIELDS: 'expenseViewInlineFields', RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns', COLLECT_DEPOSIT_ACCOUNTS: 'collectDepositAccounts', RESIZABLE_RHP: 'resizableRHP', @@ -11164,6 +11163,17 @@ const CONST = { BILLABLE_SWITCH: 'MoneyRequest-BillableSwitch', MERCHANT_INPUT: 'MoneyRequest-MerchantInput', DESCRIPTION_INPUT: 'MoneyRequest-DescriptionInput', + DATE_FIELD_CLEAR_BUTTON: 'MoneyRequest-DateFieldClearButton', + AMOUNT_FIELD: 'MoneyRequest-ViewField-Amount', + DISTANCE_FIELD: 'MoneyRequest-ViewField-Distance', + RATE_FIELD: 'MoneyRequest-ViewField-Rate', + CATEGORY_FIELD: 'MoneyRequest-ViewField-Category', + VENDOR_FIELD: 'MoneyRequest-ViewField-Vendor', + TAG_FIELD: 'MoneyRequest-ViewField-Tag', + TAX_RATE_FIELD: 'MoneyRequest-ViewField-TaxRate', + TAX_AMOUNT_FIELD: 'MoneyRequest-ViewField-TaxAmount', + ATTENDEES_FIELD: 'MoneyRequest-ViewField-Attendees', + REPORT_FIELD: 'MoneyRequest-ViewField-Report', }, SPLIT_EXPENSE: { ADD_SPLIT_BUTTON: 'SplitExpense-AddSplitButton', diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index eb26aa6f78a8..cbcc84e36304 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'; @@ -188,23 +188,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/ExpenseFieldRow.tsx b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx index ef8561e4cc95..5677776feb37 100644 --- a/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx @@ -76,6 +76,9 @@ type ExpenseFieldRowProps = { /** 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 @@ -125,6 +128,7 @@ function ExpenseFieldRow({ isExpanded, isDisabled = false, isInteractive = true, + copyValue, backgroundStyle, sentryLabel, testID, @@ -137,7 +141,8 @@ 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 = ( @@ -149,6 +154,7 @@ function ExpenseFieldRow({ {value} @@ -174,6 +180,7 @@ function ExpenseFieldRow({ )} {isInteractive && } + {shouldShowCopy && } )} @@ -202,6 +209,7 @@ function ExpenseFieldRow({ // the sign-in prompt rather than the field's selector. onPress={isInteractive ? callFunctionIfActionIsAllowed(onPress, false, session) : undefined} isDisabled={isDisabled} + shouldAllowTextSelection={shouldShowCopy} accessibilityLabel={accessibilityLabel} accessibilityState={isExpanded === undefined ? undefined : {expanded: isExpanded}} sentryLabel={sentryLabel} diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index f0ce5c38be80..201f45aa9602 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1,16 +1,20 @@ 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 CategoryFieldDropdown from '@components/MoneyRequestConfirmationList/sections/CategoryFieldDropdown'; +import ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import ExpenseFieldRow from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldRow'; +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'; @@ -54,7 +58,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 +68,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 +99,7 @@ import { isActionCreator, isExpenseReport, isInvoiceReport, + isIOUReport, isOpenReport, isReportApproved, isSettled as isSettledReportUtils, @@ -109,6 +116,7 @@ import { getDetailedExpenseTypeTranslationKey, getDisplayTransactionWithoutInvalidCommuterExclusion, getDistanceInMeters, + getEnabledTaxRateCount, getFormattedCreated, getOriginalAmountForDisplay, getOriginalTransactionWithSplitInfo, @@ -116,6 +124,7 @@ import { getReservationNights, getTagForDisplay, getTaxName, + getTaxRateTitle, hasMissingSmartscanFields, hasReservationList, hasRoute as hasRouteTransactionUtils, @@ -145,8 +154,6 @@ import AnimatedEmptyStateBackground from '@pages/inbox/report/AnimatedEmptyState import variables from '@styles/variables'; -import {callFunctionIfActionIsAllowed} from '@userActions/Session'; - import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -157,12 +164,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'; @@ -171,8 +176,11 @@ 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'; type MoneyRequestViewProps = { transactionThreadReport?: OnyxEntry; @@ -218,7 +226,6 @@ function MoneyRequestView({ isFromReviewDuplicates = false, mergeTransactionID, }: MoneyRequestViewProps) { - const [session] = useOnyx(ONYXKEYS.SESSION, {selector: authTokenTypeSelector}); const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase', 'NewWindow']); const styles = useThemeStyles(); const theme = useTheme(); @@ -313,7 +320,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) { @@ -470,11 +477,8 @@ function MoneyRequestView({ rules, }); - // Users on the beta edit the saved expense's text fields in place. A value shown for review, from the duplicates - // or merge flows, is not saved yet, so those keep the rows that open their page. - const canUseInlineFields = isBetaEnabled(CONST.BETAS.EXPENSE_VIEW_INLINE_FIELDS) && !isFromReviewDuplicates && !isFromMergeTransaction; - const shouldUseInlineDescriptionInput = canUseInlineFields && canEdit; - const shouldUseInlineMerchantInput = canUseInlineFields && canEditMerchant && !isTransactionScanning; + // The values shown for review in the duplicates and merge flows are not saved yet, so those flows lock every field. + const isReviewingUnsavedValues = isFromReviewDuplicates || isFromMergeTransaction; const canEditDate = isEditable && @@ -1054,162 +1058,235 @@ 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 { + Navigation.navigate(categoryRoute); + } + }; + + 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, - ), - ), - ); - }, - false, - 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, - ), - ), - ); - }, - false, - session, - ) - : undefined - } - > - - {!!getErrorForField('customUnitRateID') && } - {canEditDistanceRate && } - {!canEditDistanceRate && !!distanceRateCopyValue && } - - {!!getErrorForField('customUnitRateID') && ( - - )} - + ); - const hasDependentTags = !shouldShowFullTransactionTag && hasDependentTagsPolicyUtils(policy, policyTagList); const shouldShowTagList = hasDependentTags ? getDependentTagVisibility(policyTagLists, transactionTag) : []; @@ -1233,7 +1310,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; } @@ -1246,44 +1323,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)} /> ); @@ -1305,646 +1364,434 @@ 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); + const shouldShowReceipt = !isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction; + + // A per diem amount comes from its rates, a distance amount waits for its route, a scanning receipt's amount comes + // from SmartScan and a split amount is edited across its splits, so those keep the row that opens their own page. + const canEditAmountInline = + canEditAmount && + !isReviewingUnsavedValues && + shouldDisplayTransactionAmount && + !isPerDiemRequest && + !isTransactionScanning && + !hasFailedScanAmountPlaceholder && + !(shouldShowSplitIndicator && isSplitAvailable); + const canAddCategory = canCreateCategoryInSitu(policy, currentUserEmailParam); + + const hasAttendees = Array.isArray(actualAttendees) && actualAttendees.length > 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'))} + + + + + )} + {shouldShowBillable && ( + + + + {translate('common.billable')} + + {!!getErrorForField('billable') && ( + + + + )} + + + + )} + + ); + // If the view is readonly, we don't need the transactionThread dependency if ((!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID) { return ; } - return ( - - {shouldShowAnimatedBackground && } - <> - {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( - + + {canEditAmountInline ? ( + - )} - {isCustomUnitOutOfPolicy && isPerDiemRequest && ( - - - - {translate('violations.customUnitOutOfPolicy')} - - - )} - - { - 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} + onPress={openAmountPage} copyValue={amountCopyValue} - copyable={!canEditAmount && !!amountCopyValue} - isTitleSelectable={!!amountCopyValue} + isInteractive={canEditAmount} + sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.AMOUNT_FIELD} /> - - {!shouldHideEmptyDescription && ( - - {shouldUseInlineDescriptionInput ? ( - - ) : ( - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ); - }} - wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} - brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('comment')} - errorTextStyle={styles.userSelectNone} - numberOfLinesTitle={0} - copyValue={descriptionCopyValue} - copyable={!canEdit && !!descriptionCopyValue} - isTitleSelectable={!!descriptionCopyValue} - /> - )} - )} - {isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( - distanceRequestFields - ) : ( - - {shouldUseInlineMerchantInput ? ( - + {!shouldHideEmptyDescription && ( + + {canEdit ? ( + + ) : ( + {descriptionHTML} : undefined} + hasValueComponent={!!descriptionHTML} + accessibilityLabel={[translate('common.description'), descriptionCopyValue].filter(Boolean).join(', ')} + copyValue={descriptionCopyValue} + errorText={getErrorForField('comment')} + isInteractive={false} + /> + )} + + )} + {isManualDistanceRequest || isGPSDistanceRequest || isOdometerDistanceRequest || (isMapDistanceRequest && transaction?.comment?.waypoints) ? ( + distanceRequestFields + ) : ( + + {canEditMerchant && !isTransactionScanning ? ( + + {shouldShowGoogleMerchantSearchLink && renderGoogleMerchantSearchLink()} + + ) : ( + <> + - ) : ( - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, transactionThreadReport?.reportID), - ), - ); - }} - wrapperStyle={[styles.taskDescriptionMenuItem]} - furtherDetailsComponent={shouldShowGoogleMerchantSearchLink ? renderGoogleMerchantSearchLink() : undefined} - brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - errorText={getErrorForField('merchant')} - errorTextStyle={styles.userSelectNone} - numberOfLinesTitle={0} - copyValue={merchantCopyValue} - copyable={!canEditMerchant && !!merchantCopyValue} - isTitleSelectable={!!merchantCopyValue} + {shouldShowGoogleMerchantSearchLink && {renderGoogleMerchantSearchLink()}} + + )} + + )} + + {canEditDate ? ( + + ) : ( + + )} + + {!!shouldShowCategory && ( + + ( + )} - - )} - - { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction.transactionID, - transactionThreadReport?.reportID, - ), - ), - ); - }, - false, - session, - ) - : undefined - } - > - - {!!dateError && } - {canEditDate && } - {!canEditDate && !!dateCopyValue && } - - {!!dateError && ( - + + )} + {shouldShowVendor && ( + + + + )} + {shouldShowTag && tagList} + {!!shouldShowCard && ( + + + + )} + {shouldShowTax && ( + + 0} + listHeaderHeight={SELECTION_LIST_POPOVER_HEADER_HEIGHT} + renderDropdown={(dropdownProps) => ( + )} - + sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.TAX_RATE_FIELD} + /> - {!!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 { - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({ - action: CONST.IOU.ACTION.EDIT, - iouType, - transactionID: transaction.transactionID, - reportID: transactionThreadReport?.reportID, - }), - ), - ); - } - }, - false, - 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(), - ), - ); - }, - false, - 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, - ), - ), - ); - }, - false, - 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, - ), - ), - ); - }, - false, - 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 && ( - + + + )} + {shouldShowAttendees && ( + + + + )} + {toggleFields} + {shouldShowReport && ( + + + + )} + + ); + + return ( + + {shouldShowAnimatedBackground && } + {shouldShowReceipt && ( + + )} + {isCustomUnitOutOfPolicy && isPerDiemRequest && ( + + + - - - {translate('common.billable')} - - {!!getErrorForField('billable') && ( - - - - )} - - - - )} - {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} - /> - - )} - {shouldShowTripRoomLink && ( - <> - { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(tripRoomReportID, undefined, undefined, Navigation.getActiveRoute())); - }} - /> - - - )} - {/* Note: "View trip details" should be always the last item */} - {shouldShowViewTripDetails && ( - + + )} + {!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..bfad364f53ea --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx @@ -0,0 +1,209 @@ +/** + * 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 FormHelpMessage from '@components/FormHelpMessage'; +import NumberWithSymbolForm from '@components/NumberWithSymbolForm'; + +import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useLocalize from '@hooks/useLocalize'; +import useMirroredInputValue from '@hooks/useMirroredInputValue'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useUpdateTransactionAmount from '@hooks/useUpdateTransactionAmount'; + +import {convertToFrontendAmountAsString, 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'; + +type AmountInputFieldProps = { + /** Expense whose amount is edited */ + transaction: OnyxEntry; + + transactionThreadReport: OnyxEntry; + + /** 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, + transactionThreadReport, + 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, transactionThreadReport, policy, policyCategories}); + const currencyButtonRef = useRef(null); + + const decimals = getCurrencyDecimals(currency); + const savedValue = `${amount < 0 ? '-' : ''}${convertToFrontendAmountAsString(Math.abs(amount), decimals)}`; + const {inputValue, setInputValue, isFocused, startEditing, finishEditing} = useMirroredInputValue(savedValue); + const [inputError, setInputError] = useState(''); + const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); + + // The input keeps what is typed in its own state, so it is remounted to show an amount the user didn't type. + const [shownSavedValue, setShownSavedValue] = useState(savedValue); + const [resetCount, setResetCount] = useState(0); + if (!isFocused && shownSavedValue !== savedValue) { + setShownSavedValue(savedValue); + setInputError(''); + } + + const isValidAmount = (newAmount: number) => isValidMoneyRequestAmount(newAmount, iouType, allowNegative, isP2P); + + const showSavedAmount = () => { + setInputValue(savedValue); + setResetCount((count) => count + 1); + }; + + const saveAmount = (typedAmount: string) => { + const newAmount = getBackendAmountFromInput(typedAmount); + // An expense always has an amount, so a cleared input goes back to the saved one. + if (newAmount === null) { + setInputError(''); + showSavedAmount(); + return; + } + if (!isValidAmount(newAmount)) { + setInputError(translate('iou.error.invalidAmount')); + return; + } + setInputError(''); + if (newAmount === amount) { + showSavedAmount(); + return; + } + updateAmount(newAmount, currency); + }; + + 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 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 && isValidAmount(typedAmount) ? typedAmount : amount, newCurrency); + }; + + return ( + <> + setIsCurrencyPickerVisible(false)} + headerText={translate('common.selectCurrency')} + value={currency} + onInputChange={updateCurrency} + launcherRef={currencyButtonRef} + /> + + setIsCurrencyPickerVisible(true)} + currencyButtonRef={currencyButtonRef} + shouldUseBorderlessButtons + /> + {!!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..55aa9bfc18d7 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/DateInputField.tsx @@ -0,0 +1,76 @@ +/** + * 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 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; + + transactionThreadReport: OnyxEntry; + + /** 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, transactionThreadReport, isTrackExpense, label, errorText = ''}: DateInputFieldProps) { + const styles = useThemeStyles(); + const {updateDate} = useUpdateTransactionDate({transaction, transactionThreadReport, 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 index 4285bf6afa4b..2cecf72ab004 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx @@ -1,3 +1,7 @@ +/** + * 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'; @@ -24,34 +28,38 @@ type DescriptionInputFieldProps = { /** Expense whose description is edited */ transaction: OnyxEntry; - /** Transaction thread report of the expense */ transactionThreadReport: OnyxEntry; /** 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; }; -/** - * The description of a saved expense, edited in place as markdown. The value is saved when the input loses focus, - * through the same write the description page uses. - */ -function DescriptionInputField({transaction, transactionThreadReport, isReportInGroupPolicy, errorText = ''}: DescriptionInputFieldProps) { +function DescriptionInputField({transaction, transactionThreadReport, isReportInGroupPolicy, hint, errorText = ''}: DescriptionInputFieldProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {updateDescription} = useUpdateTransactionDescription({transaction, transactionThreadReport}); // The saved description is HTML, so it is compared and edited as markdown. const savedDescription = Parser.htmlToMarkdown(getDescription(transaction)); - const {inputValue, setInputValue, markFocused, markBlurred} = useMirroredInputValue(savedDescription); + const {inputValue, setInputValue, startEditing, finishEditing} = useMirroredInputValue(savedDescription); const [inputError, setInputError] = useState(''); const saveDescription = () => { - markBlurred(); + 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(inputValue).trim(); + const newDescription = StringUtils.removeInvisibleCharacters(typedDescription).trim(); const descriptionError = getInlineDescriptionErrorMessage(translate, newDescription); if (descriptionError) { setInputError(descriptionError); @@ -66,11 +74,11 @@ function DescriptionInputField({transaction, transactionThreadReport, isReportIn }; return ( - + diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx index ec0636f6d0d9..dc778dd4569a 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx @@ -1,3 +1,7 @@ +/** + * 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'; @@ -22,33 +26,33 @@ type MerchantInputFieldProps = { /** Expense whose merchant is edited */ transaction: OnyxEntry; - /** Transaction thread report of the expense */ transactionThreadReport: OnyxEntry; /** Violation message for the merchant, shown while the typed value has no error of its own */ errorText?: string; /** Rendered below the input, e.g. the Google search link for a card merchant */ - footer?: ReactNode; + children?: ReactNode; }; -/** - * The merchant of a saved expense, edited in place. The value is saved when the input loses focus, through the same - * write the merchant page uses. - */ -function MerchantInputField({transaction, transactionThreadReport, errorText = '', footer}: MerchantInputFieldProps) { +function MerchantInputField({transaction, transactionThreadReport, errorText = '', children}: MerchantInputFieldProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {updateMerchant} = useUpdateTransactionMerchant({transactionID: transaction?.transactionID, transactionThreadReport}); const merchant = getTransactionDetails(transaction)?.merchant; const savedMerchant = isInvalidMerchantValue(merchant) ? '' : (merchant ?? ''); - const {inputValue, setInputValue, markFocused, markBlurred} = useMirroredInputValue(savedMerchant); + const {inputValue, setInputValue, startEditing, finishEditing} = useMirroredInputValue(savedMerchant); const [inputError, setInputError] = useState(''); const saveMerchant = () => { - markBlurred(); - const newMerchant = inputValue.trim(); + const typedMerchant = finishEditing(); + // Leaving the input untouched is not an edit, so it neither validates nor saves. + if (typedMerchant === undefined) { + setInputError(''); + return; + } + const newMerchant = typedMerchant.trim(); const merchantError = getInlineMerchantErrorMessage(translate, newMerchant, isMerchantRequired(transactionThreadReport, transaction)); if (merchantError) { setInputError(merchantError); @@ -63,18 +67,18 @@ function MerchantInputField({transaction, transactionThreadReport, errorText = ' }; return ( - + - {!!footer && {footer}} + {!!children && {children}} ); } diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/TagFieldRow.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/TagFieldRow.tsx new file mode 100644 index 000000000000..ddbff8ec7680 --- /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.TAG_FIELD} + /> + ); +} + +export default TagFieldRow; diff --git a/src/hooks/useMirroredInputValue.ts b/src/hooks/useMirroredInputValue.ts index c0bdb1a92f36..00e7f5bac2c8 100644 --- a/src/hooks/useMirroredInputValue.ts +++ b/src/hooks/useMirroredInputValue.ts @@ -1,3 +1,8 @@ +/** + * Mirrors a value stored in Onyx in local state for a controlled text input. Feeding the stored value straight to the + * input lets a write that lands mid-typing overwrite what the user typed and move the caret, so the input shows the + * mirror instead, which picks up outside changes to the stored value only while the input is not being edited. + */ import {useState} from 'react'; type UseMirroredInputValueResult = { @@ -10,22 +15,21 @@ type UseMirroredInputValueResult = { /** Whether the input is being edited */ isFocused: boolean; - /** Marks the input as being edited, which holds off outside changes */ - markFocused: () => void; + /** Starts an edit, which holds off outside changes to the stored value */ + startEditing: () => void; - /** Marks the input as no longer being edited */ - markBlurred: () => 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; }; -/** - * Mirrors a value stored in Onyx in local state for a controlled text input. Feeding the stored value straight to the - * input lets a write that lands mid-typing overwrite what the user typed and move the caret, so the input shows the - * mirror instead, which picks up outside changes to the stored value only while the input is not being edited. - */ function useMirroredInputValue(storedValue: string): UseMirroredInputValueResult { const [isFocused, setIsFocused] = useState(false); const [inputValue, setInputValue] = useState(storedValue); const [prevStoredValue, setPrevStoredValue] = useState(storedValue); + const [valueAtFocus, setValueAtFocus] = useState(); // Synced during render rather than in an effect, to avoid a render pass that shows the stale value. if (storedValue !== prevStoredValue) { @@ -35,7 +39,22 @@ function useMirroredInputValue(storedValue: string): UseMirroredInputValueResult } } - return {inputValue, setInputValue, isFocused, markFocused: () => setIsFocused(true), markBlurred: () => setIsFocused(false)}; + const startEditing = () => { + setIsFocused(true); + setValueAtFocus(inputValue); + }; + + const finishEditing = () => { + setIsFocused(false); + if (inputValue !== valueAtFocus) { + return inputValue; + } + // The stored value may have changed while the untouched input was focused, and nothing else brings it 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..ba3d3104640e --- /dev/null +++ b/src/hooks/useMoneyRequestUpdateParams.ts @@ -0,0 +1,62 @@ +/** + * 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, + }; +} + +export default useMoneyRequestUpdateParams; diff --git a/src/hooks/useUpdateTransactionAmount.ts b/src/hooks/useUpdateTransactionAmount.ts new file mode 100644 index 000000000000..bfbf97fdbcf5 --- /dev/null +++ b/src/hooks/useUpdateTransactionAmount.ts @@ -0,0 +1,73 @@ +/** + * 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 useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; +import useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; +import useOnyx from './useOnyx'; + +type UseUpdateTransactionAmountParams = { + /** Saved expense being written to */ + transaction: OnyxEntry; + + transactionThreadReport: OnyxEntry; + + /** 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, transactionThreadReport, policy, policyCategories}: UseUpdateTransactionAmountParams): UseUpdateTransactionAmountResult { + const transactionID = transaction?.transactionID; + const updateParams = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []); + const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); + + const updateAmount = (amount: number, currency: string) => { + if (!transactionID) { + return; + } + updateSavedExpenseAmount({ + policy, + transactionID, + transaction, + transactionThreadReport, + parentReport: updateParams.parentReport, + iouReportOwnerLogin: updateParams.iouReportOwnerLogin, + amount, + currency, + duplicateTransactions, + 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/useUpdateTransactionDate.ts b/src/hooks/useUpdateTransactionDate.ts new file mode 100644 index 000000000000..99ce46e63f9b --- /dev/null +++ b/src/hooks/useUpdateTransactionDate.ts @@ -0,0 +1,72 @@ +/** + * 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 useDistanceRateOriginalPolicy from './useDistanceRateOriginalPolicy'; +import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; +import useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; +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; + + transactionThreadReport: OnyxEntry; + + /** 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, transactionThreadReport, isTrackExpense}: UseUpdateTransactionDateParams): UseUpdateTransactionDateResult { + const transactionID = transaction?.transactionID; + const {policy, policyTagList, violations, ...updateParams} = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); + 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 {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []); + + const updateDate = (created: string) => { + if (!transactionID) { + return; + } + updateMoneyRequestDate({ + ...updateParams, + transactionID, + transactions: duplicateTransactions, + transactionViolations: 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 index 5b3db9240d21..02ce32bd7d2f 100644 --- a/src/hooks/useUpdateTransactionDescription.ts +++ b/src/hooks/useUpdateTransactionDescription.ts @@ -1,30 +1,23 @@ +/** + * 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 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 useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; import useOnyx from './useOnyx'; -import usePermissions from './usePermissions'; -import {usePersonalDetail} from './usePersonalDetails'; -import usePolicy from './usePolicy'; type UseUpdateTransactionDescriptionParams = { /** Saved expense being written to */ transaction: OnyxEntry; - /** Transaction thread report of that expense */ transactionThreadReport: OnyxEntry; }; @@ -33,56 +26,16 @@ type UseUpdateTransactionDescriptionResult = { updateDescription: (description: string) => void; }; -/** - * Saves the description of a saved expense, from wherever it is edited. The write needs a wide set of policy and user - * data to recompute violations. 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. - */ function useUpdateTransactionDescription({transaction, transactionThreadReport}: UseUpdateTransactionDescriptionParams): UseUpdateTransactionDescriptionResult { - const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const delegateAccountID = useDelegateAccountID(); const transactionID = transaction?.transactionID; - const policyID = transactionThreadReport?.policyID; - const policy = usePolicy(policyID); - const allTransactionViolations = useAllTransactionViolations(transactionID); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(policyID)}`); - const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${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); + const updateParams = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${getNonEmptyStringOnyxID(transactionThreadReport?.policyID)}`); const updateDescription = (description: string) => { if (!transactionID) { return; } - updateMoneyRequestDescription({ - isVendorMatchingBetaEnabled: isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING), - transactionID, - transaction, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - comment: description, - policy, - policyTagList: policyTags, - policyCategories, - policyVendors, - currentUserAccountIDParam: currentUserPersonalDetails.accountID, - currentUserEmailParam: currentUserPersonalDetails.login ?? '', - isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - violations: allTransactionViolations, - getCurrencyDecimals, - getCurrencySymbol, - rules, - }); + updateMoneyRequestDescription({...updateParams, transactionID, transaction, comment: description, policyVendors}); }; return {updateDescription}; diff --git a/src/hooks/useUpdateTransactionMerchant.ts b/src/hooks/useUpdateTransactionMerchant.ts index 7d21ae586abe..8201f3ff5652 100644 --- a/src/hooks/useUpdateTransactionMerchant.ts +++ b/src/hooks/useUpdateTransactionMerchant.ts @@ -1,31 +1,21 @@ -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; - +/** + * 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 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 useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; import useNetwork from './useNetwork'; -import useOnyx from './useOnyx'; -import usePermissions from './usePermissions'; -import {usePersonalDetail} from './usePersonalDetails'; -import usePolicy from './usePolicy'; type UseUpdateTransactionMerchantParams = { /** ID of the saved expense being written to */ transactionID: string | undefined; - /** Transaction thread report of that expense */ transactionThreadReport: OnyxEntry; }; @@ -34,54 +24,15 @@ type UseUpdateTransactionMerchantResult = { updateMerchant: (merchant: string) => void; }; -/** - * Saves the merchant of a saved expense, from wherever it is edited. The write needs a wide set of policy and user - * data to recompute violations. 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. - */ function useUpdateTransactionMerchant({transactionID, transactionThreadReport}: UseUpdateTransactionMerchantParams): UseUpdateTransactionMerchantResult { - const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const updateParams = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); const {isOffline} = useNetwork(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const delegateAccountID = useDelegateAccountID(); - const policyID = transactionThreadReport?.policyID; - const policy = usePolicy(policyID); - const allTransactionViolations = useAllTransactionViolations(transactionID); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policyID)}`); - const [policyTags] = 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); const updateMerchant = (merchant: string) => { if (!transactionID) { return; } - updateMoneyRequestMerchant({ - isVendorMatchingBetaEnabled: isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING), - transactionID, - transactionThreadReport, - parentReport, - iouReportOwnerLogin, - value: merchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - policy, - policyTagList: policyTags, - policyCategories, - currentUserAccountIDParam: currentUserPersonalDetails.accountID, - currentUserEmailParam: currentUserPersonalDetails.login ?? '', - isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), - isOffline, - delegateAccountID, - reportPolicyTags, - isTrackIntentUser, - violations: allTransactionViolations, - getCurrencyDecimals, - getCurrencySymbol, - rules, - }); + updateMoneyRequestMerchant({...updateParams, transactionID, value: merchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, isOffline}); }; return {updateMerchant}; diff --git a/src/libs/IOUAmountSubmission.ts b/src/libs/IOUAmountSubmission.ts index 48304087758b..4f3044585cfc 100644 --- a/src/libs/IOUAmountSubmission.ts +++ b/src/libs/IOUAmountSubmission.ts @@ -632,6 +632,115 @@ 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, + 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, @@ -641,19 +750,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; @@ -665,55 +766,38 @@ 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, {amount: newAmount, currency: selectedCurrency, taxCode, taxAmount}, getCurrencyDecimals, getCurrencySymbol); navigateBack(); 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, 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(); } @@ -733,5 +817,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/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index b616deab081e..31da4768e6d5 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -143,6 +143,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; @@ -191,6 +194,7 @@ function ReportActionItem({ parentReportAction, shouldDisplayNewMarker, shouldHideThreadDividerLine = false, + hasActionsAfterCreated = true, onPress = undefined, isFirstVisibleReportAction = false, isThreadReportParentAction = false, @@ -523,6 +527,7 @@ function ReportActionItem({ transactionID={createdTransactionID} draftMessage={draftMessage} shouldHideThreadDividerLine={shouldHideThreadDividerLine} + hasActionsAfterCreated={hasActionsAfterCreated} /> @@ -586,6 +591,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 && ( + <> + + + {translate('common.comments')} + + {shouldHideThreadDividerLine && ( + + )} + + ); + const disabledStateValue = {...contextMenuStateValue, isDisabled: true}; if (isTransactionThread(parentReportAction)) { @@ -103,7 +129,7 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf expensePolicy={policy} shouldShowAnimatedBackground /> - {renderThreadDivider} + {expenseThreadDivider} @@ -168,7 +194,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 ( ({ ...jest.requireActual>('@userActions/IOU/UpdateMoneyRequest'), updateMoneyRequestMerchant: jest.fn(), updateMoneyRequestDescription: jest.fn(), + updateMoneyRequestAmountAndCurrency: jest.fn(), + updateMoneyRequestDate: 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(() => '$')}))})); @@ -92,7 +102,7 @@ describe('saved expense inline fields', () => { await waitForBatchedUpdatesWithAct(); const input = screen.getByLabelText('Merchant'); - // When the input is focused and left without a real change + // When only a trailing space is typed and the input is left fireEvent(input, 'focus'); fireEvent.changeText(input, 'Coffee Shop '); fireEvent(input, 'blur'); @@ -102,6 +112,29 @@ describe('saved expense inline fields', () => { 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(); @@ -175,6 +208,28 @@ describe('saved expense inline fields', () => { 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( + '}}} + transactionThreadReport={threadReport} + 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(); @@ -192,4 +247,122 @@ describe('saved expense inline fields', () => { 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('useUpdateTransactionDate', () => { + it('saves the picked date to the saved expense', async () => { + // Given the save hook for a reported expense + const {result} = renderHook(() => useUpdateTransactionDate({transaction, transactionThreadReport: threadReport, isTrackExpense: false}), { + wrapper: ({children}: {children: React.ReactNode}) => ( + {children} + ), + }); + 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..c1f75e12d9cc 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'}})), })); @@ -67,11 +69,6 @@ jest.mock('@pages/inbox/report/AnimatedEmptyStateBackground', () => { 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'); @@ -246,7 +243,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 e0d0f3a122fc..c5397d5f6ac1 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -5,8 +5,6 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import MoneyRequestView from '@components/ReportActionItem/MoneyRequestView'; import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; -import useThemeStyles from '@hooks/useThemeStyles'; - import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; @@ -14,7 +12,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 +26,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 +47,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 +62,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 +88,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 +108,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( - { await act(async () => { await Onyx.clear(); }); - mockMenuItemWithTopDescription.mockClear(); }); const setupTestData = async (isSettledReport = false) => { @@ -263,74 +216,70 @@ 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 merchant and description as inline inputs for a user on the inline fields beta', async () => { - // Given an open expense the user can edit, for a user on the inline fields beta + 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(); - await act(async () => { - await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.EXPENSE_VIEW_INLINE_FIELDS]); - }); // When the expense view renders renderMoneyRequestView(threadReport); await waitForBatchedUpdatesWithAct(); - // Then both text fields are inputs edited in place, in place of the rows that open their page + // 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')).toBeOnTheScreen(); + expect(screen.getByLabelText('common.merchant').props.value).toBe('Coffee Shop'); }); expect(screen.getByLabelText('common.description')).toBeOnTheScreen(); - expect(screen.queryByTestId('menu-item-common.merchant')).toBeNull(); - expect(screen.queryByTestId('menu-item-common.description')).toBeNull(); + 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('keeps the merchant a read-only row when it cannot be edited, even on the beta', async () => { - // Given a settled expense, whose merchant can no longer be edited, for a user on the inline fields beta + 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); - await act(async () => { - await Onyx.merge(ONYXKEYS.BETAS, [CONST.BETAS.EXPENSE_VIEW_INLINE_FIELDS]); - }); // When the expense view renders renderMoneyRequestView(threadReport); await waitForBatchedUpdatesWithAct(); - // Then the merchant stays a read-only row, as an input would invite an edit that cannot be saved + // 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.getByTestId('menu-item-common.merchant')).toHaveTextContent('readonly'); + 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 Category and Tag rows from the report policy when nothing is selected yet', async () => { + it('shows the category and tag of an editable expense as dropdown fields', async () => { + // Given an open expense 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}, @@ -340,50 +289,41 @@ describe('MoneyRequestView edit fields', () => { }); }); + // When the expense view renders renderMoneyRequestView(threadReport, {areCategoriesEnabled: true, areTagsEnabled: true}); await waitForBatchedUpdatesWithAct(); + // Then both are 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 Category and Tag rows from the report policy when nothing is selected yet', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), parentReportID: expenseReportID, parentReportActionID, }; - const longTagName = 'LongTagNameWithoutSpaces'; await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, { + Travel: {name: 'Travel', enabled: true}, + }); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { - [longTagName]: {name: longTagName, orderWeight: 0, required: false, tags: {Berlin: {name: 'Berlin', enabled: true}}}, + Location: {name: 'Location', orderWeight: 0, required: false, tags: {Berlin: {name: 'Berlin', enabled: true}}}, }); }); - // When the expense details are rendered - renderMoneyRequestView(threadReport, {areTagsEnabled: true}); + renderMoneyRequestView(threadReport, {areCategoriesEnabled: true, areTagsEnabled: true}); await waitForBatchedUpdatesWithAct(); - // Then each affected description keeps both breakWord and userSelectNone 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('common.category'))).toBeOnTheScreen(); + expect(screen.getByLabelText(fieldLabel('Location'))).toBeOnTheScreen(); }); }); @@ -403,7 +343,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})); @@ -434,10 +374,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 = {}) => { @@ -509,10 +449,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 () => { @@ -522,7 +462,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(); }); @@ -614,14 +554,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(); }); }); @@ -658,9 +596,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(); }); }); @@ -699,10 +637,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(); }); }); @@ -723,7 +661,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(); }); }); @@ -757,7 +695,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(); }); }); @@ -791,8 +729,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(); }); }); @@ -824,7 +762,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(); }); }); @@ -1294,7 +1232,6 @@ describe('MoneyRequestView edit fields', () => { await waitFor(() => { expect(screen.getByRole('alert')).toHaveTextContent('violations.inactiveVendor.'); - expect(screen.getByTestId('menu-item-brick-road-indicator')).toBeOnTheScreen(); }); }); @@ -1416,7 +1353,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/unit/hooks/useMirroredInputValueTest.tsx b/tests/unit/hooks/useMirroredInputValueTest.tsx index 28d99c2c7a49..7ad8c2a224a7 100644 --- a/tests/unit/hooks/useMirroredInputValueTest.tsx +++ b/tests/unit/hooks/useMirroredInputValueTest.tsx @@ -18,7 +18,7 @@ describe('useMirroredInputValue', () => { 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.markFocused()); + act(() => result.current.startEditing()); act(() => result.current.setInputValue('Coffee and cake')); // When a write lands on the stored value before the user is done @@ -29,12 +29,50 @@ describe('useMirroredInputValue', () => { 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('shows a stored value that changed while the untouched input was 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'}); + expect(result.current.inputValue).toBe('Coffee'); + act(() => { + result.current.finishEditing(); + }); + + // Then the input catches up, as otherwise it would keep the old value and a later blur could write it back + 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.markFocused()); + act(() => result.current.startEditing()); act(() => result.current.setInputValue('Tea')); - act(() => result.current.markBlurred()); + act(() => { + result.current.finishEditing(); + }); // When the stored value changes afterwards rerender({storedValue: 'Tea House'}); From a43855920d7d81fc3c729ceb2ac9c9135fd19af8 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Mon, 9 Nov 2026 16:08:22 +0700 Subject: [PATCH 03/14] enhance merchant input handling by stripping invisible characters and improving mirrored input behavior --- .../MerchantInputField.tsx | 4 +++- src/hooks/useMirroredInputValue.ts | 12 +++++++----- tests/ui/MoneyRequestViewInlineFieldsTest.tsx | 17 +++++++++++++++++ tests/unit/hooks/useMirroredInputValueTest.tsx | 14 +++++++++----- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx index dc778dd4569a..118ed2bafb5d 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx @@ -11,6 +11,7 @@ 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'; @@ -52,7 +53,8 @@ function MerchantInputField({transaction, transactionThreadReport, errorText = ' setInputError(''); return; } - const newMerchant = typedMerchant.trim(); + // 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(transactionThreadReport, transaction)); if (merchantError) { setInputError(merchantError); diff --git a/src/hooks/useMirroredInputValue.ts b/src/hooks/useMirroredInputValue.ts index 00e7f5bac2c8..d6e729549782 100644 --- a/src/hooks/useMirroredInputValue.ts +++ b/src/hooks/useMirroredInputValue.ts @@ -1,7 +1,7 @@ /** * Mirrors a value stored in Onyx in local state for a controlled text input. Feeding the stored value straight to the * input lets a write that lands mid-typing overwrite what the user typed and move the caret, so the input shows the - * mirror instead, which picks up outside changes to the stored value only while the input is not being edited. + * mirror instead, which picks up outside changes to the stored value until the user types in it. */ import {useState} from 'react'; @@ -15,7 +15,7 @@ type UseMirroredInputValueResult = { /** Whether the input is being edited */ isFocused: boolean; - /** Starts an edit, which holds off outside changes to the stored value */ + /** Starts an edit. Outside changes to the stored value are held off once the user types */ startEditing: () => void; /** @@ -31,11 +31,13 @@ function useMirroredInputValue(storedValue: string): UseMirroredInputValueResult 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. + // 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) { + if (!isFocused || inputValue === valueAtFocus) { setInputValue(storedValue); + setValueAtFocus(storedValue); } } @@ -49,7 +51,7 @@ function useMirroredInputValue(storedValue: string): UseMirroredInputValueResult if (inputValue !== valueAtFocus) { return inputValue; } - // The stored value may have changed while the untouched input was focused, and nothing else brings it in. + // 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; }; diff --git a/tests/ui/MoneyRequestViewInlineFieldsTest.tsx b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx index 41e9b4922ab4..b83d7515c04d 100644 --- a/tests/ui/MoneyRequestViewInlineFieldsTest.tsx +++ b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx @@ -96,6 +96,23 @@ describe('saved expense inline fields', () => { 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(); diff --git a/tests/unit/hooks/useMirroredInputValueTest.tsx b/tests/unit/hooks/useMirroredInputValueTest.tsx index 7ad8c2a224a7..24db4cbf378b 100644 --- a/tests/unit/hooks/useMirroredInputValueTest.tsx +++ b/tests/unit/hooks/useMirroredInputValueTest.tsx @@ -49,19 +49,23 @@ describe('useMirroredInputValue', () => { expect(typedResult).toBe('Tea'); }); - it('shows a stored value that changed while the untouched input was focused', () => { + 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 + // When the stored value changes meanwhile, and the input is then left rerender({storedValue: 'Coffee Shop'}); - expect(result.current.inputValue).toBe('Coffee'); + const showsWhileFocused = result.current.inputValue; + let editResult: string | undefined; act(() => { - result.current.finishEditing(); + editResult = result.current.finishEditing(); }); - // Then the input catches up, as otherwise it would keep the old value and a later blur could write it back + // 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'); }); From 0733cb77954bad43d2f746e2d23263d8cd02e521 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Mon, 9 Nov 2026 16:49:06 +0700 Subject: [PATCH 04/14] update attendee handling in MoneyRequest components to use new hooks and improve dropdown functionality --- .../sections/AttendeeFieldDropdown.tsx | 31 +++-- .../ReportActionItem/MoneyRequestView.tsx | 56 +++++++-- .../AmountInputField.tsx | 75 ++---------- .../TaxAmountInputField.tsx | 81 +++++++++++++ .../useSavedAmountInput.ts | 109 ++++++++++++++++++ src/hooks/useUpdateTransactionAttendees.ts | 48 ++++++++ src/hooks/useUpdateTransactionTaxAmount.ts | 47 ++++++++ .../step/DynamicIOURequestStepAttendees.tsx | 60 +--------- .../DynamicIOURequestStepTaxAmountPage.tsx | 45 +------- tests/ui/MoneyRequestViewInlineFieldsTest.tsx | 94 ++++++++++++++- tests/ui/MoneyRequestViewTest.tsx | 44 +++++++ .../AttendeeFieldDropdown.test.tsx | 63 ++++++++-- 12 files changed, 555 insertions(+), 198 deletions(-) create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx create mode 100644 src/components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput.ts create mode 100644 src/hooks/useUpdateTransactionAttendees.ts create mode 100644 src/hooks/useUpdateTransactionTaxAmount.ts diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx index b96317b6734f..5c447e554dd5 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx @@ -1,15 +1,17 @@ /** - * 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 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 +22,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 +30,21 @@ 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 {updateAttendees} = useUpdateTransactionAttendees({transactionID, report, 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 +62,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/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 7d91bf2505b4..56b94ac2c071 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -5,9 +5,11 @@ import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; 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'; @@ -182,6 +184,7 @@ 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; @@ -1379,6 +1382,8 @@ function MoneyRequestView({ !hasFailedScanAmountPlaceholder && !(shouldShowSplitIndicator && isSplitAvailable); const canAddCategory = canCreateCategoryInSitu(policy, currentUserEmailParam); + // A tax rate that can no longer be picked asks to be cleared first, which the tax amount's page flow handles. + const canEditTaxAmountInline = canEditTaxFields && !isReviewingUnsavedValues && !shouldShowTaxDisabledAlert; const hasAttendees = Array.isArray(actualAttendees) && actualAttendees.length > 0; const attendeesPerPersonText = @@ -1656,20 +1661,31 @@ function MoneyRequestView({ )} {shouldShowTax && ( - + {canEditTaxAmountInline ? ( + + ) : ( + + )} )} {shouldShowAttendees && ( - ( + + )} sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.ATTENDEES_FIELD} /> @@ -1685,13 +1710,22 @@ function MoneyRequestView({ {toggleFields} {shouldShowReport && ( - ( + + )} sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.REPORT_FIELD} /> diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx index bfad364f53ea..ccaab74303cb 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx @@ -8,11 +8,10 @@ import NumberWithSymbolForm from '@components/NumberWithSymbolForm'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; -import useMirroredInputValue from '@hooks/useMirroredInputValue'; import useThemeStyles from '@hooks/useThemeStyles'; import useUpdateTransactionAmount from '@hooks/useUpdateTransactionAmount'; -import {convertToFrontendAmountAsString, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; +import {getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; import {getBackendAmountFromInput, isValidMoneyRequestAmount, stripDecimalsFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; import IOURequestStepCurrencyModal from '@pages/iou/request/step/IOURequestStepCurrencyModal'; @@ -26,6 +25,8 @@ 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; @@ -87,67 +88,15 @@ function AmountInputField({ const currencyButtonRef = useRef(null); const decimals = getCurrencyDecimals(currency); - const savedValue = `${amount < 0 ? '-' : ''}${convertToFrontendAmountAsString(Math.abs(amount), decimals)}`; - const {inputValue, setInputValue, isFocused, startEditing, finishEditing} = useMirroredInputValue(savedValue); - const [inputError, setInputError] = useState(''); const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); - // The input keeps what is typed in its own state, so it is remounted to show an amount the user didn't type. - const [shownSavedValue, setShownSavedValue] = useState(savedValue); - const [resetCount, setResetCount] = useState(0); - if (!isFocused && shownSavedValue !== savedValue) { - setShownSavedValue(savedValue); - setInputError(''); - } - - const isValidAmount = (newAmount: number) => isValidMoneyRequestAmount(newAmount, iouType, allowNegative, isP2P); - - const showSavedAmount = () => { - setInputValue(savedValue); - setResetCount((count) => count + 1); - }; - - const saveAmount = (typedAmount: string) => { - const newAmount = getBackendAmountFromInput(typedAmount); - // An expense always has an amount, so a cleared input goes back to the saved one. - if (newAmount === null) { - setInputError(''); - showSavedAmount(); - return; - } - if (!isValidAmount(newAmount)) { - setInputError(translate('iou.error.invalidAmount')); - return; - } - setInputError(''); - if (newAmount === amount) { - showSavedAmount(); - return; - } - updateAmount(newAmount, currency); - }; - - 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 getAmountError = (newAmount: number) => (isValidMoneyRequestAmount(newAmount, iouType, allowNegative, isP2P) ? '' : translate('iou.error.invalidAmount')); + const {inputValue, inputError, inputKey, startEditing, applyTypedAmount, saveTypedAmount} = useSavedAmountInput({ + amount, + decimals, + getAmountError, + onSave: (newAmount) => updateAmount(newAmount, currency), + }); const updateCurrency = (newCurrency: string) => { setIsCurrencyPickerVisible(false); @@ -159,7 +108,7 @@ function AmountInputField({ const newDecimals = getCurrencyDecimals(newCurrency); const typedAmountForCurrency = validateAmount(inputValue, newDecimals, undefined, allowNegative) ? inputValue : stripDecimalsFromAmount(inputValue); const typedAmount = inputError ? null : getBackendAmountFromInput(typedAmountForCurrency); - updateAmount(typedAmount !== null && isValidAmount(typedAmount) ? typedAmount : amount, newCurrency); + updateAmount(typedAmount !== null && !getAmountError(typedAmount) ? typedAmount : amount, newCurrency); }; return ( @@ -174,7 +123,7 @@ function AmountInputField({ /> ; + + transactionThreadReport: OnyxEntry; + + /** 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, transactionThreadReport, policy, taxAmount, currency, label}: TaxAmountInputFieldProps) { + const styles = useThemeStyles(); + const {translate, preferredLocale} = useLocalize(); + const {getCurrencyDecimals, convertToDisplayString} = useCurrencyListActions(); + const {updateTaxAmount} = useUpdateTransactionTaxAmount({transactionID: transaction?.transactionID, transactionThreadReport, 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} = 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..09e582a00e80 --- /dev/null +++ b/src/components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput.ts @@ -0,0 +1,109 @@ +/** + * 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 useMirroredInputValue from '@hooks/useMirroredInputValue'; + +import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; +import {getBackendAmountFromInput} from '@libs/MoneyRequestUtils'; + +import {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; +}; + +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); + } + }; + + return {inputValue, inputError, inputKey: `${shownSavedValue}-${resetCount}`, startEditing, applyTypedAmount, saveTypedAmount}; +} + +export default useSavedAmountInput; diff --git a/src/hooks/useUpdateTransactionAttendees.ts b/src/hooks/useUpdateTransactionAttendees.ts new file mode 100644 index 000000000000..de86cbf57d6e --- /dev/null +++ b/src/hooks/useUpdateTransactionAttendees.ts @@ -0,0 +1,48 @@ +/** + * 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 * as OnyxTypes from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import useMoneyRequestUpdateParams 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; + + /** Report the expense lives on: the transaction thread when a saved expense is edited */ + report: OnyxEntry; + + /** 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, report, isEditing}: UseUpdateTransactionAttendeesParams): UseUpdateTransactionAttendeesResult { + const updateParams = useMoneyRequestUpdateParams(transactionID, report); + 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/useUpdateTransactionTaxAmount.ts b/src/hooks/useUpdateTransactionTaxAmount.ts new file mode 100644 index 000000000000..9a820844d366 --- /dev/null +++ b/src/hooks/useUpdateTransactionTaxAmount.ts @@ -0,0 +1,47 @@ +/** + * 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 useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; +import useOnyx from './useOnyx'; + +type UseUpdateTransactionTaxAmountParams = { + /** ID of the saved expense being written to */ + transactionID: string | undefined; + + transactionThreadReport: OnyxEntry; + + /** 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, transactionThreadReport, policy}: UseUpdateTransactionTaxAmountParams): UseUpdateTransactionTaxAmountResult { + const updateParams = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); + 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/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx b/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx index aac40ab5fc3f..73f93e20194f 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx @@ -1,20 +1,11 @@ -import useAllTransactionViolations from '@hooks/useAllTransactionViolations'; -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 useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; -import {usePersonalDetail} from '@hooks/usePersonalDetails'; import usePrevious from '@hooks/usePrevious'; import useReportOwnerAsAttendee from '@hooks/useReportOwnerAsAttendee'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; +import useUpdateTransactionAttendees from '@hooks/useUpdateTransactionAttendees'; -import {setMoneyRequestAttendees} from '@libs/actions/IOU/MoneyRequest'; -import {updateMoneyRequestAttendees} from '@libs/actions/IOU/UpdateMoneyRequest'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; import {getOriginalAttendees} from '@libs/TransactionUtils'; @@ -26,8 +17,6 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Attendee} from '@src/types/onyx/IOU'; -import {isTrackIntentUserSelector} from '@selectors/Onboarding'; -import {loginSelector} from '@selectors/PersonalDetails'; import {deepEqual} from 'fast-equals'; import React, {useEffect, useRef, useState} from 'react'; @@ -43,35 +32,17 @@ function DynamicIOURequestStepAttendees({ params: {transactionID, reportID, iouType, action}, }, }: DynamicIOURequestStepAttendeesProps) { - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.path); const isEditing = action === CONST.IOU.ACTION.EDIT; // eslint-disable-next-line rulesdir/no-default-id-values const [transaction] = useOnyx(`${isEditing ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID || CONST.DEFAULT_NUMBER_ID}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const policyID = report?.policyID; - const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); + const {updateAttendees} = useUpdateTransactionAttendees({transactionID, report, isEditing}); const reportOwnerAsAttendee = useReportOwnerAsAttendee(transaction); const [attendees, setAttendees] = useState(() => 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 +56,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/DynamicIOURequestStepTaxAmountPage.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx index 59f2e31d7bc8..16da1e177611 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx @@ -1,21 +1,16 @@ 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 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 +28,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 +65,8 @@ 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 {updateTaxAmount: updateSavedExpenseTaxAmount} = useUpdateTransactionTaxAmount({transactionID, transactionThreadReport: report, policy}); const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -88,13 +75,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 +115,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/tests/ui/MoneyRequestViewInlineFieldsTest.tsx b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx index b83d7515c04d..3cae68cc5835 100644 --- a/tests/ui/MoneyRequestViewInlineFieldsTest.tsx +++ b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx @@ -7,10 +7,17 @@ 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 useUpdateTransactionDate from '@hooks/useUpdateTransactionDate'; -import {updateMoneyRequestAmountAndCurrency, updateMoneyRequestDate, updateMoneyRequestDescription, updateMoneyRequestMerchant} from '@userActions/IOU/UpdateMoneyRequest'; +import { + updateMoneyRequestAmountAndCurrency, + updateMoneyRequestDate, + updateMoneyRequestDescription, + updateMoneyRequestMerchant, + updateMoneyRequestTaxAmount, +} from '@userActions/IOU/UpdateMoneyRequest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -28,18 +35,26 @@ jest.mock('@userActions/IOU/UpdateMoneyRequest', () => ({ 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(() => '$')}))})); +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, @@ -362,6 +377,81 @@ describe('saved expense inline fields', () => { }); }); + 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 diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index c5397d5f6ac1..5f466d65fa87 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -300,6 +300,27 @@ describe('MoneyRequestView edit fields', () => { expect(screen.getByLabelText(fieldLabel('Location')).props.accessibilityState).toMatchObject({expanded: false}); }); + 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, + }; + await setupTestData(); + + // When the expense view renders + renderMoneyRequestView(threadReport, {type: CONST.POLICY.TYPE.CORPORATE, isAttendeeTrackingEnabled: true}); + await waitForBatchedUpdatesWithAct(); + + // 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(() => { + expect(screen.getByLabelText(fieldLabel('iou.attendees')).props.accessibilityState).toMatchObject({expanded: false}); + }); + expect(screen.getByLabelText(fieldLabel('common.report')).props.accessibilityState).toMatchObject({expanded: false}); + }); + it('shows the Category and Tag rows from the report policy when nothing is selected yet', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), @@ -766,6 +787,29 @@ describe('MoneyRequestView edit fields', () => { }); }); + 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'); + }); + }); + it('appends "Converted" to the Tax amount description for a foreign-currency taxed expense', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), 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); + }); }); From ea6b8b0b7557898354412cbcdc8141b32df08210 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 10 Nov 2026 13:49:20 +0700 Subject: [PATCH 05/14] Refactor Money Request components to utilize new `useMoneyRequestUpdateParams` hook for managing transaction updates and violations --- .../sections/AttendeeFieldDropdown.tsx | 4 +- .../ReportActionItem/MoneyRequestView.tsx | 37 +++++++++++---- .../AmountInputField.tsx | 13 +++-- .../MoneyRequestViewFields/DateInputField.tsx | 12 +++-- .../DescriptionInputField.tsx | 8 ++-- .../MerchantInputField.tsx | 10 ++-- .../TaxAmountInputField.tsx | 8 ++-- .../useDuplicateTransactionsAndViolations.ts | 1 + src/hooks/useMoneyRequestUpdateParams.ts | 3 ++ src/hooks/useUpdateTransactionAmount.ts | 21 +++++---- src/hooks/useUpdateTransactionAttendees.ts | 11 ++--- src/hooks/useUpdateTransactionDate.ts | 20 ++++---- src/hooks/useUpdateTransactionDescription.ts | 11 +++-- src/hooks/useUpdateTransactionMerchant.ts | 10 ++-- src/hooks/useUpdateTransactionTaxAmount.ts | 9 ++-- .../step/DynamicIOURequestStepAttendees.tsx | 4 +- .../step/DynamicIOURequestStepDate.tsx | 6 ++- .../step/DynamicIOURequestStepDescription.tsx | 4 +- .../step/DynamicIOURequestStepMerchant.tsx | 4 +- .../DynamicIOURequestStepTaxAmountPage.tsx | 4 +- tests/ui/MoneyRequestViewInlineFieldsTest.tsx | 47 +++++++++++++------ tests/ui/MoneyRequestViewTest.tsx | 29 ++++++++++++ 22 files changed, 191 insertions(+), 85 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx index 5c447e554dd5..899504d57653 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx @@ -5,6 +5,7 @@ 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'; @@ -40,7 +41,8 @@ function AttendeeFieldDropdown({transactionID, iouType, isVisible, onClose, ...p // 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 {updateAttendees} = useUpdateTransactionAttendees({transactionID, report, isEditing}); + const updateParams = useMoneyRequestUpdateParams(transactionID, report); + const {updateAttendees} = useUpdateTransactionAttendees({transactionID, updateParams, isEditing}); const reportOwnerAsAttendee = useReportOwnerAsAttendee(transaction); const originalAttendees = getOriginalAttendees(transaction, reportOwnerAsAttendee); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index e1a3a447815a..51fbbcccc3a8 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -32,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'; @@ -306,6 +308,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(); @@ -1047,10 +1052,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); @@ -1181,6 +1187,15 @@ function MoneyRequestView({ } }; + 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; @@ -1484,7 +1499,8 @@ function MoneyRequestView({ {canEditAmountInline ? ( {shouldShowGoogleMerchantSearchLink && renderGoogleMerchantSearchLink()} @@ -1553,9 +1569,11 @@ function MoneyRequestView({ name={translate('common.merchant')} value={updatedMerchantTitle} numberOfLinesValue={0} - copyValue={merchantCopyValue || undefined} + // A merchant still being scanned can't be typed over in place, so it keeps its page. + onPress={openMerchantPage} + copyValue={merchantCopyValue} errorText={getErrorForField('merchant')} - isInteractive={false} + isInteractive={canEditMerchant} sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST_VIEW.MERCHANT_FIELD} /> {shouldShowGoogleMerchantSearchLink && {renderGoogleMerchantSearchLink()}} @@ -1567,7 +1585,8 @@ function MoneyRequestView({ {canEditDate ? ( ; - transactionThreadReport: 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; @@ -68,7 +74,8 @@ type AmountInputFieldProps = { function AmountInputField({ transaction, - transactionThreadReport, + updateParams, + duplicates, policy, policyCategories, amount, @@ -84,7 +91,7 @@ function AmountInputField({ const styles = useThemeStyles(); const {translate, preferredLocale} = useLocalize(); const {getCurrencyDecimals} = useCurrencyListActions(); - const {updateAmount} = useUpdateTransactionAmount({transaction, transactionThreadReport, policy, policyCategories}); + const {updateAmount} = useUpdateTransactionAmount({transaction, updateParams, duplicates, policy, policyCategories}); const currencyButtonRef = useRef(null); const decimals = getCurrencyDecimals(currency); diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/DateInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/DateInputField.tsx index 2d36b2fa65bc..db15995ddec1 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/DateInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/DateInputField.tsx @@ -4,6 +4,8 @@ */ 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'; @@ -22,7 +24,11 @@ type DateInputFieldProps = { /** Expense whose date is edited */ transaction: OnyxEntry; - transactionThreadReport: 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; @@ -34,9 +40,9 @@ type DateInputFieldProps = { errorText?: string; }; -function DateInputField({transaction, transactionThreadReport, isTrackExpense, label, errorText = ''}: DateInputFieldProps) { +function DateInputField({transaction, updateParams, duplicates, isTrackExpense, label, errorText = ''}: DateInputFieldProps) { const styles = useThemeStyles(); - const {updateDate} = useUpdateTransactionDate({transaction, transactionThreadReport, isTrackExpense}); + const {updateDate} = useUpdateTransactionDate({transaction, updateParams, duplicates, isTrackExpense}); const savedDate = getFormattedCreated(transaction); const [isCleared, setIsCleared] = useState(false); diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx index a1b78a184eec..66fe5cfb381d 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/DescriptionInputField.tsx @@ -6,6 +6,7 @@ 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'; @@ -28,7 +29,8 @@ type DescriptionInputFieldProps = { /** Expense whose description is edited */ transaction: OnyxEntry; - transactionThreadReport: 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; @@ -40,10 +42,10 @@ type DescriptionInputFieldProps = { errorText?: string; }; -function DescriptionInputField({transaction, transactionThreadReport, isReportInGroupPolicy, hint, errorText = ''}: DescriptionInputFieldProps) { +function DescriptionInputField({transaction, updateParams, isReportInGroupPolicy, hint, errorText = ''}: DescriptionInputFieldProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {updateDescription} = useUpdateTransactionDescription({transaction, transactionThreadReport}); + const {updateDescription} = useUpdateTransactionDescription({transaction, updateParams}); // The saved description is HTML, so it is compared and edited as markdown. const savedDescription = Parser.htmlToMarkdown(getDescription(transaction)); diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx index fe637e29a8ce..4ff5de58ccaa 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx @@ -6,6 +6,7 @@ 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'; @@ -27,7 +28,8 @@ type MerchantInputFieldProps = { /** Expense whose merchant is edited */ transaction: OnyxEntry; - transactionThreadReport: 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; @@ -36,10 +38,10 @@ type MerchantInputFieldProps = { children?: ReactNode; }; -function MerchantInputField({transaction, transactionThreadReport, errorText = '', children}: MerchantInputFieldProps) { +function MerchantInputField({transaction, updateParams, errorText = '', children}: MerchantInputFieldProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {updateMerchant} = useUpdateTransactionMerchant({transactionID: transaction?.transactionID, transactionThreadReport}); + const {updateMerchant} = useUpdateTransactionMerchant({transactionID: transaction?.transactionID, updateParams}); const merchant = getTransactionDetails(transaction)?.merchant; const savedMerchant = isInvalidMerchantValue(merchant) ? '' : (merchant ?? ''); @@ -55,7 +57,7 @@ function MerchantInputField({transaction, transactionThreadReport, errorText = ' } // 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(transactionThreadReport, transaction)); + const merchantError = getInlineMerchantErrorMessage(translate, newMerchant, isMerchantRequired(updateParams.transactionThreadReport, transaction)); if (merchantError) { setInputError(merchantError); return; diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx index 05cdf8eb313e..c32d6b117bac 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx @@ -6,6 +6,7 @@ import NumberWithSymbolForm from '@components/NumberWithSymbolForm'; 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'; @@ -25,7 +26,8 @@ type TaxAmountInputFieldProps = { /** Expense whose tax amount is edited */ transaction: OnyxEntry; - transactionThreadReport: 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; @@ -40,11 +42,11 @@ type TaxAmountInputFieldProps = { label: string; }; -function TaxAmountInputField({transaction, transactionThreadReport, policy, taxAmount, currency, label}: TaxAmountInputFieldProps) { +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, transactionThreadReport, policy}); + 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. 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/useMoneyRequestUpdateParams.ts b/src/hooks/useMoneyRequestUpdateParams.ts index ba3d3104640e..012c26a61377 100644 --- a/src/hooks/useMoneyRequestUpdateParams.ts +++ b/src/hooks/useMoneyRequestUpdateParams.ts @@ -59,4 +59,7 @@ function useMoneyRequestUpdateParams(transactionID: string | undefined, transact }; } +type MoneyRequestUpdateParams = ReturnType; + export default useMoneyRequestUpdateParams; +export type {MoneyRequestUpdateParams}; diff --git a/src/hooks/useUpdateTransactionAmount.ts b/src/hooks/useUpdateTransactionAmount.ts index bfbf97fdbcf5..42b8e8572cf7 100644 --- a/src/hooks/useUpdateTransactionAmount.ts +++ b/src/hooks/useUpdateTransactionAmount.ts @@ -9,15 +9,20 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; -import useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; +import type {DuplicateTransactionsAndViolations} from './useDuplicateTransactionsAndViolations'; +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; + import useOnyx from './useOnyx'; type UseUpdateTransactionAmountParams = { /** Saved expense being written to */ transaction: OnyxEntry; - transactionThreadReport: 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; @@ -31,10 +36,8 @@ type UseUpdateTransactionAmountResult = { updateAmount: (amount: number, currency: string) => void; }; -function useUpdateTransactionAmount({transaction, transactionThreadReport, policy, policyCategories}: UseUpdateTransactionAmountParams): UseUpdateTransactionAmountResult { +function useUpdateTransactionAmount({transaction, updateParams, duplicates, policy, policyCategories}: UseUpdateTransactionAmountParams): UseUpdateTransactionAmountResult { const transactionID = transaction?.transactionID; - const updateParams = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); - const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(transactionID ? [transactionID] : []); const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); const updateAmount = (amount: number, currency: string) => { @@ -45,13 +48,13 @@ function useUpdateTransactionAmount({transaction, transactionThreadReport, polic policy, transactionID, transaction, - transactionThreadReport, + transactionThreadReport: updateParams.transactionThreadReport, parentReport: updateParams.parentReport, iouReportOwnerLogin: updateParams.iouReportOwnerLogin, amount, currency, - duplicateTransactions, - duplicateTransactionViolations, + duplicateTransactions: duplicates.duplicateTransactions, + duplicateTransactionViolations: duplicates.duplicateTransactionViolations, policyCategories, currentUserAccountID: updateParams.currentUserAccountIDParam, currentUserEmail: updateParams.currentUserEmailParam, diff --git a/src/hooks/useUpdateTransactionAttendees.ts b/src/hooks/useUpdateTransactionAttendees.ts index de86cbf57d6e..6ebe51b0897c 100644 --- a/src/hooks/useUpdateTransactionAttendees.ts +++ b/src/hooks/useUpdateTransactionAttendees.ts @@ -6,20 +6,18 @@ import {setMoneyRequestAttendees} from '@libs/actions/IOU/MoneyRequest'; import {updateMoneyRequestAttendees} from '@libs/actions/IOU/UpdateMoneyRequest'; -import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; -import useMoneyRequestUpdateParams 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; - /** Report the expense lives on: the transaction thread when a saved expense is edited */ - report: OnyxEntry; + /** 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; @@ -30,8 +28,7 @@ type UseUpdateTransactionAttendeesResult = { updateAttendees: (attendees: Attendee[]) => void; }; -function useUpdateTransactionAttendees({transactionID, report, isEditing}: UseUpdateTransactionAttendeesParams): UseUpdateTransactionAttendeesResult { - const updateParams = useMoneyRequestUpdateParams(transactionID, report); +function useUpdateTransactionAttendees({transactionID, updateParams, isEditing}: UseUpdateTransactionAttendeesParams): UseUpdateTransactionAttendeesResult { const {isOffline} = useNetwork(); const updateAttendees = (attendees: Attendee[]) => { diff --git a/src/hooks/useUpdateTransactionDate.ts b/src/hooks/useUpdateTransactionDate.ts index 99ce46e63f9b..7c1cb15dd816 100644 --- a/src/hooks/useUpdateTransactionDate.ts +++ b/src/hooks/useUpdateTransactionDate.ts @@ -11,9 +11,10 @@ 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 useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; -import useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; import useNetwork from './useNetwork'; import usePersonalPolicy from './usePersonalPolicy'; import usePolicy from './usePolicy'; @@ -23,7 +24,11 @@ type UseUpdateTransactionDateParams = { /** Saved expense being written to */ transaction: OnyxEntry; - transactionThreadReport: 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; @@ -34,9 +39,9 @@ type UseUpdateTransactionDateResult = { updateDate: (created: string) => void; }; -function useUpdateTransactionDate({transaction, transactionThreadReport, isTrackExpense}: UseUpdateTransactionDateParams): UseUpdateTransactionDateResult { +function useUpdateTransactionDate({transaction, updateParams: allUpdateParams, duplicates, isTrackExpense}: UseUpdateTransactionDateParams): UseUpdateTransactionDateResult { const transactionID = transaction?.transactionID; - const {policy, policyTagList, violations, ...updateParams} = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); + const {policy, policyTagList, violations, ...updateParams} = allUpdateParams; const {isOffline} = useNetwork(); const personalPolicy = usePersonalPolicy(); const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); @@ -45,7 +50,6 @@ function useUpdateTransactionDate({transaction, transactionThreadReport, isTrack 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 updateDate = (created: string) => { if (!transactionID) { @@ -54,8 +58,8 @@ function useUpdateTransactionDate({transaction, transactionThreadReport, isTrack updateMoneyRequestDate({ ...updateParams, transactionID, - transactions: duplicateTransactions, - transactionViolations: duplicateTransactionViolations, + transactions: duplicates.duplicateTransactions, + transactionViolations: duplicates.duplicateTransactionViolations, value: created, policy: effectivePolicy, policyForTrackExpense, diff --git a/src/hooks/useUpdateTransactionDescription.ts b/src/hooks/useUpdateTransactionDescription.ts index 02ce32bd7d2f..d6c482b1a2aa 100644 --- a/src/hooks/useUpdateTransactionDescription.ts +++ b/src/hooks/useUpdateTransactionDescription.ts @@ -11,14 +11,16 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; + import useOnyx from './useOnyx'; type UseUpdateTransactionDescriptionParams = { /** Saved expense being written to */ transaction: OnyxEntry; - transactionThreadReport: OnyxEntry; + /** What the write needs to recompute the expense's violations, read once by the caller */ + updateParams: MoneyRequestUpdateParams; }; type UseUpdateTransactionDescriptionResult = { @@ -26,10 +28,9 @@ type UseUpdateTransactionDescriptionResult = { updateDescription: (description: string) => void; }; -function useUpdateTransactionDescription({transaction, transactionThreadReport}: UseUpdateTransactionDescriptionParams): UseUpdateTransactionDescriptionResult { +function useUpdateTransactionDescription({transaction, updateParams}: UseUpdateTransactionDescriptionParams): UseUpdateTransactionDescriptionResult { const transactionID = transaction?.transactionID; - const updateParams = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); - const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${getNonEmptyStringOnyxID(transactionThreadReport?.policyID)}`); + const [policyVendors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_VENDORS}${getNonEmptyStringOnyxID(updateParams.transactionThreadReport?.policyID)}`); const updateDescription = (description: string) => { if (!transactionID) { diff --git a/src/hooks/useUpdateTransactionMerchant.ts b/src/hooks/useUpdateTransactionMerchant.ts index 8201f3ff5652..8c0609d90224 100644 --- a/src/hooks/useUpdateTransactionMerchant.ts +++ b/src/hooks/useUpdateTransactionMerchant.ts @@ -5,18 +5,17 @@ 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 useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; import useNetwork from './useNetwork'; type UseUpdateTransactionMerchantParams = { /** ID of the saved expense being written to */ transactionID: string | undefined; - transactionThreadReport: OnyxEntry; + /** What the write needs to recompute the expense's violations, read once by the caller */ + updateParams: MoneyRequestUpdateParams; }; type UseUpdateTransactionMerchantResult = { @@ -24,8 +23,7 @@ type UseUpdateTransactionMerchantResult = { updateMerchant: (merchant: string) => void; }; -function useUpdateTransactionMerchant({transactionID, transactionThreadReport}: UseUpdateTransactionMerchantParams): UseUpdateTransactionMerchantResult { - const updateParams = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); +function useUpdateTransactionMerchant({transactionID, updateParams}: UseUpdateTransactionMerchantParams): UseUpdateTransactionMerchantResult { const {isOffline} = useNetwork(); const updateMerchant = (merchant: string) => { diff --git a/src/hooks/useUpdateTransactionTaxAmount.ts b/src/hooks/useUpdateTransactionTaxAmount.ts index 9a820844d366..122dfac960c8 100644 --- a/src/hooks/useUpdateTransactionTaxAmount.ts +++ b/src/hooks/useUpdateTransactionTaxAmount.ts @@ -11,14 +11,16 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import useMoneyRequestUpdateParams from './useMoneyRequestUpdateParams'; +import type {MoneyRequestUpdateParams} from './useMoneyRequestUpdateParams'; + import useOnyx from './useOnyx'; type UseUpdateTransactionTaxAmountParams = { /** ID of the saved expense being written to */ transactionID: string | undefined; - transactionThreadReport: OnyxEntry; + /** 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; @@ -29,8 +31,7 @@ type UseUpdateTransactionTaxAmountResult = { updateTaxAmount: (taxAmount: number) => void; }; -function useUpdateTransactionTaxAmount({transactionID, transactionThreadReport, policy}: UseUpdateTransactionTaxAmountParams): UseUpdateTransactionTaxAmountResult { - const updateParams = useMoneyRequestUpdateParams(transactionID, transactionThreadReport); +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)}`); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx b/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx index 73f93e20194f..c39234d29840 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepAttendees.tsx @@ -1,5 +1,6 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useReportOwnerAsAttendee from '@hooks/useReportOwnerAsAttendee'; @@ -37,7 +38,8 @@ function DynamicIOURequestStepAttendees({ // eslint-disable-next-line rulesdir/no-default-id-values const [transaction] = useOnyx(`${isEditing ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID || CONST.DEFAULT_NUMBER_ID}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const {updateAttendees} = useUpdateTransactionAttendees({transactionID, report, isEditing}); + const updateParams = useMoneyRequestUpdateParams(transactionID, report); + const {updateAttendees} = useUpdateTransactionAttendees({transactionID, updateParams, isEditing}); const reportOwnerAsAttendee = useReportOwnerAsAttendee(transaction); const [attendees, setAttendees] = useState(() => getOriginalAttendees(transaction, reportOwnerAsAttendee)); const previousAttendees = usePrevious(attendees); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx index 485b0dc377ce..3e08a4594ceb 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDate.tsx @@ -4,8 +4,10 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams'; import useOnyx from '@hooks/useOnyx'; import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; @@ -66,7 +68,9 @@ function DynamicIOURequestStepDate({ const {policyForMovingExpensesID} = usePolicyForMovingExpenses(); const policyForTrackExpense = usePolicy(isTrackExpense ? policyForMovingExpensesID : undefined); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); - const {updateDate: updateSavedExpenseDate} = useUpdateTransactionDate({transaction, transactionThreadReport: report, isTrackExpense}); + 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; diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx index 736fdc37c7f6..9fad7206d847 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDescription.tsx @@ -8,6 +8,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; 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 usePolicy from '@hooks/usePolicy'; @@ -67,7 +68,8 @@ function DynamicIOURequestStepDescription({ const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report?.policyID}`); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); - const {updateDescription} = useUpdateTransactionDescription({transaction, transactionThreadReport: report}); + const updateParams = useMoneyRequestUpdateParams(transaction?.transactionID, report); + const {updateDescription} = useUpdateTransactionDescription({transaction, updateParams}); const styles = useThemeStyles(); const {translate} = useLocalize(); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx index 0607a2c5c0a6..6eefe1eb9088 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepMerchant.tsx @@ -8,6 +8,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; 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 useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; @@ -53,7 +54,8 @@ function DynamicIOURequestStepMerchant({ }: DynamicIOURequestStepMerchantProps) { const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_MERCHANT.path); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); - const {updateMerchant: updateSavedExpenseMerchant} = useUpdateTransactionMerchant({transactionID, transactionThreadReport: report}); + const updateParams = useMoneyRequestUpdateParams(transactionID, report); + const {updateMerchant: updateSavedExpenseMerchant} = useUpdateTransactionMerchant({transactionID, updateParams}); const styles = useThemeStyles(); const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx index 16da1e177611..5e431860508a 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTaxAmountPage.tsx @@ -3,6 +3,7 @@ import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; +import useMoneyRequestUpdateParams from '@hooks/useMoneyRequestUpdateParams'; import useOnyx from '@hooks/useOnyx'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import useRestartOnReceiptFailure from '@hooks/useRestartOnReceiptFailure'; @@ -66,7 +67,8 @@ function DynamicIOURequestStepTaxAmountPage({ const backPath = useDynamicBackPath(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.path); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); - const {updateTaxAmount: updateSavedExpenseTaxAmount} = useUpdateTransactionTaxAmount({transactionID, transactionThreadReport: report, policy}); + const updateParams = useMoneyRequestUpdateParams(transactionID, report); + const {updateTaxAmount: updateSavedExpenseTaxAmount} = useUpdateTransactionTaxAmount({transactionID, updateParams, policy}); const {translate} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); diff --git a/tests/ui/MoneyRequestViewInlineFieldsTest.tsx b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx index 3cae68cc5835..e1224a592bc2 100644 --- a/tests/ui/MoneyRequestViewInlineFieldsTest.tsx +++ b/tests/ui/MoneyRequestViewInlineFieldsTest.tsx @@ -9,6 +9,9 @@ import DescriptionInputField from '@components/ReportActionItem/MoneyRequestView 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 { @@ -68,10 +71,19 @@ const transaction: Transaction = { 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}; -const renderField = (field: React.ReactElement) => - render({field}); +// 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}); }); @@ -81,6 +93,9 @@ describe('saved expense inline fields', () => { 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', () => { @@ -88,7 +103,7 @@ describe('saved expense inline fields', () => { renderField( , ); @@ -152,12 +167,12 @@ describe('saved expense inline fields', () => { // 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(); @@ -200,7 +215,7 @@ describe('saved expense inline fields', () => { renderField( , ); @@ -246,7 +261,7 @@ describe('saved expense inline fields', () => { renderField( '}}} - transactionThreadReport={threadReport} + updateParams={updateParams} isReportInGroupPolicy />, ); @@ -292,7 +307,8 @@ describe('saved expense inline fields', () => { renderField( { renderField( { 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, transactionThreadReport: threadReport, isTrackExpense: false}), { - wrapper: ({children}: {children: React.ReactNode}) => ( - {children} - ), - }); + 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 diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 5f466d65fa87..d68e57c55535 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -249,6 +249,35 @@ describe('MoneyRequestView edit fields', () => { expect(screen.getByLabelText(amountLabel).props.value).toBe('-50.00'); }); + 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 = { From f19eb0a90c547aae5d747030310f5afa9266a201 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Tue, 10 Nov 2026 13:57:50 +0700 Subject: [PATCH 06/14] address comments --- .../ReportActionItem/MoneyRequestView.tsx | 15 +-- .../report/ReportActionItemContentCreated.tsx | 11 +- tests/ui/MoneyRequestViewTest.tsx | 34 +---- .../ui/ReportActionItemContentCreatedTest.tsx | 119 ++++++++++++++++++ 4 files changed, 133 insertions(+), 46 deletions(-) create mode 100644 tests/ui/ReportActionItemContentCreatedTest.tsx diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 51fbbcccc3a8..0aa52df67a34 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -232,7 +232,7 @@ function MoneyRequestView({ isFromReviewDuplicates = false, mergeTransactionID, }: MoneyRequestViewProps) { - const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Checkmark', 'Suitcase', 'NewWindow']); + const icons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Suitcase', 'NewWindow']); const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); @@ -486,9 +486,6 @@ function MoneyRequestView({ rules, }); - // The values shown for review in the duplicates and merge flows are not saved yet, so those flows lock every field. - const isReviewingUnsavedValues = isFromReviewDuplicates || isFromMergeTransaction; - const canEditDate = isEditable && canEditFieldOfMoneyRequest({ @@ -1389,16 +1386,10 @@ function MoneyRequestView({ // A per diem amount comes from its rates, a distance amount waits for its route, a scanning receipt's amount comes // from SmartScan and a split amount is edited across its splits, so those keep the row that opens their own page. const canEditAmountInline = - canEditAmount && - !isReviewingUnsavedValues && - shouldDisplayTransactionAmount && - !isPerDiemRequest && - !isTransactionScanning && - !hasFailedScanAmountPlaceholder && - !(shouldShowSplitIndicator && isSplitAvailable); + canEditAmount && shouldDisplayTransactionAmount && !isPerDiemRequest && !isTransactionScanning && !hasFailedScanAmountPlaceholder && !(shouldShowSplitIndicator && isSplitAvailable); const canAddCategory = canCreateCategoryInSitu(policy, currentUserEmailParam); // A tax rate that can no longer be picked asks to be cleared first, which the tax amount's page flow handles. - const canEditTaxAmountInline = canEditTaxFields && !isReviewingUnsavedValues && !shouldShowTaxDisabledAlert; + const canEditTaxAmountInline = canEditTaxFields && !shouldShowTaxDisabledAlert; const hasAttendees = Array.isArray(actualAttendees) && actualAttendees.length > 0; const attendeesPerPersonText = diff --git a/src/pages/inbox/report/ReportActionItemContentCreated.tsx b/src/pages/inbox/report/ReportActionItemContentCreated.tsx index be0d53368ff7..b7d49435bbf4 100644 --- a/src/pages/inbox/report/ReportActionItemContentCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemContentCreated.tsx @@ -67,10 +67,13 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf // 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 && ( + + )} { expect(screen.queryByLabelText('common.date')).toBeNull(); }); - it('shows the category and tag of an editable expense as dropdown fields', async () => { - // Given an open expense on a workspace with categories and tags + 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, @@ -322,7 +322,8 @@ describe('MoneyRequestView edit fields', () => { renderMoneyRequestView(threadReport, {areCategoriesEnabled: true, areTagsEnabled: true}); await waitForBatchedUpdatesWithAct(); - // Then both are fields that report a closed list, which is how a field that opens its list in place reads + // 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')).props.accessibilityState).toMatchObject({expanded: false}); }); @@ -350,33 +351,6 @@ describe('MoneyRequestView edit fields', () => { expect(screen.getByLabelText(fieldLabel('common.report')).props.accessibilityState).toMatchObject({expanded: false}); }); - it('shows the Category and Tag rows from the report policy when nothing is selected yet', async () => { - 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}, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, { - Location: {name: 'Location', orderWeight: 0, required: false, tags: {Berlin: {name: 'Berlin', enabled: true}}}, - }); - }); - - renderMoneyRequestView(threadReport, {areCategoriesEnabled: true, areTagsEnabled: true}); - await waitForBatchedUpdatesWithAct(); - - await waitFor(() => { - expect(screen.getByLabelText(fieldLabel('common.category'))).toBeOnTheScreen(); - expect(screen.getByLabelText(fieldLabel('Location'))).toBeOnTheScreen(); - }); - }); - it('marks selectable toggle labels for multi-field copying', async () => { // Given an expense that displays the Reimbursable and Billable toggles const threadReport = { diff --git a/tests/ui/ReportActionItemContentCreatedTest.tsx b/tests/ui/ReportActionItemContentCreatedTest.tsx new file mode 100644 index 000000000000..049dd11e47a9 --- /dev/null +++ b/tests/ui/ReportActionItemContentCreatedTest.tsx @@ -0,0 +1,119 @@ +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 divider parts 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 () => ; +}); +jest.mock('@components/SpacerView', () => { + 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('divider-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('divider-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('divider-rule')).not.toBeOnTheScreen(); + }); +}); From 54466ae5814ee5dd2aa989c76a1a6e18df64ab7f Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 11 Nov 2026 16:31:19 +0700 Subject: [PATCH 07/14] address comments --- .../sections/DescriptionField.tsx | 2 +- .../sections/ExpenseFieldRow.tsx | 1 - src/components/ReceiptAudit.tsx | 6 ++- .../ReportActionItem/MoneyReportView.tsx | 37 +++++++++++--- .../ReportActionItem/MoneyRequestView.tsx | 11 +---- .../DynamicConfirmationPage.tsx | 1 - .../DynamicConfirmationPage.tsx | 1 - .../report/MoneyReportContentCreated.tsx | 4 -- .../report/ReportActionItemContentCreated.tsx | 9 ++-- tests/ui/MoneyReportContentCreatedTest.tsx | 19 ++++++- tests/ui/MoneyRequestViewReceiptTest.tsx | 6 --- tests/ui/MoneyRequestViewTest.tsx | 49 +++++++++++++++++-- .../ui/ReportActionItemContentCreatedTest.tsx | 12 ++--- tests/ui/components/ReceiptAuditTest.tsx | 23 ++++++++- 14 files changed, 129 insertions(+), 52 deletions(-) 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 04164a3c98c8..783b0464e3c7 100644 --- a/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx @@ -153,7 +153,6 @@ function ExpenseFieldRow({ {valueComponent ?? ( {value} 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 0aa52df67a34..cc3f4834dc35 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -155,8 +155,6 @@ import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import Navigation from '@navigation/Navigation'; -import AnimatedEmptyStateBackground from '@pages/inbox/report/AnimatedEmptyStateBackground'; - import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -195,9 +193,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; @@ -226,7 +221,6 @@ function MoneyRequestView({ transactionThreadReport, parentReportID, expensePolicy, - shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false, @@ -721,7 +715,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', { @@ -1531,7 +1525,7 @@ function MoneyRequestView({ ) : ( {descriptionHTML} : undefined} + valueComponent={descriptionHTML ? {descriptionHTML} : undefined} hasValueComponent={!!descriptionHTML} accessibilityLabel={[translate('common.description'), descriptionCopyValue].filter(Boolean).join(', ')} copyValue={descriptionCopyValue} @@ -1752,7 +1746,6 @@ function MoneyRequestView({ style={[styles.moneyRequestView]} dataSet={COPYABLE_ROW_DATA_SET} > - {shouldShowAnimatedBackground && } {shouldShowReceipt && ( } 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/ReportActionItemContentCreated.tsx b/src/pages/inbox/report/ReportActionItemContentCreated.tsx index b7d49435bbf4..d00c2ccdb62c 100644 --- a/src/pages/inbox/report/ReportActionItemContentCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemContentCreated.tsx @@ -69,13 +69,13 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf <> {/* An unread comment's "New" line takes the rule's place, as it does below every other created action. */} {!shouldHideThreadDividerLine && ( - )} {translate('common.comments')} @@ -130,7 +130,6 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf transactionThreadReport={report} parentReportID={report?.parentReportID} expensePolicy={policy} - shouldShowAnimatedBackground /> {expenseThreadDivider} 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/MoneyRequestViewReceiptTest.tsx b/tests/ui/MoneyRequestViewReceiptTest.tsx index c1f75e12d9cc..7ff86fabaf1d 100644 --- a/tests/ui/MoneyRequestViewReceiptTest.tsx +++ b/tests/ui/MoneyRequestViewReceiptTest.tsx @@ -64,11 +64,6 @@ jest.mock('@components/ReportActionItem/MoneyRequestReceiptView', () => { return () => ; }); -jest.mock('@pages/inbox/report/AnimatedEmptyStateBackground', () => { - const RN = jest.requireActual>>('react-native'); - return () => ; -}); - jest.mock('@components/MenuItem', () => { const RN = jest.requireActual>>('react-native'); const {default: actualMenuItem} = jest.requireActual<{default: Record}>('@components/MenuItem'); @@ -137,7 +132,6 @@ const renderMoneyRequestView = () => transactionThreadReport={threadReport} parentReportID={expenseReportID} expensePolicy={expensePolicy} - shouldShowAnimatedBackground={false} /> , ); diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 34445cdc3a2a..9b8417da09dc 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -1,10 +1,12 @@ -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 useTheme from '@hooks/useTheme'; + import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; @@ -124,7 +126,6 @@ const renderMoneyRequestView = (threadReport: ReturnType @@ -249,6 +250,26 @@ describe('MoneyRequestView edit fields', () => { 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 = { @@ -301,6 +322,28 @@ describe('MoneyRequestView edit fields', () => { 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 mutedColor = renderHook(() => useTheme()).result.current.textSupporting; + + // When the expense view renders + renderMoneyRequestView(threadReport); + await waitForBatchedUpdatesWithAct(); + + // Then the merchant reads like any other text rather than muted, since the field's disabled fill already marks it as locked + await waitFor(() => { + expect(screen.getByText('Coffee Shop')).toBeOnTheScreen(); + }); + expect(screen.getByText('Coffee Shop')).not.toHaveStyle({color: mutedColor}); + }); + 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 = { @@ -458,7 +501,6 @@ describe('MoneyRequestView edit fields', () => { transactionThreadReport={threadReport} parentReportID={selfDMReportID} expensePolicy={undefined} - shouldShowAnimatedBackground={false} /> , @@ -1392,7 +1434,6 @@ describe('MoneyRequestView edit fields', () => { transactionThreadReport={threadReport} parentReportID={selfDMReportID} expensePolicy={undefined} - shouldShowAnimatedBackground={false} /> , ); diff --git a/tests/ui/ReportActionItemContentCreatedTest.tsx b/tests/ui/ReportActionItemContentCreatedTest.tsx index 049dd11e47a9..a6876d61bf84 100644 --- a/tests/ui/ReportActionItemContentCreatedTest.tsx +++ b/tests/ui/ReportActionItemContentCreatedTest.tsx @@ -19,7 +19,7 @@ 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 divider parts are stand-ins. +// 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 () => ; @@ -28,10 +28,6 @@ jest.mock('@components/UnreadActionIndicator', () => { const RN = jest.requireActual>>('react-native'); return () => ; }); -jest.mock('@components/SpacerView', () => { - const RN = jest.requireActual>>('react-native'); - return () => ; -}); const threadReport: OnyxTypes.Report = {...LHNTestUtils.getFakeReport(), parentReportID: 'expenseReport1'}; const expenseAction: OnyxTypes.ReportAction = buildOptimisticIOUReportAction({ @@ -89,7 +85,7 @@ describe('ReportActionItemContentCreated below an expense', () => { await waitForBatchedUpdatesWithAct(); // Then a rule and the "Comments" heading sit between the expense and the comments, with no "New" line - expect(screen.getByTestId('divider-rule')).toBeOnTheScreen(); + expect(screen.getByTestId('expense-comments-rule')).toBeOnTheScreen(); expect(screen.getByText('Comments')).toBeOnTheScreen(); expect(screen.queryByTestId('unread-indicator')).not.toBeOnTheScreen(); }); @@ -103,7 +99,7 @@ describe('ReportActionItemContentCreated below an expense', () => { // 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('divider-rule')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('expense-comments-rule')).not.toBeOnTheScreen(); }); it('shows no heading while the expense has nothing below it', async () => { @@ -114,6 +110,6 @@ describe('ReportActionItemContentCreated below an expense', () => { // Then there is nothing to head, so neither the heading nor the rule shows expect(screen.queryByText('Comments')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('divider-rule')).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..ea55069e2098 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 mutedColor = renderHook(() => useTheme()).result.current.textSupporting; + + // 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'))).not.toHaveStyle({color: mutedColor}); + expect(screen.getByText(new RegExp(translateLocal('iou.receiptIssuesFound', {count: 1})))).toHaveStyle({color: mutedColor}); + }); }); From 6d5c7f6684c4bde1eb4e7f4f0cb5d7d35d30ff3a Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 11 Nov 2026 17:06:08 +0700 Subject: [PATCH 08/14] Refactor TaxAmountInputField to use AmountFormField component instead of NumberWithSymbolForm, enhancing input handling and structure. --- .../TaxAmountInputField.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx index c32d6b117bac..78521c282982 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx @@ -2,7 +2,7 @@ * 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 NumberWithSymbolForm from '@components/NumberWithSymbolForm'; +import AmountFormField from '@components/AmountFormField'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; @@ -61,21 +61,20 @@ function TaxAmountInputField({transaction, updateParams, policy, taxAmount, curr return ( - + > + + ); } From 0379e5893acd9db8d69b4f8f1af4019abc93b68b Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 11 Nov 2026 17:24:01 +0700 Subject: [PATCH 09/14] Enhance AmountActionButtons and AmountInputField components by adding support for borderless styles, improving visual integration within forms. Update tests to verify new styling behavior and ensure proper ref handling for currency buttons. --- .../AmountCurrencyPickerButton.tsx | 14 ++++- .../AmountSignFlipButton.tsx | 11 +++- .../AmountInputField.tsx | 38 ++++++------ tests/ui/AmountActionButtonsTest.tsx | 60 ++++++++++++++++++- 4 files changed, 100 insertions(+), 23 deletions(-) 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/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx index 3722728bd4ef..dac07d2b4d94 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx @@ -3,8 +3,8 @@ * 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 NumberWithSymbolForm from '@components/NumberWithSymbolForm'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import type {DuplicateTransactionsAndViolations} from '@hooks/useDuplicateTransactionsAndViolations'; @@ -129,27 +129,31 @@ function AmountInputField({ launcherRef={currencyButtonRef} /> - setIsCurrencyPickerVisible(true)} - currencyButtonRef={currencyButtonRef} - shouldUseBorderlessButtons - /> + allowNegative={allowNegative} + > + + + {isCurrencyEditable && ( + setIsCurrencyPickerVisible(true)} + shouldUseBorderlessStyle + /> + )} + + {!!hintText && ( { 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(); + }); + }); }); From c059c721740fa0f84a881f40e86bf1c12083b791 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 12 Nov 2026 13:53:46 +0700 Subject: [PATCH 10/14] update padding --- src/styles/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index c5efae431fd5..deb01433adbc 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6943,7 +6943,7 @@ const staticStyles = (theme: ThemeColors) => }, wideRHPMoneyRequestReceiptViewScrollViewContainer: { - ...spacing.pt3, + ...spacing.pt4, ...spacing.pb2, minHeight: '100%', }, From d405b516e9358cf6c103a2bd6b04ee164781034c Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 12 Nov 2026 13:57:48 +0700 Subject: [PATCH 11/14] Fix selected tax rate calculation in MoneyRequestView component --- src/components/ReportActionItem/MoneyRequestView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index a50504913f71..c241a285df38 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1660,7 +1660,7 @@ function MoneyRequestView({ {...dropdownProps} transactionID={transaction.transactionID} policy={policy} - selectedTaxRate={getTaxRateTitle(policy, transaction, false)} + selectedTaxRate={getTaxRateTitle(translate, policy, transaction, false)} /> )} sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST_VIEW.TAX_RATE_FIELD} From c8d7453fd148760484df3496d4be3d7173246bd9 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 13 Nov 2026 13:38:01 +0700 Subject: [PATCH 12/14] Enhance MenuItemContent to accept custom styles and update ExpenseFieldRow to apply zero gap style for better layout consistency --- .../MenuItem/layout/MenuItemContent.tsx | 10 ++++++--- .../sections/ExpenseFieldRow.tsx | 3 ++- src/styles/utils/spacing.ts | 4 ++++ .../unit/components/ExpenseFieldRow.test.tsx | 22 +++++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) 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/ExpenseFieldRow.tsx b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx index 32b97c8e56f2..22eea8e8f004 100644 --- a/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/ExpenseFieldRow.tsx @@ -153,7 +153,8 @@ function ExpenseFieldRow({ 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} 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/unit/components/ExpenseFieldRow.test.tsx b/tests/unit/components/ExpenseFieldRow.test.tsx index 8d840fc3267c..0c0d8b551927 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 && 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', () => { From ec532f006305d79a2fdd9bbf85a4cdc364044bd2 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 13 Nov 2026 14:00:16 +0700 Subject: [PATCH 13/14] merge main --- .../MoneyRequestViewFields/MerchantInputField.tsx | 2 +- src/hooks/useUpdateTransactionDate.ts | 1 + src/hooks/useUpdateTransactionMerchant.ts | 10 ++++++++-- tests/unit/components/ExpenseFieldRow.test.tsx | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx index 4ff5de58ccaa..889d2893ea89 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/MerchantInputField.tsx @@ -41,7 +41,7 @@ type MerchantInputFieldProps = { function MerchantInputField({transaction, updateParams, errorText = '', children}: MerchantInputFieldProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {updateMerchant} = useUpdateTransactionMerchant({transactionID: transaction?.transactionID, updateParams}); + const {updateMerchant} = useUpdateTransactionMerchant({transactionID: transaction?.transactionID, transaction, updateParams}); const merchant = getTransactionDetails(transaction)?.merchant; const savedMerchant = isInvalidMerchantValue(merchant) ? '' : (merchant ?? ''); diff --git a/src/hooks/useUpdateTransactionDate.ts b/src/hooks/useUpdateTransactionDate.ts index 7c1cb15dd816..8630e848a860 100644 --- a/src/hooks/useUpdateTransactionDate.ts +++ b/src/hooks/useUpdateTransactionDate.ts @@ -58,6 +58,7 @@ function useUpdateTransactionDate({transaction, updateParams: allUpdateParams, d updateMoneyRequestDate({ ...updateParams, transactionID, + transaction, transactions: duplicates.duplicateTransactions, transactionViolations: duplicates.duplicateTransactionViolations, value: created, diff --git a/src/hooks/useUpdateTransactionMerchant.ts b/src/hooks/useUpdateTransactionMerchant.ts index 8c0609d90224..c4cde6658798 100644 --- a/src/hooks/useUpdateTransactionMerchant.ts +++ b/src/hooks/useUpdateTransactionMerchant.ts @@ -5,6 +5,9 @@ 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'; @@ -14,6 +17,9 @@ 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; }; @@ -23,14 +29,14 @@ type UseUpdateTransactionMerchantResult = { updateMerchant: (merchant: string) => void; }; -function useUpdateTransactionMerchant({transactionID, updateParams}: UseUpdateTransactionMerchantParams): UseUpdateTransactionMerchantResult { +function useUpdateTransactionMerchant({transactionID, transaction, updateParams}: UseUpdateTransactionMerchantParams): UseUpdateTransactionMerchantResult { const {isOffline} = useNetwork(); const updateMerchant = (merchant: string) => { if (!transactionID) { return; } - updateMoneyRequestMerchant({...updateParams, transactionID, value: merchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, isOffline}); + updateMoneyRequestMerchant({...updateParams, transactionID, transaction, value: merchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, isOffline}); }; return {updateMerchant}; diff --git a/tests/unit/components/ExpenseFieldRow.test.tsx b/tests/unit/components/ExpenseFieldRow.test.tsx index 0c0d8b551927..c82d1936f92e 100644 --- a/tests/unit/components/ExpenseFieldRow.test.tsx +++ b/tests/unit/components/ExpenseFieldRow.test.tsx @@ -295,7 +295,7 @@ describe('ExpenseFieldRow', () => { // When the form renders it let contentCell = screen.getByText(FIELD_NAME).parent; - while (contentCell && contentCell.type !== 'View') { + while (contentCell && String(contentCell.type) !== 'View') { contentCell = contentCell.parent; } From c417d6541845c456ea836c2c3a163a495b88b5d8 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 13 Nov 2026 14:07:16 +0700 Subject: [PATCH 14/14] address comments --- .../AmountInputField.tsx | 4 +++- .../TaxAmountInputField.tsx | 4 +++- .../useSavedAmountInput.ts | 19 +++++++++++++++-- tests/ui/MoneyRequestViewTest.tsx | 6 +++--- tests/ui/components/ReceiptAuditTest.tsx | 4 ++-- tests/unit/hooks/useSavedAmountInputTest.tsx | 21 +++++++++++++++++++ 6 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 tests/unit/hooks/useSavedAmountInputTest.tsx diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx index dac07d2b4d94..96a63017a865 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/AmountInputField.tsx @@ -98,7 +98,7 @@ function AmountInputField({ 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} = useSavedAmountInput({ + const {inputValue, inputError, inputKey, startEditing, applyTypedAmount, saveTypedAmount, inputRef, blurInput} = useSavedAmountInput({ amount, decimals, getAmountError, @@ -138,10 +138,12 @@ function AmountInputField({ allowNegative={allowNegative} > {isCurrencyEditable && ( diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx b/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx index 78521c282982..2ca74d379c6b 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx +++ b/src/components/ReportActionItem/MoneyRequestViewFields/TaxAmountInputField.tsx @@ -52,7 +52,7 @@ function TaxAmountInputField({transaction, updateParams, policy, taxAmount, curr // 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} = useSavedAmountInput({ + const {inputValue, inputError, inputKey, startEditing, applyTypedAmount, saveTypedAmount, inputRef, blurInput} = useSavedAmountInput({ amount: taxAmount, decimals, getAmountError, @@ -69,10 +69,12 @@ function TaxAmountInputField({transaction, updateParams, policy, taxAmount, curr onInputChange={applyTypedAmount} > diff --git a/src/components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput.ts b/src/components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput.ts index 09e582a00e80..da108034e7fc 100644 --- a/src/components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput.ts +++ b/src/components/ReportActionItem/MoneyRequestViewFields/useSavedAmountInput.ts @@ -3,12 +3,16 @@ * 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 {useState} from 'react'; +import type {RefObject} from 'react'; + +import {useRef, useState} from 'react'; type UseSavedAmountInputParams = { /** Saved amount, signed and in the smallest currency unit */ @@ -42,6 +46,12 @@ type UseSavedAmountInputResult = { /** 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 { @@ -103,7 +113,12 @@ function useSavedAmountInput({amount, decimals, getAmountError, onSave}: UseSave } }; - return {inputValue, inputError, inputKey: `${shownSavedValue}-${resetCount}`, startEditing, applyTypedAmount, saveTypedAmount}; + 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/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 9b8417da09dc..dce3246cccd2 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -331,17 +331,17 @@ describe('MoneyRequestView edit fields', () => { }; await setupTestData(true); // Read before the view renders, as a later render would replace the view on the screen - const mutedColor = renderHook(() => useTheme()).result.current.textSupporting; + const standardColor = renderHook(() => useTheme()).result.current.text; // When the expense view renders renderMoneyRequestView(threadReport); await waitForBatchedUpdatesWithAct(); - // Then the merchant reads like any other text rather than muted, since the field's disabled fill already marks it as locked + // 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')).not.toHaveStyle({color: mutedColor}); + 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 () => { diff --git a/tests/ui/components/ReceiptAuditTest.tsx b/tests/ui/components/ReceiptAuditTest.tsx index ea55069e2098..920f2c7daa73 100644 --- a/tests/ui/components/ReceiptAuditTest.tsx +++ b/tests/ui/components/ReceiptAuditTest.tsx @@ -37,7 +37,7 @@ describe('ReceiptAudit', () => { 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 mutedColor = renderHook(() => useTheme()).result.current.textSupporting; + const {text: standardColor, textSupporting: mutedColor} = renderHook(() => useTheme()).result.current; // When the receipt audit is rendered render( @@ -50,7 +50,7 @@ describe('ReceiptAudit', () => { ); // Then "Receipt" reads as a heading like "Details", and only the audit result stays muted - expect(screen.getByText(translateLocal('common.receipt'))).not.toHaveStyle({color: mutedColor}); + 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/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); + }); +});