diff --git a/src/CONST/SENTRY_LABEL.ts b/src/CONST/SENTRY_LABEL.ts index 398101f1397a..acd67d057668 100644 --- a/src/CONST/SENTRY_LABEL.ts +++ b/src/CONST/SENTRY_LABEL.ts @@ -840,6 +840,7 @@ const SENTRY_LABEL = { BILLABLE_FIELD: 'SearchEditMultiple-BillableField', REIMBURSABLE_FIELD: 'SearchEditMultiple-ReimbursableField', ATTENDEES_FIELD: 'SearchEditMultiple-AttendeesField', + BOOLEAN_DROPDOWN_BACK_BUTTON: 'SearchEditMultiple-BooleanDropdownBackButton', SAVE_BUTTON: 'SearchEditMultiple-SaveButton', }, INSIGHTS: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 69849b278d82..94e391fabf9d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1467,12 +1467,6 @@ const ONYXKEYS = { WORKSPACE_PER_DIEM_FORM_DRAFT: 'workspacePerDiemFormDraft', ENABLE_GLOBAL_REIMBURSEMENTS: 'enableGlobalReimbursementsForm', ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT: 'enableGlobalReimbursementsFormDraft', - SEARCH_EDIT_MULTIPLE_DESCRIPTION_FORM: 'searchEditMultipleDescriptionForm', - SEARCH_EDIT_MULTIPLE_DESCRIPTION_FORM_DRAFT: 'searchEditMultipleDescriptionFormDraft', - SEARCH_EDIT_MULTIPLE_MERCHANT_FORM: 'searchEditMultipleMerchantForm', - SEARCH_EDIT_MULTIPLE_MERCHANT_FORM_DRAFT: 'searchEditMultipleMerchantFormDraft', - SEARCH_EDIT_MULTIPLE_DATE_FORM: 'searchEditMultipleDateForm', - SEARCH_EDIT_MULTIPLE_DATE_FORM_DRAFT: 'searchEditMultipleDateFormDraft', CREATE_DOMAIN_FORM: 'createDomainForm', CREATE_DOMAIN_FORM_DRAFT: 'createDomainFormDraft', SPLIT_EXPENSE_EDIT_DATES: 'splitExpenseEditDates', @@ -1660,9 +1654,6 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WISE_BUSINESS_ONBOARDING_FORM]: FormTypes.WiseBusinessOnboardingForm; [ONYXKEYS.FORMS.WORKSPACE_PER_DIEM_FORM]: FormTypes.WorkspacePerDiemForm; [ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS]: FormTypes.EnableGlobalReimbursementsForm; - [ONYXKEYS.FORMS.SEARCH_EDIT_MULTIPLE_DESCRIPTION_FORM]: FormTypes.SearchEditMultipleDescriptionForm; - [ONYXKEYS.FORMS.SEARCH_EDIT_MULTIPLE_MERCHANT_FORM]: FormTypes.SearchEditMultipleMerchantForm; - [ONYXKEYS.FORMS.SEARCH_EDIT_MULTIPLE_DATE_FORM]: FormTypes.SearchEditMultipleDateForm; [ONYXKEYS.FORMS.CREATE_DOMAIN_FORM]: FormTypes.CreateDomainForm; [ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm; [ONYXKEYS.FORMS.EXPENSE_RULE_FORM]: FormTypes.ExpenseRuleForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cea8d54c9930..e489ddeb8a9e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2460,17 +2460,11 @@ const ROUTES = { SEARCH_REJECT_REASON_RHP: 'search/reject', SEARCH_CHANGE_WORKSPACE_RHP: 'search/change-workspace/search', SEARCH_EDIT_MULTIPLE_TRANSACTIONS_RHP: 'search/edit-multiple-transactions', - SEARCH_EDIT_MULTIPLE_AMOUNT_RHP: 'search/edit-multiple/amount', - SEARCH_EDIT_MULTIPLE_DESCRIPTION_RHP: 'search/edit-multiple/description', - SEARCH_EDIT_MULTIPLE_MERCHANT_RHP: 'search/edit-multiple/merchant', - SEARCH_EDIT_MULTIPLE_DATE_RHP: 'search/edit-multiple/date', SEARCH_EDIT_MULTIPLE_CATEGORY_RHP: 'search/edit-multiple/category', SEARCH_EDIT_MULTIPLE_TAG_RHP: { route: 'search/edit-multiple/tag/:tagListIndex', getRoute: (tagListIndex = 0) => `search/edit-multiple/tag/${tagListIndex}` as const, }, - SEARCH_EDIT_MULTIPLE_BILLABLE_RHP: 'search/edit-multiple/billable', - SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP: 'search/edit-multiple/reimbursable', SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP: 'search/edit-multiple/attendees', SEARCH_EDIT_MULTIPLE_TAX_RHP: 'search/edit-multiple/tax', MOVE_TRANSACTIONS_SEARCH_RHP: { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1f67edc29599..61a014a39fbb 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -61,14 +61,8 @@ const SCREENS = { SEARCH_REJECT_REASON_RHP: 'Search_Reject_Reason_RHP', SEARCH_CHANGE_WORKSPACE_RHP: 'Search_Change_Workspace_RHP', EDIT_MULTIPLE_TRANSACTIONS_RHP: 'Search_Edit_Multiple_Transactions_RHP', - EDIT_MULTIPLE_AMOUNT_RHP: 'Search_Edit_Multiple_Amount_RHP', - EDIT_MULTIPLE_DESCRIPTION_RHP: 'Search_Edit_Multiple_Description_RHP', - EDIT_MULTIPLE_MERCHANT_RHP: 'Search_Edit_Multiple_Merchant_RHP', - EDIT_MULTIPLE_DATE_RHP: 'Search_Edit_Multiple_Date_RHP', EDIT_MULTIPLE_CATEGORY_RHP: 'Search_Edit_Multiple_Category_RHP', EDIT_MULTIPLE_TAG_RHP: 'Search_Edit_Multiple_Tag_RHP', - EDIT_MULTIPLE_BILLABLE_RHP: 'Search_Edit_Multiple_Billable_RHP', - EDIT_MULTIPLE_REIMBURSABLE_RHP: 'Search_Edit_Multiple_Reimbursable_RHP', EDIT_MULTIPLE_ATTENDEES_RHP: 'Search_Edit_Multiple_Attendees_RHP', EDIT_MULTIPLE_TAX_RHP: 'Search_Edit_Multiple_Tax_RHP', TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP: 'Search_Transactions_Change_Report_Search', 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/AttendeePicker/AttendeePickerModal.tsx b/src/components/AttendeePicker/AttendeePickerModal.tsx index 20f36bb5646e..2b2cbd2966cc 100644 --- a/src/components/AttendeePicker/AttendeePickerModal.tsx +++ b/src/components/AttendeePicker/AttendeePickerModal.tsx @@ -34,6 +34,9 @@ type AttendeePickerModalProps = { /** Type of the expense, which the list's empty state is worded for */ iouType: IOUType; + + /** Whether the empty-selection error waits for an apply attempt instead of showing as soon as the list opens */ + shouldDeferEmptySelectionError?: boolean; } & Omit; /** @@ -42,7 +45,7 @@ type AttendeePickerModalProps = { * container open, so the user can keep picking. The selection is applied with the Apply button at the bottom, as on * the full page, where the button reads Save. */ -function AttendeePickerModal({attendees, onAttendeesChanged, onApply, iouType, ...popoverProps}: AttendeePickerModalProps) { +function AttendeePickerModal({attendees, onAttendeesChanged, onApply, iouType, shouldDeferEmptySelectionError = false, ...popoverProps}: AttendeePickerModalProps) { const {translate} = useLocalize(); return ( @@ -64,6 +67,7 @@ function AttendeePickerModal({attendees, onAttendeesChanged, onApply, iouType, . searchInputLabel={translate('selectionList.searchForSomeone')} confirmButtonText={translate('common.apply')} confirmButtonSize={CONST.BUTTON_SIZE.MEDIUM} + shouldDeferEmptySelectionError={shouldDeferEmptySelectionError} /> diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 2f825c5ca7a8..b88addeebf9c 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -20,7 +20,7 @@ import { import {convertToBackendAmount, convertToFrontendAmountAsString, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {calculateAmount, isMovingTransactionFromTrackExpense, isP2PRecipient, isParticipantP2P} from '@libs/IOUUtils'; -import {isConfirmationAmountMissing} from '@libs/MoneyRequestUtils'; +import {getBackendAmountFromInput, isConfirmationAmountMissing} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {shouldEnableNegative} from '@libs/ReportUtils'; @@ -189,23 +189,6 @@ function AmountField({ setIsCurrencyPickerVisible(false); }; - const getBackendAmountFromInput = (value: string) => { - const isNegative = value.startsWith('-'); - const cleanAmount = value.replace('-', ''); - - if (!cleanAmount || cleanAmount.trim() === '') { - return null; - } - - const numericAmount = Number.parseFloat(cleanAmount); - if (Number.isNaN(numericAmount)) { - return null; - } - - const absoluteBackendAmount = convertToBackendAmount(numericAmount); - return isNegative ? -absoluteBackendAmount : absoluteBackendAmount; - }; - /** * When the bill total or currency changes: * - Editing an existing split bill: recompute shares on the split draft (`SPLIT_TRANSACTION_DRAFT`). diff --git a/src/components/TaxPicker/TaxPickerModal.tsx b/src/components/TaxPicker/TaxPickerModal.tsx index 403788d3e474..beca6827f15f 100644 --- a/src/components/TaxPicker/TaxPickerModal.tsx +++ b/src/components/TaxPicker/TaxPickerModal.tsx @@ -37,13 +37,16 @@ type TaxPickerModalProps = { /** Called when the user confirms a tax rate. `shouldClearTax` asks to clear the tax instead, for a rate the workspace no longer has */ onSelected?: (tax: TaxRatesOption, shouldClearTax?: boolean) => void; + + /** Whether tapping the rate already picked hands it to `onSelected` again, for a caller that clears it that way, rather than only closing the list */ + allowDeselect?: boolean; } & Omit; /** * The tax rate list in a container anchored to a row: a pop-over on a wide layout, a bottom sheet on a narrow one. * Picking a rate hands it to `onSelected` and closes the container, as the category and tag lists do. */ -function TaxPickerModal({policyID, transactionID, selectedTaxRate, action, iouType, onSelected, onClose, ...popoverProps}: TaxPickerModalProps) { +function TaxPickerModal({policyID, transactionID, selectedTaxRate, action, iouType, onSelected, allowDeselect = false, onClose, ...popoverProps}: TaxPickerModalProps) { const {translate} = useLocalize(); const [taxRatesSummary] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: taxRatesSummarySelector}); @@ -73,8 +76,9 @@ function TaxPickerModal({policyID, transactionID, selectedTaxRate, action, iouTy action={action} iouType={iouType} onSubmit={handleTaxRateSelected} - // Tapping the rate the expense already has leaves it alone, as the full-page selector does, and only closes the list. + // Unless the caller asks otherwise, tapping the rate the expense already has leaves it alone, as the full-page selector does, and only closes the list. onDismiss={onClose} + allowDeselect={allowDeselect} shouldAutoFocusSearchInput /> diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 865478e05009..58344e49dddd 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -51,6 +51,23 @@ function addLeadingZero(amount: string, shouldAllowNegative = false): string { return amount.startsWith('.') ? `0${amount}` : amount; } +/** The backend amount for what was typed in an inline amount field, or null when the field is empty or holds only a sign. */ +function getBackendAmountFromInput(value: string): number | null { + const isNegative = value.startsWith('-'); + const cleanAmount = value.replace('-', '').trim(); + if (!cleanAmount) { + return null; + } + + const numericAmount = Number.parseFloat(cleanAmount); + if (Number.isNaN(numericAmount)) { + return null; + } + + const absoluteBackendAmount = convertToBackendAmount(numericAmount); + return isNegative ? -absoluteBackendAmount : absoluteBackendAmount; +} + /** * Check if amount is a decimal up to 3 digits */ @@ -290,6 +307,7 @@ function isConfirmationDateMissing( } export { + getBackendAmountFromInput, addLeadingZero, isConfirmationAmountMissing, isConfirmationDateMissing, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 85cd953ca2cb..344b201991d4 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1509,14 +1509,8 @@ const SearchReportActionsModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchAddApproverPage').default, [SCREENS.SEARCH.CHANGE_APPROVER.REASSIGN_APPROVER]: () => require('../../../../pages/Search/SearchReassignApproverPage').default, [SCREENS.SEARCH.EDIT_MULTIPLE_TRANSACTIONS_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultiplePage').default, - [SCREENS.SEARCH.EDIT_MULTIPLE_AMOUNT_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage').default, - [SCREENS.SEARCH.EDIT_MULTIPLE_DESCRIPTION_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage').default, - [SCREENS.SEARCH.EDIT_MULTIPLE_MERCHANT_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage').default, - [SCREENS.SEARCH.EDIT_MULTIPLE_DATE_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleDatePage').default, [SCREENS.SEARCH.EDIT_MULTIPLE_CATEGORY_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage').default, [SCREENS.SEARCH.EDIT_MULTIPLE_TAG_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleTagPage').default, - [SCREENS.SEARCH.EDIT_MULTIPLE_BILLABLE_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage').default, - [SCREENS.SEARCH.EDIT_MULTIPLE_REIMBURSABLE_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage').default, [SCREENS.SEARCH.EDIT_MULTIPLE_TAX_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage').default, [SCREENS.SEARCH.EDIT_MULTIPLE_ATTENDEES_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesPage').default, }); diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index c6b8d03634e2..cba9e9a9b786 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -2154,14 +2154,8 @@ const config: LinkingOptions['config'] = { [SCREENS.SEARCH.SEARCH_REJECT_REASON_RHP]: ROUTES.SEARCH_REJECT_REASON_RHP, [SCREENS.SEARCH.SEARCH_CHANGE_WORKSPACE_RHP]: ROUTES.SEARCH_CHANGE_WORKSPACE_RHP, [SCREENS.SEARCH.EDIT_MULTIPLE_TRANSACTIONS_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_TRANSACTIONS_RHP, - [SCREENS.SEARCH.EDIT_MULTIPLE_AMOUNT_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_AMOUNT_RHP, - [SCREENS.SEARCH.EDIT_MULTIPLE_DESCRIPTION_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_DESCRIPTION_RHP, - [SCREENS.SEARCH.EDIT_MULTIPLE_MERCHANT_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_MERCHANT_RHP, - [SCREENS.SEARCH.EDIT_MULTIPLE_DATE_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_DATE_RHP, [SCREENS.SEARCH.EDIT_MULTIPLE_CATEGORY_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_CATEGORY_RHP, [SCREENS.SEARCH.EDIT_MULTIPLE_TAG_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_TAG_RHP.route, - [SCREENS.SEARCH.EDIT_MULTIPLE_BILLABLE_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_BILLABLE_RHP, - [SCREENS.SEARCH.EDIT_MULTIPLE_REIMBURSABLE_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP, [SCREENS.SEARCH.EDIT_MULTIPLE_ATTENDEES_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP, [SCREENS.SEARCH.EDIT_MULTIPLE_TAX_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: { diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx deleted file mode 100644 index cf2d88f33ede..000000000000 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import {useSearchResultsContext} from '@components/Search/SearchContext'; -import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputFocused'; -import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; - -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; - -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; -import {convertToBackendAmount} from '@libs/CurrencyUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import {isInvoiceReport, isIOUReport, shouldEnableNegative} from '@libs/ReportUtils'; -import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils'; - -import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; -import IOURequestStepCurrencyModal from '@pages/iou/request/step/IOURequestStepCurrencyModal'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import {useFocusEffect} from '@react-navigation/native'; -import React, {useRef, useState} from 'react'; - -import {withSnapshotReports, withSnapshotTransactions} from './SearchEditMultipleUtils'; - -type CurrentMoney = {amount: string; currency: string}; - -function SearchEditMultipleAmountPage() { - const {translate} = useLocalize(); - const {currentSearchResults} = useSearchResultsContext(); - const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - - const snapshotData = currentSearchResults?.data; - const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData); - const mergedReports = withSnapshotReports(allReports, snapshotData); - - const textInput = useRef(null); - const focusTimeoutRef = useRef(null); - - const selectedTransactionIDs = draftTransaction?.selectedTransactionIDs ?? []; - - const policyID = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, mergedTransactions, mergedReports); - const policy = policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; - const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; - - const initialCurrency = draftTransaction?.currency ?? policyCurrency; - const [selectedCurrency, setSelectedCurrency] = useState(initialCurrency); - const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); - - useFocusEffect(() => { - if (isCurrencyPickerVisible) { - return; - } - focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION + 100); - return () => { - if (!focusTimeoutRef.current) { - return; - } - clearTimeout(focusTimeoutRef.current); - }; - }); - - const amount = draftTransaction?.amount ?? 0; - let allowNegative; - if (!selectedTransactionIDs.length) { - allowNegative = false; - } else { - allowNegative = selectedTransactionIDs.every((transactionID) => { - const transaction = mergedTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - if (!transaction) { - return false; - } - - // Self-DM track expenses have no IOU report — they always support negative amounts - if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { - return true; - } - const transactionReport = mergedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; - const iouReport = - transactionReport?.type === CONST.REPORT.TYPE.CHAT && transactionReport?.parentReportID - ? mergedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionReport.parentReportID}`] - : transactionReport; - if (!iouReport) { - return false; - } - const transactionPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`]; - const iouType = isInvoiceReport(iouReport) ? CONST.IOU.TYPE.INVOICE : CONST.IOU.TYPE.SUBMIT; - return shouldEnableNegative(iouReport, transactionPolicy, iouType); - }); - } - const isP2P = selectedTransactionIDs.some((transactionID) => { - const transaction = mergedTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - if (!transaction) { - return false; - } - const report = mergedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; - return isIOUReport(report); - }); - - const isAnyInvoice = selectedTransactionIDs.some((transactionID) => { - const transaction = mergedTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; - if (!transaction) { - return false; - } - const transactionReport = mergedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; - const iouReport = - transactionReport?.type === CONST.REPORT.TYPE.CHAT && transactionReport?.parentReportID - ? mergedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionReport.parentReportID}`] - : transactionReport; - return isInvoiceReport(iouReport); - }); - - const amountForForm = allowNegative ? amount : Math.abs(amount); - - const saveAmount = (currentMoney: CurrentMoney) => { - const newAmount = convertToBackendAmount(Number.parseFloat(currentMoney.amount)); - // TODO: Currency update should be handled in a separate PR - updateBulkEditDraftTransaction({ - amount: newAmount, - }); - Navigation.goBack(); - }; - - const showCurrencyPicker = () => { - if (isTextInputFocused(textInput)) { - textInput.current?.blur(); - } - setIsCurrencyPickerVisible(true); - }; - - return ( - - - setIsCurrencyPickerVisible(false)} - headerText={translate('common.selectCurrency')} - value={selectedCurrency} - onInputChange={(value) => setSelectedCurrency(value)} - /> - { - textInput.current = e; - }} - onCurrencyButtonPress={showCurrencyPicker} - onSubmitButtonPress={saveAmount} - allowFlippingAmount={allowNegative} - /> - - ); -} - -export default SearchEditMultipleAmountPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeePills.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeePills.tsx new file mode 100644 index 000000000000..273c0385c134 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeePills.tsx @@ -0,0 +1,43 @@ +/** + * The attendees the bulk edit draft holds, as the avatar pills the expense form's attendees row shows, so the + * attendees row on Edit multiple matches every other attendees row in the new field style. + */ +import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import UserPills from '@components/UserPills'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import {enrichAndSortAttendees} from '@libs/AttendeeUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import React from 'react'; + +type SearchEditMultipleAttendeePillsProps = { + /** Attendees the bulk edit draft holds */ + attendees: Attendee[]; +}; + +function SearchEditMultipleAttendeePills({attendees}: SearchEditMultipleAttendeePillsProps) { + const {localeCompare} = useLocalize(); + const personalDetailsList = usePersonalDetails(); + const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); + + // The draft only stores what was picked, so names and avatars are filled in from the live personal details. + const enrichedAttendees = enrichAndSortAttendees(attendees, loginToAccountIDMap, personalDetailsList, localeCompare) ?? []; + + return ( + ({ + avatar: attendee.avatarUrl, + displayName: attendee.displayName ?? attendee.email ?? '', + accountID: attendee.accountID, + email: attendee.email, + }))} + /> + ); +} + +export default SearchEditMultipleAttendeePills; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesDropdown.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesDropdown.tsx new file mode 100644 index 000000000000..0583e340a8f7 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesDropdown.tsx @@ -0,0 +1,60 @@ +import AttendeePickerModal from '@components/AttendeePicker/AttendeePickerModal'; +import type {ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; + +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; + +import CONST from '@src/CONST'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import {deepEqual} from 'fast-equals'; +import React, {useState} from 'react'; + +type SearchEditMultipleAttendeesDropdownProps = ExpenseFieldDropdownRenderProps & { + /** Attendees the draft already holds when the list opens */ + draftAttendees: Attendee[]; +}; + +/** + * The attendee list, in the container anchored to the bulk edit 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. + */ +function SearchEditMultipleAttendeesDropdown({draftAttendees, isVisible, onClose, ...popoverProps}: SearchEditMultipleAttendeesDropdownProps) { + // 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(); + const [wasVisible, setWasVisible] = useState(isVisible); + if (wasVisible !== isVisible) { + setWasVisible(isVisible); + setPickedAttendees(undefined); + } + const attendees = pickedAttendees ?? draftAttendees; + + const applyAttendees = () => { + // The list shows an error and disables Apply for an empty selection, so this only guards the Enter key. + if (attendees.length === 0) { + return; + } + if (!deepEqual(draftAttendees, attendees)) { + updateBulkEditDraftTransaction({comment: {attendees}}); + } + onClose(); + }; + + return ( + + ); +} + +export default SearchEditMultipleAttendeesDropdown; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanDropdown.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanDropdown.tsx new file mode 100644 index 000000000000..bbeed38798c2 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanDropdown.tsx @@ -0,0 +1,73 @@ +import AnchoredSelectionListPopover from '@components/AnchoredSelectionListPopover'; +import type {ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; + +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type BooleanOption = ListItem & { + /** The value picking this option records for every expense */ + value: boolean; +}; + +type SearchEditMultipleBooleanDropdownProps = ExpenseFieldDropdownRenderProps & { + /** Name of the field, which heads the bottom sheet */ + title: string; + + /** Value the draft records for every expense, `undefined` or `null` while each keeps its own */ + selectedValue: boolean | null | undefined; + + /** Records the pick on the draft. `null` drops the pending change, so each expense keeps its own value. */ + onSelected: (value: boolean | null) => void; +}; + +/** + * A yes/no field of the bulk edit form, in the container anchored to its row. Nothing is picked until the user chooses + * a value, which leaves each expense as it is. Tapping the picked value again unpicks it, as the full-page list did. + */ +function SearchEditMultipleBooleanDropdown({title, selectedValue, onSelected, onClose, ...popoverProps}: SearchEditMultipleBooleanDropdownProps) { + const {translate} = useLocalize(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- must match PopoverWithMeasuredContent's dock decision (bottom-docked only when isSmallScreenWidth) + const {isSmallScreenWidth} = useResponsiveLayout(); + + const options: BooleanOption[] = [ + {value: true, keyForList: CONST.SEARCH.BOOLEAN.YES, text: translate('common.yes'), isSelected: selectedValue === true}, + {value: false, keyForList: CONST.SEARCH.BOOLEAN.NO, text: translate('common.no'), isSelected: selectedValue === false}, + ]; + + const selectOption = (option: BooleanOption) => { + onSelected(option.value === selectedValue ? null : option.value); + onClose(); + }; + + return ( + + option.isSelected)?.keyForList} + shouldSingleExecuteRowSelect + /> + + ); +} + +export default SearchEditMultipleBooleanDropdown; +export type {BooleanOption}; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx deleted file mode 100644 index 16096292555e..000000000000 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; -import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import type {ListItem} from '@components/SelectionList/ListItem/types'; - -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; -import Navigation from '@libs/Navigation/Navigation'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; - -import {useRoute} from '@react-navigation/native'; -import React, {useState} from 'react'; -import {View} from 'react-native'; - -type BooleanOption = ListItem & { - value: boolean; -}; - -function SearchEditMultipleBooleanPage() { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const route = useRoute(); - const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); - - const isBillableScreen = route.name === SCREENS.SEARCH.EDIT_MULTIPLE_BILLABLE_RHP; - const persistedValue = isBillableScreen ? draftTransaction?.billable : draftTransaction?.reimbursable; - const title = isBillableScreen ? translate('common.billable') : translate('common.reimbursable'); - const testID = isBillableScreen ? 'SearchEditMultipleBillablePage' : 'SearchEditMultipleReimbursablePage'; - - const [draftValue, setDraftValue] = useState(); - const selectedValue = draftValue === undefined ? persistedValue : draftValue; - - const items = [ - { - value: true, - keyForList: CONST.SEARCH.BOOLEAN.YES, - text: translate('common.yes'), - isSelected: selectedValue === true, - }, - { - value: false, - keyForList: CONST.SEARCH.BOOLEAN.NO, - text: translate('common.no'), - isSelected: selectedValue === false, - }, - ]; - - const selectValue = (item: BooleanOption) => { - setDraftValue((prev) => { - const current = prev === undefined ? persistedValue : prev; - return current === item.value ? null : item.value; - }); - }; - - const saveAndGoBack = () => { - if (isBillableScreen) { - updateBulkEditDraftTransaction({billable: selectedValue ?? null}); - } else { - updateBulkEditDraftTransaction({reimbursable: selectedValue ?? null}); - } - Navigation.goBack(); - }; - - const confirmButtonOptions = { - showButton: true, - text: translate('common.save'), - onConfirm: saveAndGoBack, - isDisabled: (selectedValue ?? null) === (persistedValue ?? null), - }; - - return ( - - - - - - - ); -} - -export default SearchEditMultipleBooleanPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryDropdown.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryDropdown.tsx new file mode 100644 index 000000000000..e3d7819c25e5 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryDropdown.tsx @@ -0,0 +1,41 @@ +import CategoryPickerModal from '@components/CategoryPicker/CategoryPickerModal'; +import type {ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import type {ListItem} from '@components/SelectionList/types'; + +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; + +import React from 'react'; + +import {getBulkEditCategoryChange} from './SearchEditMultipleUtils'; + +type SearchEditMultipleCategoryDropdownProps = ExpenseFieldDropdownRenderProps & { + /** Workspace the categories belong to */ + policyID: string | undefined; + + /** Category the draft already holds, so the list can mark it */ + selectedCategory: string; +}; + +/** + * The category list, in the container anchored to the bulk edit form's category row. Mounted only once the row is + * opened, and records the pick on the draft the same way the full-page selector does. + */ +function SearchEditMultipleCategoryDropdown({policyID, selectedCategory, onClose, ...popoverProps}: SearchEditMultipleCategoryDropdownProps) { + const saveCategory = (item: ListItem) => { + // The list hands back an empty item when the picked category is tapped again, which drops the pending change. + updateBulkEditDraftTransaction(getBulkEditCategoryChange(item.searchText ?? '', selectedCategory)); + }; + + return ( + + ); +} + +export default SearchEditMultipleCategoryDropdown; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx index 1e62f4ec38f5..689d14210b8a 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx @@ -16,6 +16,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; +import {getBulkEditCategoryChange} from './SearchEditMultipleUtils'; + function SearchEditMultipleCategoryPage() { const {translate} = useLocalize(); const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); @@ -26,17 +28,7 @@ function SearchEditMultipleCategoryPage() { const currentCategory = draftTransaction?.category ?? ''; const saveCategory = (item: ListItem) => { - const nextCategory = item.searchText ?? ''; - if (!nextCategory || nextCategory === currentCategory) { - updateBulkEditDraftTransaction({ - category: null, - }); - Navigation.goBack(); - return; - } - updateBulkEditDraftTransaction({ - category: nextCategory, - }); + updateBulkEditDraftTransaction(getBulkEditCategoryChange(item.searchText ?? '', currentCategory)); Navigation.goBack(); }; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx deleted file mode 100644 index c6aad542b4aa..000000000000 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; - -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; -import Navigation from '@libs/Navigation/Navigation'; -import {isValidDate} from '@libs/ValidationUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/SearchEditMultipleDateForm'; - -import React from 'react'; -import {View} from 'react-native'; - -function SearchEditMultipleDatePage() { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); - - const currentDate = draftTransaction?.created ?? ''; - - const validate = (value: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - const dateValue = value.date; - if (dateValue && !isValidDate(dateValue)) { - errors.date = translate('common.error.dateInvalid'); - } - return errors; - }; - - const saveDate = (value: FormOnyxValues) => { - const newDate = value.date; - updateBulkEditDraftTransaction({ - created: newDate, - }); - Navigation.goBack(); - }; - - return ( - - - - - - - - - ); -} - -export default SearchEditMultipleDatePage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx deleted file mode 100644 index 09124a5fb347..000000000000 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import TextInput from '@components/TextInput'; - -import useAutoFocusInput from '@hooks/useAutoFocusInput'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; -import {addErrorMessage} from '@libs/ErrorUtils'; -import Navigation from '@libs/Navigation/Navigation'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/SearchEditMultipleDescriptionForm'; - -import React from 'react'; -import {View} from 'react-native'; - -function SearchEditMultipleDescriptionPage() { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const {inputCallbackRef} = useAutoFocusInput(); - const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); - - const currentDescription = draftTransaction?.comment?.comment ?? ''; - - const validate = (value: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - - if ((value.description?.length ?? 0) > CONST.DESCRIPTION_LIMIT) { - addErrorMessage(errors, INPUT_IDS.DESCRIPTION, translate('common.error.characterLimitExceedCounter', value.description?.length ?? 0, CONST.DESCRIPTION_LIMIT)); - } - - return errors; - }; - - const saveDescription = (value: FormOnyxValues) => { - const newDescription = value.description?.trim() ?? ''; - updateBulkEditDraftTransaction({ - comment: {comment: newDescription}, - }); - Navigation.goBack(); - }; - - return ( - - - - - - - - - ); -} - -export default SearchEditMultipleDescriptionPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx deleted file mode 100644 index 5815452bc2a3..000000000000 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import TextInput from '@components/TextInput'; - -import useAutoFocusInput from '@hooks/useAutoFocusInput'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; -import Navigation from '@libs/Navigation/Navigation'; -import {isInvalidMerchantValue, isValidInputLength} from '@libs/ValidationUtils'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/SearchEditMultipleMerchantForm'; - -import React from 'react'; -import {View} from 'react-native'; - -function SearchEditMultipleMerchantPage() { - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const {inputCallbackRef} = useAutoFocusInput(); - const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); - - const currentMerchant = draftTransaction?.merchant ?? ''; - - const validate = (value: FormOnyxValues): FormInputErrors => { - const errors: FormInputErrors = {}; - const {isValid, byteLength} = isValidInputLength(value.merchant ?? '', CONST.MERCHANT_NAME_MAX_BYTES); - - const trimmedMerchant = (value.merchant ?? '').trim(); - if (trimmedMerchant && isInvalidMerchantValue(trimmedMerchant)) { - errors.merchant = translate('iou.error.invalidMerchant'); - } else if (!isValid) { - errors.merchant = translate('common.error.characterLimitExceedCounter', byteLength, CONST.MERCHANT_NAME_MAX_BYTES); - } - - return errors; - }; - - const saveMerchant = (value: FormOnyxValues) => { - const newMerchant = value.merchant?.trim() ?? ''; - updateBulkEditDraftTransaction({ - merchant: newMerchant, - }); - Navigation.goBack(); - }; - - return ( - - - - - - - - - ); -} - -export default SearchEditMultipleMerchantPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx index 34221d61ebc0..e5b4f325087e 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -1,12 +1,16 @@ +import AmountFormField from '@components/AmountFormField'; import Button from '@components/Button'; +import DatePicker from '@components/DatePicker'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemFieldHTML from '@components/MenuItem/presets/MenuItemFieldHTML'; import {ModalActions} from '@components/Modal/Global/ModalContext'; +import ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import ExpenseFieldRow from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldRow'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext'; +import {SELECTION_LIST_POPOVER_HEADER_HEIGHT} from '@components/SelectionListPopoverHeader'; import Text from '@components/Text'; +import TextInput from '@components/TextInput'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -22,10 +26,11 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import type {StartWithLoading} from '@hooks/usePressLoading'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU/BulkEdit'; +import {clearBulkEditDraftTransaction, updateBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU/BulkEdit'; +import {convertToFrontendAmountAsString, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; +import {getBackendAmountFromInput} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; -import Parser from '@libs/Parser'; import {getCleanedTagName, getTagLists, hasDependentTags as hasDependentTagsPolicyUtils} from '@libs/PolicyUtils'; import {canEditFieldOfMoneyRequest, isInvoiceReport, isIOUReport, isReportApproved, isSettled} from '@libs/ReportUtils'; import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils'; @@ -42,6 +47,11 @@ import { isPerDiemRequest, isTimeRequest, } from '@libs/TransactionUtils'; +import {isInvalidMerchantValue, isValidDate, isValidInputLength} from '@libs/ValidationUtils'; + +import IOURequestStepCurrencyModal from '@pages/iou/request/step/IOURequestStepCurrencyModal'; + +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -51,22 +61,33 @@ import type {TransactionChanges} from '@src/types/onyx/Transaction'; import type {GestureResponderEvent} from 'react-native'; import type {ValueOf} from 'type-fest'; -import React, {useEffect, useState} from 'react'; +import noop from 'lodash/noop'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +import SearchEditMultipleAttendeePills from './SearchEditMultipleAttendeePills'; +import SearchEditMultipleAttendeesDropdown from './SearchEditMultipleAttendeesDropdown'; +import SearchEditMultipleBooleanDropdown from './SearchEditMultipleBooleanDropdown'; +import SearchEditMultipleCategoryDropdown from './SearchEditMultipleCategoryDropdown'; +import SearchEditMultipleTagDropdown from './SearchEditMultipleTagDropdown'; +import SearchEditMultipleTaxDropdown from './SearchEditMultipleTaxDropdown'; import { areAllTransactionsExpenseCompatible, getTransactionEditContext, hasCustomUnitMerchantInSelection, isBulkEditAttendeeTrackingEnabled, + isBulkEditNegativeAmountAllowed, isBulkEditTaxTrackingEnabled, withSnapshotReportActions, withSnapshotReports, withSnapshotTransactions, } from './SearchEditMultipleUtils'; +/** The date input has no form around it, so this only names the input to the picker */ +const DATE_INPUT_ID = 'created'; + function SearchEditMultiplePage() { - const {translate, localeCompare, formatPhoneNumber} = useLocalize(); + const {translate, localeCompare, formatPhoneNumber, preferredLocale} = useLocalize(); const {isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); const {convertToDisplayStringWithoutCurrency, getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -194,6 +215,102 @@ function SearchEditMultiplePage() { }, []); const [isSaving, setIsSaving] = useState(false); + const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); + const currencyButtonRef = useRef(null); + + // The draft only holds a currency once one is picked; until then the amount is read in the workspace's currency. + const currency = draftTransaction?.currency ?? policy?.outputCurrency ?? CONST.CURRENCY.USD; + const decimals = getCurrencyDecimals(currency); + const draftAmount = draftTransaction?.amount; + const draftMerchant = draftTransaction?.merchant ?? ''; + const description = draftTransaction?.comment?.comment ?? ''; + const draftDate = draftTransaction?.created ?? ''; + const draftAttendees = draftTransaction?.comment?.attendees ?? []; + const hasDraftAttendees = draftAttendees.length > 0; + + // Mirror the draft merchant in local state so the input updates synchronously as the user types; feeding the async + // Onyx value straight to `value` snaps the caret to the end on every keystroke. The draft only changes from this + // input, so it is synced back only while the input is not being edited. + const [isMerchantInputFocused, setIsMerchantInputFocused] = useState(false); + const [merchantInput, setMerchantInput] = useState(draftMerchant); + const [prevDraftMerchant, setPrevDraftMerchant] = useState(draftMerchant); + if (draftMerchant !== prevDraftMerchant) { + setPrevDraftMerchant(draftMerchant); + if (!isMerchantInputFocused) { + setMerchantInput(draftMerchant); + } + } + + // A P2P expense can't be set to zero, as its own amount page rules. + const hasP2PTransaction = selectedTransactionContexts.some(({report}) => isIOUReport(report)); + const allowNegative = isBulkEditNegativeAmountAllowed(selectedTransactionContexts, mergedReports, policies); + const amountErrorText = hasP2PTransaction && draftAmount === 0 ? translate('common.error.invalidAmount') : ''; + + const merchantErrorText = (() => { + const trimmedMerchant = merchantInput.trim(); + if (trimmedMerchant && isInvalidMerchantValue(trimmedMerchant)) { + return translate('iou.error.invalidMerchant'); + } + const {isValid, byteLength} = isValidInputLength(merchantInput, CONST.MERCHANT_NAME_MAX_BYTES); + return isValid ? '' : translate('common.error.characterLimitExceedCounter', byteLength, CONST.MERCHANT_NAME_MAX_BYTES); + })(); + + const descriptionErrorText = description.length > CONST.DESCRIPTION_LIMIT ? translate('common.error.characterLimitExceedCounter', description.length, CONST.DESCRIPTION_LIMIT) : ''; + + const dateErrorText = draftDate && !isValidDate(draftDate) ? translate('common.error.dateInvalid') : ''; + + const hasFieldError = !!amountErrorText || !!merchantErrorText || !!descriptionErrorText || !!dateErrorText; + + // An emptied field drops the pending change rather than recording an empty value. + const saveDraftAmount = (newAmount: string) => { + updateBulkEditDraftTransaction({amount: getBackendAmountFromInput(newAmount)}); + }; + + const showCurrencyPicker = () => { + setIsCurrencyPickerVisible(true); + }; + + const hideCurrencyPicker = () => { + setIsCurrencyPickerVisible(false); + }; + + const saveDraftCurrency = (newCurrency: string) => { + hideCurrencyPicker(); + updateBulkEditDraftTransaction({currency: newCurrency}); + }; + + // `null` means the picked value was tapped again, which drops the pending change so each expense keeps its own value. + const saveDraftReimbursable = (value: boolean | null) => { + updateBulkEditDraftTransaction({reimbursable: value}); + }; + + const saveDraftBillable = (value: boolean | null) => { + updateBulkEditDraftTransaction({billable: value}); + }; + + // An empty row means nothing is pending, as on the other fields. Once a value is picked the row shows it. + const getPendingBooleanTitle = (value: boolean | null | undefined) => { + if (value === true) { + return translate('common.yes'); + } + if (value === false) { + return translate('common.no'); + } + return ''; + }; + + const saveDraftMerchant = (newMerchant: string) => { + setMerchantInput(newMerchant); + updateBulkEditDraftTransaction({merchant: newMerchant}); + }; + + const saveDraftDescription = (newDescription: string) => { + updateBulkEditDraftTransaction({comment: {comment: newDescription}}); + }; + + const saveDraftDate = (newDate: string) => { + updateBulkEditDraftTransaction({created: newDate || null}); + }; const commit = (changes: TransactionChanges, startWithLoading?: StartWithLoading) => { setIsSaving(true); @@ -239,19 +356,25 @@ function SearchEditMultiplePage() { }; const save = (event?: GestureResponderEvent | KeyboardEvent, startWithLoading?: StartWithLoading) => { - if (!draftTransaction || isSaving) { + if (!draftTransaction || isSaving || hasFieldError) { return; } const changes: TransactionChanges = {}; + const trimmedMerchant = draftTransaction.merchant?.trim(); + const trimmedDescription = draftTransaction.comment?.comment?.trim(); if (draftTransaction.amount !== undefined) { changes.amount = draftTransaction.amount; } - if (draftTransaction.merchant) { - changes.merchant = draftTransaction.merchant; + // Each expense keeps its own currency unless one is sent, so an amount goes with the currency the row shows. + if (draftTransaction.amount !== undefined || draftTransaction.currency) { + changes.currency = currency; + } + if (trimmedMerchant) { + changes.merchant = trimmedMerchant; } - if (draftTransaction.comment?.comment) { - changes.comment = draftTransaction.comment.comment; + if (trimmedDescription) { + changes.comment = trimmedDescription; } if (draftTransaction.created) { changes.created = draftTransaction.created; @@ -298,24 +421,20 @@ function SearchEditMultiplePage() { commit(changes, startWithLoading); }; - const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; - - // TODO: Currency editing and currency symbol should be handled in a separate PR const tagsArray = getTagArrayFromName(draftTransaction?.tag ?? ''); const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTags); const shouldShowTagList = hasDependentTags ? getDependentTagVisibility(policyTagLists, draftTransaction?.tag) : []; - const getBooleanTitle = (value?: boolean) => { - if (value === undefined) { - return ''; - } - return value ? translate('common.yes') : translate('common.no'); - }; - const isAmountDisabled = hasCustomUnitTransaction || hasPartiallyEditableTransaction || hasSplitTransaction; const isTaxDisabled = hasPartiallyEditableTaxRateTransaction || hasSplitTransaction; const isBillableDisabled = isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.BILLABLE); const isAttendeesDisabled = isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.ATTENDEES); - const description = draftTransaction?.comment?.comment; + + // The selectable rows open their list in a container anchored to the row. A dependent tag level needs the levels + // above it, which only the full page walks through, and a selection without a workspace has no lists to show. + const canOpenListInDropdown = !!policyID; + const selectedTaxRate = draftTransaction?.taxCode ? (getTaxName(translate, policy, draftTransaction) ?? '') : ''; + const taxRateName = policy?.taxRates?.name ?? translate('common.tax'); + const lockedAmountTitle = draftAmount !== undefined ? convertToDisplayStringWithoutCurrency(draftAmount, currency) : ''; return ( + - {translate('search.bulkActions.editMultipleDescription')} - Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_AMOUNT_RHP)} - isDisabled={isAmountDisabled} - /> - Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_DESCRIPTION_RHP)} - /> - Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_MERCHANT_RHP)} - isDisabled={hasPartiallyEditableMerchantTransaction} - /> - Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_DATE_RHP)} - isDisabled={hasPartiallyEditableDateTransaction} - /> + {translate('search.bulkActions.editMultipleDescription')} + {isAmountDisabled ? ( + + ) : ( + + + + + + + + + )} + + + + {hasPartiallyEditableMerchantTransaction ? ( + + ) : ( + + setIsMerchantInputFocused(true)} + onBlur={() => setIsMerchantInputFocused(false)} + label={translate('common.merchant')} + accessibilityLabel={translate('common.merchant')} + errorText={merchantErrorText} + sentryLabel={CONST.SENTRY_LABEL.SEARCH_EDIT_MULTIPLE.MERCHANT_FIELD} + /> + + )} + {hasPartiallyEditableDateTransaction ? ( + + ) : ( + + + + )} {areCategoriesEnabled && ( - { startOpenCategoryPickerSpan(); Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_CATEGORY_RHP); }} + shouldOpenInDropdown={canOpenListInDropdown} + renderDropdown={(dropdownProps) => ( + + )} + sentryLabel={CONST.SENTRY_LABEL.SEARCH_EDIT_MULTIPLE.CATEGORY_FIELD} /> )} {areTagsEnabled && @@ -374,49 +569,105 @@ function SearchEditMultiplePage() { } const tagName = tagsArray.at(tagListIndex) ?? ''; return ( - Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_TAG_RHP.getRoute(tagListIndex))} + shouldOpenInDropdown={canOpenListInDropdown && !hasDependentTags} + listHeaderHeight={SELECTION_LIST_POPOVER_HEADER_HEIGHT} + renderDropdown={(dropdownProps) => ( + + )} + sentryLabel={CONST.SENTRY_LABEL.SEARCH_EDIT_MULTIPLE.TAG_FIELD} /> ); })} {isTaxTrackingEnabled && ( - Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP)} + shouldOpenInDropdown={canOpenListInDropdown && !isTaxDisabled} + listHeaderHeight={SELECTION_LIST_POPOVER_HEADER_HEIGHT} + renderDropdown={(dropdownProps) => ( + + )} + isInteractive={!isTaxDisabled} sentryLabel={CONST.SENTRY_LABEL.SEARCH_EDIT_MULTIPLE.TAX_FIELD} - name={policy?.taxRates?.name ?? translate('common.tax')} - value={draftTransaction?.taxCode ? getTaxName(translate, policy, draftTransaction) : ''} - onPress={isTaxDisabled ? undefined : () => Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP)} - isDisabled={isTaxDisabled} - /> - )} - {areSelectedTransactionsBillable && ( - Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_BILLABLE_RHP)} - isDisabled={isBillableDisabled} /> )} {areSelectedTransactionsReimbursable && ( - Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP)} - isDisabled={hasPartiallyEditableReimbursableTransaction} + value={getPendingBooleanTitle(draftTransaction?.reimbursable)} + // The list needs no workspace, so it always opens in place and there is no page to fall back to + onPress={noop} + shouldOpenInDropdown={!hasPartiallyEditableReimbursableTransaction} + renderDropdown={(dropdownProps) => ( + + )} + isInteractive={!hasPartiallyEditableReimbursableTransaction} + sentryLabel={CONST.SENTRY_LABEL.SEARCH_EDIT_MULTIPLE.REIMBURSABLE_FIELD} + /> + )} + {areSelectedTransactionsBillable && ( + ( + + )} + isInteractive={!isBillableDisabled} + sentryLabel={CONST.SENTRY_LABEL.SEARCH_EDIT_MULTIPLE.BILLABLE_FIELD} /> )} {areAttendeesEnabled && ( - Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP)} - isDisabled={isAttendeesDisabled} + valueComponent={hasDraftAttendees ? : undefined} + hasValueComponent={hasDraftAttendees} + accessibilityLabel={ + hasDraftAttendees ? `${translate('iou.attendees')}, ${getAttendeesListDisplayString(draftAttendees, formatPhoneNumber, localeCompare)}` : undefined + } + onPress={() => Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP)} + shouldOpenInDropdown={canOpenListInDropdown && !isAttendeesDisabled} + listHeaderHeight={SELECTION_LIST_POPOVER_HEADER_HEIGHT} + renderDropdown={(dropdownProps) => ( + + )} + isInteractive={!isAttendeesDisabled} + sentryLabel={CONST.SENTRY_LABEL.SEARCH_EDIT_MULTIPLE.ATTENDEES_FIELD} /> )} diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagDropdown.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagDropdown.tsx new file mode 100644 index 000000000000..f224ed423525 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagDropdown.tsx @@ -0,0 +1,80 @@ +import type {ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import TagPickerModal from '@components/TagPicker/TagPickerModal'; + +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; +import {getTagArrayFromName} from '@libs/TransactionUtils'; + +import type {PolicyTagLists} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; + +import {getBulkEditTagChange} from './SearchEditMultipleUtils'; + +type SearchEditMultipleTagDropdownProps = ExpenseFieldDropdownRenderProps & { + /** Workspace the tags belong to */ + policyID: string | undefined; + + /** Index of the tag list this row picks from */ + tagListIndex: number; + + /** The whole multi-level tag the draft shows */ + transactionTag: string; + + /** The per-level tag changes already recorded on the draft, which drive the save */ + recordedTagChanges: Record; + + policyTags: OnyxEntry; + hasMultipleTagLists: boolean; +}; + +/** + * One tag list, in the container anchored to its row on the bulk edit form. Mounted only once the row is opened, and + * records the pick on the draft the same way the full-page selector does. Dependent tags keep the full page, so the + * draft's tag is read as-is here. + */ +function SearchEditMultipleTagDropdown({ + policyID, + tagListIndex, + transactionTag, + recordedTagChanges, + policyTags, + hasMultipleTagLists, + onClose, + ...popoverProps +}: SearchEditMultipleTagDropdownProps) { + const currentTag = getTagArrayFromName(transactionTag).at(tagListIndex) ?? ''; + + const saveTag = (selectedTagName: string) => { + updateBulkEditDraftTransaction( + getBulkEditTagChange({ + // The list hands back an empty name when the picked tag is tapped again, which the helper reads as a clear. + selectedTagName: selectedTagName || currentTag, + currentTag, + tagListIndex, + transactionTag, + recordedTagChanges, + policyTags, + hasDependentTags: false, + hasMultipleTagLists, + }), + ); + }; + + return ( + + ); +} + +export default SearchEditMultipleTagDropdown; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx index 87ab82d502fe..2a1ae37a07b5 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx @@ -10,7 +10,6 @@ import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import Navigation from '@libs/Navigation/Navigation'; import {getTagList, hasDependentTags as hasDependentTagsPolicyUtils} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {getUpdatedTransactionTag} from '@libs/TagsOptionsListUtils'; import {getTagArrayFromName} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -19,7 +18,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {useRoute} from '@react-navigation/native'; import React from 'react'; -import {getCommonDependentTag} from './SearchEditMultipleUtils'; +import {getBulkEditTagChange, getCommonDependentTag} from './SearchEditMultipleUtils'; function SearchEditMultipleTagPage() { const {translate} = useLocalize(); @@ -51,43 +50,18 @@ function SearchEditMultipleTagPage() { const headerTitle = tagListName || translate('common.tag'); const saveTag = (item: Partial) => { - const selectedTagName = item.searchText ?? ''; - - // Tapping the value that's already set on this level clears it. - const isDeselecting = selectedTagName === currentTag; - const recordedTagChanges = draftTransaction?.bulkEditTagChanges ?? {}; - - const updatedTag = getUpdatedTransactionTag({ - transactionTag, - selectedTagName, - currentTag, - tagListIndex, - policyTags, - hasDependentTags, - hasMultipleTagLists: policy?.hasMultipleTagLists ?? false, - }); - - // If the user picks and then removes a tag before saving, do not save a change. - // Otherwise, remove the tag. - const isUndoingOwnPick = isDeselecting && recordedTagChanges[tagListIndex] === currentTag; - const deselectValue = isUndoingOwnPick ? null : ''; - const bulkEditTagChanges: Record = {[tagListIndex]: isDeselecting ? deselectValue : selectedTagName}; - - // For dependent tags, changing a level makes the deeper ones stale, so clear any child intents still in the draft before they get replayed. - if (hasDependentTags) { - for (const recordedIndex of Object.keys(recordedTagChanges)) { - if (Number(recordedIndex) <= tagListIndex) { - continue; - } - bulkEditTagChanges[recordedIndex] = null; - } - } - - updateBulkEditDraftTransaction({ - // tag is only for display here, bulkEditTagChanges is what drives the save. - tag: updatedTag, - bulkEditTagChanges, - }); + updateBulkEditDraftTransaction( + getBulkEditTagChange({ + selectedTagName: item.searchText ?? '', + currentTag, + tagListIndex, + transactionTag, + recordedTagChanges: draftTransaction?.bulkEditTagChanges ?? {}, + policyTags, + hasDependentTags, + hasMultipleTagLists: policy?.hasMultipleTagLists ?? false, + }), + ); Navigation.goBack(); }; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxDropdown.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxDropdown.tsx new file mode 100644 index 000000000000..fe58adb9aa9d --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxDropdown.tsx @@ -0,0 +1,50 @@ +import type {ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import TaxPickerModal from '@components/TaxPicker/TaxPickerModal'; + +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; +import type {TaxRatesOption} from '@libs/TaxOptionsListUtils'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import {getBulkEditTaxCodeChange} from './SearchEditMultipleUtils'; + +type SearchEditMultipleTaxDropdownProps = ExpenseFieldDropdownRenderProps & { + /** Workspace the tax rates belong to */ + policyID: string | undefined; + + /** Code of the rate the draft already holds */ + currentTaxCode: string; + + /** The draft's rate as its row shows it, so the list can mark it */ + selectedTaxRate: string; +}; + +/** + * The tax rate list, in the container anchored to the bulk edit form's tax row. Mounted only once the row is opened, + * and records the pick on the draft the same way the full-page selector does. + */ +function SearchEditMultipleTaxDropdown({policyID, currentTaxCode, selectedTaxRate, onClose, ...popoverProps}: SearchEditMultipleTaxDropdownProps) { + const saveTaxRate = (taxes: TaxRatesOption) => { + updateBulkEditDraftTransaction(getBulkEditTaxCodeChange(taxes.code ?? '', currentTaxCode)); + }; + + return ( + + ); +} + +export default SearchEditMultipleTaxDropdown; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage.tsx index 762e04f78835..23bc28d74da9 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage.tsx +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage.tsx @@ -18,6 +18,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; import {View} from 'react-native'; +import {getBulkEditTaxCodeChange} from './SearchEditMultipleUtils'; + function SearchEditMultipleTaxPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -32,21 +34,7 @@ function SearchEditMultipleTaxPage() { : ''; const onSubmit = (taxes: TaxRatesOption) => { - const nextTaxCode = taxes.code ?? ''; - const currentTaxCode = draftTransaction?.taxCode ?? ''; - - // If the selected tax rate is the same as the current one, clear it - if (nextTaxCode === currentTaxCode) { - updateBulkEditDraftTransaction({ - taxCode: null, - }); - Navigation.goBack(); - return; - } - - updateBulkEditDraftTransaction({ - taxCode: nextTaxCode, - }); + updateBulkEditDraftTransaction(getBulkEditTaxCodeChange(taxes.code ?? '', draftTransaction?.taxCode ?? '')); Navigation.goBack(); }; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleUtils.ts b/src/pages/Search/SearchEditMultiple/SearchEditMultipleUtils.ts index a8336c96dec2..2f342ced95e9 100644 --- a/src/pages/Search/SearchEditMultiple/SearchEditMultipleUtils.ts +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleUtils.ts @@ -1,11 +1,12 @@ import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; -import {isInvoiceReport, isIOUReport} from '@libs/ReportUtils'; +import {isInvoiceReport, isIOUReport, shouldEnableNegative} from '@libs/ReportUtils'; +import {getUpdatedTransactionTag} from '@libs/TagsOptionsListUtils'; import {getTagArrayFromName, isDistanceRequest, isPerDiemRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Report, ReportActions, SearchResults, Transaction} from '@src/types/onyx'; +import type {Policy, PolicyTagLists, Report, ReportActions, SearchResults, Transaction} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -180,6 +181,99 @@ function withSnapshotReports(onyxReports: OnyxCollection | undefined, sn return merged; } +/** + * Whether the amount typed on the bulk edit form may be negative: only when every selected expense allows it. + * An unreported expense always does; a reported one follows its report and workspace. + */ +function isBulkEditNegativeAmountAllowed( + selectedTransactionContexts: Array<{transaction: Transaction; report: OnyxEntry}>, + reports: OnyxCollection, + policies: OnyxCollection, +): boolean { + if (selectedTransactionContexts.length === 0) { + return false; + } + return selectedTransactionContexts.every(({transaction, report}) => { + if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + return true; + } + // A chat report stands in for the money request report it belongs to. + const iouReport = report?.type === CONST.REPORT.TYPE.CHAT && report.parentReportID ? reports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`] : report; + if (!iouReport) { + return false; + } + const iouType = isInvoiceReport(iouReport) ? CONST.IOU.TYPE.INVOICE : CONST.IOU.TYPE.SUBMIT; + return shouldEnableNegative(iouReport, policies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`], iouType); + }); +} + +/** The draft change for a picked category. Picking nothing, or the category already picked, drops the pending change. */ +function getBulkEditCategoryChange(nextCategory: string, currentCategory: string): {category: string | null} { + return {category: !nextCategory || nextCategory === currentCategory ? null : nextCategory}; +} + +/** The draft change for a picked tax rate. Picking the rate already picked drops the pending change. */ +function getBulkEditTaxCodeChange(nextTaxCode: string, currentTaxCode: string): {taxCode: string | null} { + return {taxCode: nextTaxCode === currentTaxCode ? null : nextTaxCode}; +} + +type BulkEditTagChangeParams = { + /** The tag picked on this level. Picking the level's current tag again clears it. */ + selectedTagName: string; + + /** The tag the level holds before the pick */ + currentTag: string; + + tagListIndex: number; + + /** The whole multi-level tag the draft shows */ + transactionTag: string; + + /** The per-level intents already recorded on the draft, which drive the save */ + recordedTagChanges: Record; + + policyTags: OnyxEntry; + hasDependentTags: boolean; + hasMultipleTagLists: boolean; +}; + +/** + * The draft change for a tag picked on one level: the multi-level tag to show, and the per-level intents to save. + * Clearing a level the user picked in this session drops its intent; clearing one the expenses already hold records a clear. + */ +function getBulkEditTagChange({selectedTagName, currentTag, tagListIndex, transactionTag, recordedTagChanges, policyTags, hasDependentTags, hasMultipleTagLists}: BulkEditTagChangeParams): { + tag: string; + bulkEditTagChanges: Record; +} { + const isDeselecting = selectedTagName === currentTag; + + const tag = getUpdatedTransactionTag({ + transactionTag, + selectedTagName, + currentTag, + tagListIndex, + policyTags, + hasDependentTags, + hasMultipleTagLists, + }); + + const isUndoingOwnPick = isDeselecting && recordedTagChanges[tagListIndex] === currentTag; + const deselectValue = isUndoingOwnPick ? null : ''; + const bulkEditTagChanges: Record = {[tagListIndex]: isDeselecting ? deselectValue : selectedTagName}; + + // Changing a dependent level makes the deeper ones stale, so their intents are dropped before they get replayed. + if (hasDependentTags) { + for (const recordedIndex of Object.keys(recordedTagChanges)) { + if (Number(recordedIndex) <= tagListIndex) { + continue; + } + bulkEditTagChanges[recordedIndex] = null; + } + } + + return {tag, bulkEditTagChanges}; +} + export { getCommonDependentTag, getTransactionEditContext, @@ -187,6 +281,10 @@ export { areAllTransactionsExpenseCompatible, isBulkEditTaxTrackingEnabled, isBulkEditAttendeeTrackingEnabled, + isBulkEditNegativeAmountAllowed, + getBulkEditCategoryChange, + getBulkEditTaxCodeChange, + getBulkEditTagChange, withSnapshotTransactions, withSnapshotReportActions, withSnapshotReports, diff --git a/src/types/form/SearchEditMultipleDateForm.ts b/src/types/form/SearchEditMultipleDateForm.ts deleted file mode 100644 index 645fef5b72ed..000000000000 --- a/src/types/form/SearchEditMultipleDateForm.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type {ValueOf} from 'type-fest'; - -import type Form from './Form'; - -const INPUT_IDS = { - DATE: 'date', -} as const; - -type InputID = ValueOf; - -type SearchEditMultipleDateForm = Form< - InputID, - { - [INPUT_IDS.DATE]: string; - } ->; - -export type {SearchEditMultipleDateForm}; -export default INPUT_IDS; diff --git a/src/types/form/SearchEditMultipleDescriptionForm.ts b/src/types/form/SearchEditMultipleDescriptionForm.ts deleted file mode 100644 index 0b74c9720dc6..000000000000 --- a/src/types/form/SearchEditMultipleDescriptionForm.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type {ValueOf} from 'type-fest'; - -import type Form from './Form'; - -const INPUT_IDS = { - DESCRIPTION: 'description', -} as const; - -type InputID = ValueOf; - -type SearchEditMultipleDescriptionForm = Form< - InputID, - { - [INPUT_IDS.DESCRIPTION]: string; - } ->; - -export type {SearchEditMultipleDescriptionForm}; -export default INPUT_IDS; diff --git a/src/types/form/SearchEditMultipleMerchantForm.ts b/src/types/form/SearchEditMultipleMerchantForm.ts deleted file mode 100644 index fe68dbe3b479..000000000000 --- a/src/types/form/SearchEditMultipleMerchantForm.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type {ValueOf} from 'type-fest'; - -import type Form from './Form'; - -const INPUT_IDS = { - MERCHANT: 'merchant', -} as const; - -type InputID = ValueOf; - -type SearchEditMultipleMerchantForm = Form< - InputID, - { - [INPUT_IDS.MERCHANT]: string; - } ->; - -export type {SearchEditMultipleMerchantForm}; -export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 01a9bcc8ed97..289296906134 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -108,9 +108,6 @@ export type {WorkspacePerDiemForm} from './WorkspacePerDiemForm'; export type {MoneyRequestRejectReasonForm} from './MoneyRequestRejectReasonForm'; export type {ReportRejectForm} from './ReportRejectForm'; export type {EnableGlobalReimbursementsForm} from './EnableGlobalReimbursementsForm'; -export type {SearchEditMultipleDescriptionForm} from './SearchEditMultipleDescriptionForm'; -export type {SearchEditMultipleMerchantForm} from './SearchEditMultipleMerchantForm'; -export type {SearchEditMultipleDateForm} from './SearchEditMultipleDateForm'; export type {EnterSignerInfoForm} from './EnterSignerInfoForm'; export type {CreateDomainForm} from './CreateDomainForm'; export type {SplitExpenseEditDateForm} from './SplitExpenseEditDateForm'; diff --git a/tests/navigation/ModalStackNavigatorsTest.tsx b/tests/navigation/ModalStackNavigatorsTest.tsx index 0360e647cc18..f82f67532d78 100644 --- a/tests/navigation/ModalStackNavigatorsTest.tsx +++ b/tests/navigation/ModalStackNavigatorsTest.tsx @@ -145,7 +145,7 @@ describe('modal screen registrations', () => { SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS, SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP, ]); - expect(mockScreenRegistrations).toHaveLength(22); + expect(mockScreenRegistrations).toHaveLength(16); expect(mockScreenRegistrations.every(({isLazyLoaderDefined}) => isLazyLoaderDefined)).toBe(true); const holdScreen = mockScreenRegistrations.at(0); const defaultScreen = mockScreenRegistrations.find(({name}) => name === SCREENS.SEARCH.CHANGE_APPROVER.ROOT); diff --git a/tests/ui/AmountActionButtonsTest.tsx b/tests/ui/AmountActionButtonsTest.tsx index cbf986eac1cb..5444bad7bb66 100644 --- a/tests/ui/AmountActionButtonsTest.tsx +++ b/tests/ui/AmountActionButtonsTest.tsx @@ -1,4 +1,4 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; +import {fireEvent, render, renderHook, screen} from '@testing-library/react-native'; import AmountFormField from '@components/AmountFormField'; import ComposeProviders from '@components/ComposeProviders'; @@ -6,7 +6,10 @@ import KeypadAmountEditor from '@components/KeypadAmountEditor'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import useTheme from '@hooks/useTheme'; + import type * as NativeNavigation from '@react-navigation/native'; +import type {View} from 'react-native'; import React from 'react'; @@ -270,4 +273,59 @@ describe('Amount action buttons', () => { expect(input.props.selection).toEqual({start: 2, end: 2}); }); }); + + describe('borderless style', () => { + const renderButtons = (shouldUseBorderlessStyle?: boolean, currencyButtonRef?: React.RefObject) => + renderWithProviders( + + + + , + ); + + it('mutes the button labels when the buttons sit inside a field without their fill', async () => { + // Given the flip and currency buttons drawn borderless, as part of the field they sit in + const mutedColor = renderHook(() => useTheme()).result.current.textSupporting; + + // When they render + renderButtons(true); + await waitForBatchedUpdatesWithAct(); + + // Then their labels take the supporting color, matching the icons beside them + expect(screen.getByText('Flip')).toHaveStyle({color: mutedColor}); + expect(screen.getByText('USD')).toHaveStyle({color: mutedColor}); + }); + + it('keeps the button labels in their own color by default', async () => { + // Given the flip and currency buttons with their default fill + const mutedColor = renderHook(() => useTheme()).result.current.textSupporting; + + // When they render + renderButtons(); + await waitForBatchedUpdatesWithAct(); + + // Then their labels keep the button's text color, so existing callers look the same + expect(screen.getByText('Flip')).not.toHaveStyle({color: mutedColor}); + expect(screen.getByText('USD')).not.toHaveStyle({color: mutedColor}); + }); + + it('hands the currency button to its ref, so focus can return to it once the picker closes', async () => { + // Given a ref for the currency button + const currencyButtonRef = React.createRef(); + + // When the button renders + renderButtons(true, currencyButtonRef); + await waitForBatchedUpdatesWithAct(); + + // Then the ref points at it + expect(currencyButtonRef.current).not.toBeNull(); + }); + }); }); diff --git a/tests/ui/SearchEditMultiplePageTest.tsx b/tests/ui/SearchEditMultiplePageTest.tsx index ac058c572c4c..c4f437170fbd 100644 --- a/tests/ui/SearchEditMultiplePageTest.tsx +++ b/tests/ui/SearchEditMultiplePageTest.tsx @@ -4,6 +4,7 @@ import ComposeProviders from '@components/ComposeProviders'; import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import type ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {SearchContextProvider} from '@components/Search/SearchContextProvider'; import ThemeProvider from '@components/ThemeProvider'; @@ -25,6 +26,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {OriginalMessageIOU, Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; import type {Message} from '@src/types/onyx/ReportAction'; +import type {ComponentProps} from 'react'; import type {ValueOf} from 'type-fest'; import {PortalProvider} from '@gorhom/portal'; @@ -56,6 +58,21 @@ jest.mock('@userActions/Policy/Tag', () => ({ openPolicyTagsPage: jest.fn(), })); +jest.mock('@hooks/usePermissions', () => () => ({ + isBetaEnabled: () => false, + isBetaEnabledOrUnknown: () => false, +})); + +// Records what each selectable row decided, so a test can read it without pressing the row and measuring a layout. +const mockShouldOpenInDropdownByRow: Record = {}; +const mockValueByRow: Record = {}; +type ExpenseFieldDropdownMockProps = Pick, 'name' | 'shouldOpenInDropdown' | 'value'>; +jest.mock('@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown', () => ({name, shouldOpenInDropdown, value}: ExpenseFieldDropdownMockProps) => { + mockShouldOpenInDropdownByRow[name] = shouldOpenInDropdown; + mockValueByRow[name] = value; + return null; +}); + const CURRENT_USER_ACCOUNT_ID = 5; const CURRENT_USER_EMAIL = 'bjorn@vikings.net'; const POLICY_ID = 'policy1'; @@ -162,6 +179,10 @@ describe('SearchEditMultiplePage', () => { beforeEach(async () => { jest.clearAllMocks(); + for (const rowName of Object.keys(mockShouldOpenInDropdownByRow)) { + delete mockShouldOpenInDropdownByRow[rowName]; + delete mockValueByRow[rowName]; + } await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {[CURRENT_USER_ACCOUNT_ID]: {accountID: CURRENT_USER_ACCOUNT_ID, login: CURRENT_USER_EMAIL}}); await Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, POLICY_ID); @@ -184,21 +205,62 @@ describe('SearchEditMultiplePage', () => { }); it('keeps Reimbursable editable when every selected expense is still editable', async () => { + // Given a selection whose every expense can still be edited await selectTransactions([OPEN_TRANSACTION_ID]); + // When the page renders renderPage(); await waitForBatchedUpdatesWithAct(); - expect(screen.getByLabelText(/^Reimbursable/).props.accessibilityState).toEqual(expect.objectContaining({disabled: false})); + // Then the Reimbursable row opens its list in place + expect(mockShouldOpenInDropdownByRow.Reimbursable).toBe(true); }); - it('disables Reimbursable when the selection contains a marked-as-done expense', async () => { + it('leaves Reimbursable empty until a value is picked, then shows it', async () => { + // Given a selection where nothing has been picked for Reimbursable yet + await selectTransactions([OPEN_TRANSACTION_ID]); + + // When the page renders + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the row is empty like the other untouched fields, since each expense keeps its own value + expect(mockValueByRow.Reimbursable).toBe(''); + + // When No is picked + await selectTransactions([OPEN_TRANSACTION_ID], {reimbursable: false}); + + // Then the row shows it + expect(mockValueByRow.Reimbursable).toBe('No'); + }); + + it('sets every selected expense to non-reimbursable when No is picked', async () => { + // Given a selection whose Reimbursable was set to No, which a switch could not tell apart from leaving it alone + await selectTransactions([OPEN_TRANSACTION_ID], {reimbursable: false}); + + // When the changes are saved + renderPage(); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + + // Then every selected expense is marked non-reimbursable + expect(updateMultipleMoneyRequests).toHaveBeenCalledWith(expect.objectContaining({changes: {reimbursable: false}})); + }); + + it('locks Reimbursable when the selection contains a marked-as-done expense', async () => { + // Given a selection with an expense whose report is closed, which locks Reimbursable await selectTransactions([OPEN_TRANSACTION_ID, DONE_TRANSACTION_ID]); + // When the page renders and the changes are saved renderPage(); await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); - expect(screen.getByLabelText(/^Reimbursable/).props.accessibilityState).toEqual(expect.objectContaining({disabled: true})); + // Then the Reimbursable row can't open its list, and nothing is pending to apply + expect(mockShouldOpenInDropdownByRow.Reimbursable).toBe(false); + expect(updateMultipleMoneyRequests).not.toHaveBeenCalled(); }); it('shows Reimbursable for "Do Not Force" company card expenses on an "Always" workspace', async () => { @@ -212,7 +274,7 @@ describe('SearchEditMultiplePage', () => { await waitForBatchedUpdatesWithAct(); // Then the Reimbursable row shows, because the workspace cash expense rules don't apply to card expenses - expect(screen.getByLabelText(/^Reimbursable/)).toBeOnTheScreen(); + expect(mockShouldOpenInDropdownByRow.Reimbursable).toBeDefined(); }); it.each([true, false])('hides Reimbursable when the selection contains a company card expense forced to %s', async (forceReimbursable) => { @@ -231,7 +293,7 @@ describe('SearchEditMultiplePage', () => { await waitForBatchedUpdatesWithAct(); // Then the Reimbursable row is hidden, because the forced expense's reimbursable value can't change - expect(screen.queryByLabelText(/^Reimbursable/)).not.toBeOnTheScreen(); + expect(mockShouldOpenInDropdownByRow.Reimbursable).toBeUndefined(); }); it.each([ @@ -257,9 +319,9 @@ describe('SearchEditMultiplePage', () => { // Then the row follows the expense's saved restriction, not the current feed setting if (expectedEditable) { - expect(screen.getByLabelText(/^Reimbursable/)).toBeOnTheScreen(); + expect(mockShouldOpenInDropdownByRow.Reimbursable).toBeDefined(); } else { - expect(screen.queryByLabelText(/^Reimbursable/)).not.toBeOnTheScreen(); + expect(mockShouldOpenInDropdownByRow.Reimbursable).toBeUndefined(); } }); @@ -278,7 +340,7 @@ describe('SearchEditMultiplePage', () => { await waitForBatchedUpdatesWithAct(); // Then unreported expenses still respect their saved restriction - expect(screen.queryByLabelText(/^Reimbursable/)).not.toBeOnTheScreen(); + expect(mockShouldOpenInDropdownByRow.Reimbursable).toBeUndefined(); }); it('shows the spinner before applying the changes on save, then applies them and dismisses the RHP', async () => { @@ -362,7 +424,126 @@ describe('SearchEditMultiplePage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - // Then Merchant stays editable, because the report's submitter stands in for the missing action's requester - expect(screen.getByLabelText(/^Merchant/).props.accessibilityState).toEqual(expect.objectContaining({disabled: false})); + // Then Merchant stays an editable input, because the report's submitter stands in for the missing action's requester + fireEvent.changeText(screen.getByLabelText('Merchant'), 'Starbucks'); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + expect(updateMultipleMoneyRequests).toHaveBeenCalledWith(expect.objectContaining({changes: {merchant: 'Starbucks'}})); + }); + + it('shows the currency on the amount input and saves a picked currency', async () => { + // Given a selection whose draft holds a currency picked from the amount input's currency button + await selectTransactions([OPEN_TRANSACTION_ID], {currency: CONST.CURRENCY.EUR}); + + // When the Edit multiple page is opened and the changes are saved + renderPage(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(CONST.CURRENCY.EUR)).toBeOnTheScreen(); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + + // Then the picked currency is applied to the selected expenses + expect(updateMultipleMoneyRequests).toHaveBeenCalledWith(expect.objectContaining({changes: {currency: CONST.CURRENCY.EUR}})); + }); + + it('saves a typed amount in the currency the amount input shows', async () => { + // Given a selection on a workspace whose currency the amount input shows, and no currency picked + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {outputCurrency: CONST.CURRENCY.EUR}); + await selectTransactions([OPEN_TRANSACTION_ID], {amount: 5000}); + + // When the Edit multiple page is opened and the changes are saved + renderPage(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText(CONST.CURRENCY.EUR)).toBeOnTheScreen(); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + + // Then the amount goes with that currency, as each expense would otherwise keep its own and read as a different amount + expect(updateMultipleMoneyRequests).toHaveBeenCalledWith(expect.objectContaining({changes: {amount: 5000, currency: CONST.CURRENCY.EUR}})); + }); + + it('accepts a negative amount when every selected expense allows one', async () => { + // Given an expense on a workspace expense report, which can hold a negative amount + await selectTransactions([OPEN_TRANSACTION_ID]); + + // When a minus sign and then an amount are typed, and the changes are saved + renderPage(); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByLabelText('Amount'), '-'); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByLabelText('Amount'), '-5'); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + + // Then the lone minus sign is kept while typing and the negative amount is what gets applied + expect(updateMultipleMoneyRequests).toHaveBeenCalledWith(expect.objectContaining({changes: expect.objectContaining({amount: -500})})); + }); + + it('locks Merchant when a selected expense is a distance expense', async () => { + // Given a selection with a distance expense, whose merchant is computed from the route + const distanceTransactionID = 'distanceTransaction'; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${distanceTransactionID}`, { + ...buildTransaction(distanceTransactionID, OPEN_REPORT_ID), + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, + comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: {name: CONST.CUSTOM_UNITS.NAME_DISTANCE}}, + }); + await selectTransactions([OPEN_TRANSACTION_ID, distanceTransactionID]); + + // When the Edit multiple page is opened for the selection + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then Merchant is a locked field rather than an input + expect(screen.getByLabelText(/^Merchant/).props.onChangeText).toBeUndefined(); + }); + + it('does not save while the merchant typed is not a valid merchant', async () => { + // Given the merchant input holds the placeholder merchant, which the form flags inline + await selectTransactions([OPEN_TRANSACTION_ID]); + renderPage(); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(screen.getByLabelText('Merchant'), CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT); + await waitForBatchedUpdatesWithAct(); + + // When Save is pressed + fireEvent.press(screen.getByText('Save')); + await waitForBatchedUpdatesWithAct(); + + // Then the error stays on the field and nothing is applied + expect(screen.getByText('Please enter a valid merchant')).toBeTruthy(); + expect(updateMultipleMoneyRequests).not.toHaveBeenCalled(); + }); + + it('opens the category list in place', async () => { + // Given a workspace with categories + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {areCategoriesEnabled: true}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${POLICY_ID}`, {Food: {name: 'Food', enabled: true}}); + await selectTransactions([OPEN_TRANSACTION_ID]); + + // When the Edit multiple page renders the category row + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the row opens its list in a container anchored to it rather than on its own page + expect(mockShouldOpenInDropdownByRow.Category).toBe(true); + }); + + it('keeps the full-page tag selector for dependent tags, which need the levels above', async () => { + // Given a workspace with dependent tag lists + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {areTagsEnabled: true, hasMultipleTagLists: true}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${POLICY_ID}`, { + Region: {name: 'Region', required: false, orderWeight: 0, tags: {EMEA: {name: 'EMEA', enabled: true, rules: {parentTagsFilter: ''}}}}, + Country: {name: 'Country', required: false, orderWeight: 1, tags: {Spain: {name: 'Spain', enabled: true, rules: {parentTagsFilter: 'EMEA'}}}}, + }); + await selectTransactions([OPEN_TRANSACTION_ID]); + + // When the Edit multiple page renders the first tag row + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then the row keeps the full-page selector, which walks the levels in order + expect(mockShouldOpenInDropdownByRow.Region).toBe(false); }); }); diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index b04f479ee702..4b6986acd45f 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -1,6 +1,7 @@ import {isValidPerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem'; import { getAmountHasUnsavedChanges, + getBackendAmountFromInput, getStringFieldHasUnsavedChanges, getWaypointsHasUnsavedChanges, handleNegativeAmountFlipping, @@ -571,3 +572,22 @@ describe('isConfirmationDateMissing', () => { expect(isConfirmationDateMissing(transactionWithoutDate, true, true)).toBe(false); }); }); + +describe('getBackendAmountFromInput', () => { + it('converts what was typed to a backend amount, keeping a leading minus', () => { + // Given an amount typed in an inline amount field, with and without a minus sign + // When it is converted for the draft + // Then it is in the smallest currency unit, signed as typed + expect(getBackendAmountFromInput('12.5')).toBe(1250); + expect(getBackendAmountFromInput('-12.5')).toBe(-1250); + }); + + it('returns null for an empty field or a lone sign, which has no amount to record', () => { + // Given the field was cleared, holds only whitespace, or holds only a minus sign + // When it is converted for the draft + // Then there is no amount to record + expect(getBackendAmountFromInput('')).toBeNull(); + expect(getBackendAmountFromInput(' ')).toBeNull(); + expect(getBackendAmountFromInput('-')).toBeNull(); + }); +}); diff --git a/tests/unit/SearchEditMultipleDropdownsTest.tsx b/tests/unit/SearchEditMultipleDropdownsTest.tsx new file mode 100644 index 000000000000..4897fbe4f097 --- /dev/null +++ b/tests/unit/SearchEditMultipleDropdownsTest.tsx @@ -0,0 +1,334 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import type {AnchoredSelectionListPopoverProps} from '@components/AnchoredSelectionListPopover'; +import type {AttendeePickerModalProps} from '@components/AttendeePicker/AttendeePickerModal'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import type {ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type TagPickerModal from '@components/TagPicker/TagPickerModal'; +import type {TaxPickerModalProps} from '@components/TaxPicker/TaxPickerModal'; + +import SearchEditMultipleAttendeePills from '@pages/Search/SearchEditMultiple/SearchEditMultipleAttendeePills'; +import SearchEditMultipleAttendeesDropdown from '@pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesDropdown'; +import SearchEditMultipleBooleanDropdown from '@pages/Search/SearchEditMultiple/SearchEditMultipleBooleanDropdown'; +import type {BooleanOption} from '@pages/Search/SearchEditMultiple/SearchEditMultipleBooleanDropdown'; +import SearchEditMultipleTagDropdown from '@pages/Search/SearchEditMultiple/SearchEditMultipleTagDropdown'; +import SearchEditMultipleTaxDropdown from '@pages/Search/SearchEditMultiple/SearchEditMultipleTaxDropdown'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PolicyTagLists, Transaction} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import type {ComponentProps} from 'react'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// The lists themselves are covered by their own tests; here only what each dropdown hands its list, and what a pick +// writes to the bulk edit draft, matter. +type TagPickerModalProps = ComponentProps; +let mockTagModalProps: TagPickerModalProps | undefined; +jest.mock('@components/TagPicker/TagPickerModal', () => (props: TagPickerModalProps) => { + mockTagModalProps = props; + return null; +}); + +let mockTaxModalProps: TaxPickerModalProps | undefined; +jest.mock('@components/TaxPicker/TaxPickerModal', () => (props: TaxPickerModalProps) => { + mockTaxModalProps = props; + return null; +}); + +let mockAttendeeModalProps: AttendeePickerModalProps | undefined; +jest.mock('@components/AttendeePicker/AttendeePickerModal', () => (props: AttendeePickerModalProps) => { + mockAttendeeModalProps = props; + return null; +}); + +// The yes/no list has no picker of its own, so the container shows the list as-is and the list records what it was handed. +jest.mock( + '@components/AnchoredSelectionListPopover', + () => + ({children}: AnchoredSelectionListPopoverProps) => + children, +); + +type BooleanListProps = {data: BooleanOption[]; onSelectRow: (option: BooleanOption) => void}; +let mockBooleanListProps: BooleanListProps | undefined; +jest.mock('@components/SelectionList', () => (props: BooleanListProps) => { + mockBooleanListProps = props; + return null; +}); + +const POLICY_ID = 'policy1'; +const DRAFT_KEY = `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}` as const; +const TAG_NAME = 'Engineering'; +const TAX_CODE = 'id_TAX_RATE_1'; +const TAG_LIST_INDEX = 0; + +const policyTags: PolicyTagLists = { + Department: {name: 'Department', required: false, orderWeight: 0, tags: {[TAG_NAME]: {name: TAG_NAME, enabled: true}}}, +}; + +const alice: Attendee = {email: 'alice@test.com', displayName: 'Alice', avatarUrl: ''}; +const bob: Attendee = {email: 'bob@test.com', displayName: 'Bob', avatarUrl: ''}; + +const getPopoverProps = (onClose: jest.Mock, isVisible = true): ExpenseFieldDropdownRenderProps => ({ + isVisible, + onClose, + anchorPosition: {horizontal: 0, vertical: 0}, + anchorAlignment: {horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}, + shouldMeasureAnchorPositionFromTop: true, + shouldSwitchPositionIfOverflow: false, + popoverWidth: CONST.POPOVER_DROPDOWN_WIDTH, + popoverHeight: CONST.POPOVER_DROPDOWN_MAX_HEIGHT, +}); + +const setDraft = async (draftChanges: Partial) => { + await Onyx.set(DRAFT_KEY, {transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, ...draftChanges}); + await waitForBatchedUpdates(); +}; + +describe('SearchEditMultiple dropdowns', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockTagModalProps = undefined; + mockTaxModalProps = undefined; + mockAttendeeModalProps = undefined; + mockBooleanListProps = undefined; + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + describe('tag', () => { + const renderTagDropdown = (transactionTag: string, recordedTagChanges: Record) => + render( + , + ); + + it('records a picked tag on the draft', async () => { + // Given a workspace with one tag list and a draft with no tag picked yet + await setDraft({}); + + // When the tag list opens and a tag is picked + renderTagDropdown('', {}); + act(() => mockTagModalProps?.onSelected?.(TAG_NAME)); + await waitForBatchedUpdates(); + + // Then the draft shows the tag and records it as the change to save for that level + const draft = await getOnyxValue(DRAFT_KEY); + expect(draft?.tag).toBe(TAG_NAME); + expect(draft?.bulkEditTagChanges).toEqual({[TAG_LIST_INDEX]: TAG_NAME}); + }); + + it('clears a tag picked in this session when it is tapped again', async () => { + // Given a draft whose tag was picked earlier in this bulk edit + await setDraft({tag: TAG_NAME, bulkEditTagChanges: {[TAG_LIST_INDEX]: TAG_NAME}}); + + // When the tag list opens showing that tag as picked, and it is tapped again, which the list reports as an empty name + renderTagDropdown(TAG_NAME, {[TAG_LIST_INDEX]: TAG_NAME}); + expect(mockTagModalProps?.selectedTag).toBe(TAG_NAME); + act(() => mockTagModalProps?.onSelected?.('')); + await waitForBatchedUpdates(); + + // Then the row is empty again and the pending change for that level is dropped rather than saved as a clear + const draft = await getOnyxValue(DRAFT_KEY); + expect(draft?.tag).toBe(''); + expect(draft?.bulkEditTagChanges?.[TAG_LIST_INDEX]).toBeUndefined(); + }); + }); + + describe('tax rate', () => { + const renderTaxDropdown = (currentTaxCode: string) => + render( + , + ); + + it('records a picked tax rate on the draft', async () => { + // Given a draft with no tax rate picked yet + await setDraft({}); + + // When the tax list opens and a rate is picked + renderTaxDropdown(''); + act(() => mockTaxModalProps?.onSelected?.({code: TAX_CODE, text: 'Tax Rate 1', keyForList: TAX_CODE})); + await waitForBatchedUpdates(); + + // Then the draft holds that rate as the change to save + const draft = await getOnyxValue(DRAFT_KEY); + expect(draft?.taxCode).toBe(TAX_CODE); + }); + + it('drops the pending tax rate when the picked rate is tapped again', async () => { + // Given a draft whose tax rate was picked earlier in this bulk edit + await setDraft({taxCode: TAX_CODE}); + + // When the tax list opens and the picked rate is tapped again, which the list allows and reports as the same rate + renderTaxDropdown(TAX_CODE); + expect(mockTaxModalProps?.allowDeselect).toBe(true); + act(() => mockTaxModalProps?.onSelected?.({code: TAX_CODE, text: 'Tax Rate 1', keyForList: TAX_CODE})); + await waitForBatchedUpdates(); + + // Then the draft no longer holds a tax rate, so saving leaves each expense's rate alone + const draft = await getOnyxValue(DRAFT_KEY); + expect(draft?.taxCode).toBeUndefined(); + }); + }); + + describe('attendees', () => { + const getAttendeesDropdown = (onClose: jest.Mock, isVisible: boolean, draftAttendees: Attendee[]) => ( + + ); + + it('writes the selection to the draft and closes when it is applied', async () => { + // Given a draft with one attendee applied earlier + await setDraft({comment: {attendees: [alice]}}); + const onClose = jest.fn(); + + // When the list opens, another attendee is added and the selection is applied + render(getAttendeesDropdown(onClose, true, [alice])); + act(() => mockAttendeeModalProps?.onAttendeesChanged([alice, bob])); + act(() => mockAttendeeModalProps?.onApply()); + await waitForBatchedUpdates(); + + // Then the draft holds both attendees and the container closes + const draft = await getOnyxValue(DRAFT_KEY); + expect(draft?.comment?.attendees).toEqual([alice, bob]); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('keeps the applied selection when the list is dismissed without Apply', async () => { + // Given a draft with one attendee applied earlier, and the list open + await setDraft({comment: {attendees: [alice]}}); + const onClose = jest.fn(); + const {rerender} = render(getAttendeesDropdown(onClose, true, [alice])); + + // When another attendee is ticked, the list is dismissed without Apply, and then opened again + act(() => mockAttendeeModalProps?.onAttendeesChanged([alice, bob])); + expect(mockAttendeeModalProps?.attendees).toEqual([alice, bob]); + rerender(getAttendeesDropdown(onClose, false, [alice])); + rerender(getAttendeesDropdown(onClose, true, [alice])); + await waitForBatchedUpdates(); + + // Then the list starts from the applied selection again and the draft was never touched, as the row stays + // mounted between opens and would otherwise bring the dismissed pick back + expect(mockAttendeeModalProps?.attendees).toEqual([alice]); + const draft = await getOnyxValue(DRAFT_KEY); + expect(draft?.comment?.attendees).toEqual([alice]); + }); + + it('does not apply an empty selection', async () => { + // Given a draft with no attendees and the list open with nothing ticked + await setDraft({}); + const onClose = jest.fn(); + render(getAttendeesDropdown(onClose, true, [])); + + // When the selection is applied with the Enter key, which the disabled Apply button does not stop + act(() => mockAttendeeModalProps?.onApply()); + await waitForBatchedUpdates(); + + // Then nothing is written and the list stays open so its error can show + const draft = await getOnyxValue(DRAFT_KEY); + expect(draft?.comment?.attendees).toBeUndefined(); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('reimbursable and billable', () => { + const renderBooleanDropdown = (selectedValue: boolean | null | undefined, onSelected: jest.Mock, onClose: jest.Mock = jest.fn()) => + render( + , + ); + const pickOption = (keyForList: string) => { + const option = mockBooleanListProps?.data.find((item) => item.keyForList === keyForList); + if (!option) { + throw new Error(`No "${keyForList}" option`); + } + act(() => mockBooleanListProps?.onSelectRow(option)); + }; + + it('offers Yes and No with neither picked, so an untouched field leaves each expense as it is', () => { + // Given a field nothing has been picked for yet + // When its list opens + renderBooleanDropdown(undefined, jest.fn()); + + // Then only Yes and No are offered, and neither is picked + expect(mockBooleanListProps?.data.map(({keyForList, isSelected}) => ({keyForList, isSelected}))).toEqual([ + {keyForList: CONST.SEARCH.BOOLEAN.YES, isSelected: false}, + {keyForList: CONST.SEARCH.BOOLEAN.NO, isSelected: false}, + ]); + }); + + it('records No for every expense when it is picked, and closes the list', () => { + // Given a field nothing has been picked for yet + const onSelected = jest.fn(); + const onClose = jest.fn(); + renderBooleanDropdown(undefined, onSelected, onClose); + + // When No is picked + pickOption(CONST.SEARCH.BOOLEAN.NO); + + // Then No is recorded, which a switch could not tell apart from leaving the field alone + expect(onSelected).toHaveBeenCalledWith(false); + expect(onClose).toHaveBeenCalled(); + }); + + it('drops a picked value when it is tapped again', () => { + // Given a field set to Yes earlier in this bulk edit + const onSelected = jest.fn(); + renderBooleanDropdown(true, onSelected); + expect(mockBooleanListProps?.data.find((item) => item.isSelected)?.keyForList).toBe(CONST.SEARCH.BOOLEAN.YES); + + // When Yes is tapped again + pickOption(CONST.SEARCH.BOOLEAN.YES); + + // Then the pending value is dropped, so each expense keeps its own, as the full-page list did + expect(onSelected).toHaveBeenCalledWith(null); + }); + }); + + describe('attendee pills', () => { + it('shows the draft attendees as pills by name, as the expense form does', async () => { + // Given two attendees the bulk edit draft holds + // When the attendees row renders its value + render( + + + , + ); + await waitForBatchedUpdates(); + + // Then each attendee shows as a pill with their name, matching the attendees row on the expense form + expect(screen.getByText('Alice')).toBeOnTheScreen(); + expect(screen.getByText('Bob')).toBeOnTheScreen(); + }); + }); +}); diff --git a/tests/unit/SearchEditMultipleUtilsTest.ts b/tests/unit/SearchEditMultipleUtilsTest.ts index 39a0f225e339..436324edc8ca 100644 --- a/tests/unit/SearchEditMultipleUtilsTest.ts +++ b/tests/unit/SearchEditMultipleUtilsTest.ts @@ -6,8 +6,11 @@ import type {Policy, Report, ReportAction, SearchResults, Transaction} from '@sr import { areAllTransactionsExpenseCompatible, + getBulkEditCategoryChange, + getBulkEditTaxCodeChange, getTransactionEditContext, hasCustomUnitMerchantInSelection, + isBulkEditNegativeAmountAllowed, isBulkEditTaxTrackingEnabled, withSnapshotReports, withSnapshotTransactions, @@ -311,4 +314,74 @@ describe('SearchEditMultipleUtils', () => { expect(getTransactionEditContext('missing', {}, {}, {}, undefined)).toBeNull(); }); }); + + describe('getBulkEditCategoryChange', () => { + it('records a newly picked category', () => { + // Given no category picked yet + // When Food is picked + // Then the draft records it + expect(getBulkEditCategoryChange('Food', '')).toEqual({category: 'Food'}); + }); + + it('drops the pending change when the picked category is picked again, or nothing is picked', () => { + // Given Food is already picked + // When Food is picked again, or the list hands back an empty pick + // Then the pending change is cleared rather than re-recorded + expect(getBulkEditCategoryChange('Food', 'Food')).toEqual({category: null}); + expect(getBulkEditCategoryChange('', 'Food')).toEqual({category: null}); + }); + }); + + describe('getBulkEditTaxCodeChange', () => { + it('records a newly picked rate and drops the pending change when it is picked again', () => { + // Given no rate picked yet, then one picked + // When a rate is picked, then picked again + // Then the first pick is recorded and the second clears it + expect(getBulkEditTaxCodeChange('id_TAX_1', '')).toEqual({taxCode: 'id_TAX_1'}); + expect(getBulkEditTaxCodeChange('id_TAX_1', 'id_TAX_1')).toEqual({taxCode: null}); + }); + }); + + describe('isBulkEditNegativeAmountAllowed', () => { + const THREAD_REPORT_ID = 'thread1'; + const unreportedTransaction = makeTransaction(TRANSACTION_ID_1, CONST.REPORT.UNREPORTED_REPORT_ID); + const reportedTransaction = makeTransaction(TRANSACTION_ID_2, REPORT_ID); + const expenseReport = makeReport(REPORT_ID, POLICY_A); + const policy = createMock({id: POLICY_A, type: CONST.POLICY.TYPE.TEAM}); + const reports = {[`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`]: expenseReport}; + const policies = {[`${ONYXKEYS.COLLECTION.POLICY}${POLICY_A}`]: policy}; + + it('allows a negative amount for unreported expenses', () => { + // Given only unreported expenses, which can always go negative + // When the selection is checked + // Then the amount field may flip negative + expect(isBulkEditNegativeAmountAllowed([{transaction: unreportedTransaction, report: undefined}], reports, policies)).toBe(true); + }); + + it('follows the report and workspace of a reported expense', () => { + // Given a reported expense on a workspace that allows negative expense amounts + // When the selection is checked + // Then the amount field follows that workspace, and a missing report rules it out + expect(isBulkEditNegativeAmountAllowed([{transaction: reportedTransaction, report: expenseReport}], reports, policies)).toBe(true); + expect(isBulkEditNegativeAmountAllowed([{transaction: reportedTransaction, report: undefined}], reports, policies)).toBe(false); + }); + + it('follows the money request report a chat report belongs to', () => { + // Given an expense whose report is a chat that hangs off an expense report + const threadReport = createMock({reportID: THREAD_REPORT_ID, type: CONST.REPORT.TYPE.CHAT, parentReportID: REPORT_ID}); + const threadTransaction = makeTransaction(TRANSACTION_ID_3, THREAD_REPORT_ID); + + // When the selection is checked + // Then the expense report decides, as a chat on its own never allows a negative amount + expect(isBulkEditNegativeAmountAllowed([{transaction: threadTransaction, report: threadReport}], reports, policies)).toBe(true); + expect(isBulkEditNegativeAmountAllowed([{transaction: threadTransaction, report: threadReport}], {}, policies)).toBe(false); + }); + + it('rules out a negative amount for an empty selection', () => { + // Given nothing selected + // When the selection is checked + // Then there is nothing to flip + expect(isBulkEditNegativeAmountAllowed([], reports, policies)).toBe(false); + }); + }); }); diff --git a/tests/unit/components/AttendeePickerModalTest.tsx b/tests/unit/components/AttendeePickerModalTest.tsx index a92c2c583855..06f6944ee05b 100644 --- a/tests/unit/components/AttendeePickerModalTest.tsx +++ b/tests/unit/components/AttendeePickerModalTest.tsx @@ -97,6 +97,20 @@ describe('AttendeePickerModal', () => { expect(onClose).not.toHaveBeenCalled(); }); + it('shows the empty-selection error as soon as the list opens unless told to wait for an apply attempt', () => { + // Given the expense form's attendees row, which always opens with at least the current user picked + renderModal({}); + + // Then the list shows its empty-selection error as soon as the selection is emptied, as on its page + expect(mockSelectorProps?.shouldDeferEmptySelectionError).toBe(false); + + // When a row that opens with nothing picked, such as the bulk edit form's, asks the error to wait + renderModal({shouldDeferEmptySelectionError: true}); + + // Then the list holds the error until the user tries to apply an empty selection + expect(mockSelectorProps?.shouldDeferEmptySelectionError).toBe(true); + }); + it('opens at the standard dropdown size on a wide layout', () => { // Given a wide layout and a caller passing no size of its own // When the list opens