diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 810d7c5d17e3..0e4f5b9babdd 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10582,6 +10582,9 @@ const CONST = { TAX_PICKER_MODAL: { BACK_BUTTON: 'TaxPickerModal-BackButton', }, + ATTENDEE_PICKER_MODAL: { + BACK_BUTTON: 'AttendeePickerModal-BackButton', + }, CALENDAR_PICKER: { YEAR_PICKER: 'CalendarPicker-YearPicker', MONTH_PICKER: 'CalendarPicker-MonthPicker', diff --git a/src/components/AttendeePicker/AttendeePickerModal.tsx b/src/components/AttendeePicker/AttendeePickerModal.tsx new file mode 100644 index 000000000000..20f36bb5646e --- /dev/null +++ b/src/components/AttendeePicker/AttendeePickerModal.tsx @@ -0,0 +1,74 @@ +/** + * The attendee list in the container anchored to a field's row, with an Apply button in place of the full page's Save. + */ +import AnchoredSelectionListPopover from '@components/AnchoredSelectionListPopover'; +import type {AnchoredSelectionListPopoverProps} from '@components/AnchoredSelectionListPopover'; +import ScreenWrapperStatusContext from '@components/ScreenWrapper/ScreenWrapperStatusContext'; + +import useLocalize from '@hooks/useLocalize'; + +import MoneyRequestAttendeeSelector from '@pages/iou/request/MoneyRequestAttendeeSelector'; + +import CONST from '@src/CONST'; +import type {IOUType} from '@src/CONST'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import React from 'react'; + +/** + * The list builds its options once the screen it is on has finished its transition, which it reads off the screen + * wrapper. A container anchored to a row has no screen transition, so the list is told the screen has settled, and that + * no safe area padding was applied around it, which is what it would read outside any screen wrapper. + */ +const SETTLED_SCREEN_STATUS = {didScreenTransitionEnd: true, isSafeAreaTopPaddingApplied: false, isSafeAreaBottomPaddingApplied: false}; + +type AttendeePickerModalProps = { + /** The expense's attendees when the list opens. The list keeps its own selection from there */ + attendees: Attendee[]; + + /** Called with the whole selection each time an attendee is toggled */ + onAttendeesChanged: (attendees: Attendee[]) => void; + + /** Called when the user applies the selection, with the Apply button or the Enter key */ + onApply: () => void; + + /** Type of the expense, which the list's empty state is worded for */ + iouType: IOUType; +} & Omit; + +/** + * The attendee list in a container anchored to a row: a pop-over on a wide layout, a bottom sheet on a narrow one. + * Unlike the single-select lists, toggling an attendee hands the new selection to `onAttendeesChanged` and keeps the + * 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) { + const {translate} = useLocalize(); + + return ( + + + + + + ); +} + +export default AttendeePickerModal; +export type {AttendeePickerModalProps}; diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx index c8b50b53127b..22ac37039e9c 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx @@ -1,11 +1,14 @@ import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; +import {SELECTION_LIST_POPOVER_HEADER_HEIGHT} from '@components/SelectionListPopoverHeader'; import UserPills from '@components/UserPills'; import useAttendees from '@hooks/useAttendees'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import {enrichAndSortAttendees} from '@libs/AttendeeUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -25,7 +28,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; -import ExpenseFieldRow from './ExpenseFieldRow'; +import AttendeeFieldDropdown from './AttendeeFieldDropdown'; +import ExpenseFieldDropdown from './ExpenseFieldDropdown'; import {useExpenseFormLayout} from './ExpenseFormLayoutContext'; import {attendeeSliceSelector} from './selectors'; import useTransactionSelector from './useTransactionSelector'; @@ -45,6 +49,8 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a const {translate, localeCompare, formatPhoneNumber} = useLocalize(); const personalDetailsList = usePersonalDetails(); const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); + const {isBetaEnabled} = usePermissions(); + const {didConfirm} = useConfirmationFields(); const shouldDisplayAttendeesError = formError === 'violations.missingAttendees'; const attendeeSlice = useTransactionSelector(transactionID, attendeeSliceSelector); @@ -78,9 +84,14 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID))); }; + // The dropdown writes the applied selection to the draft, so it stays closed once the expense is submitted and the + // draft is on its way out, as the category, tag and tax dropdowns do. A saved expense being edited keeps the full page, + // which saves once through the API with the expense's report and policy in hand. + const shouldOpenInDropdown = isBetaEnabled(CONST.BETAS.ANCHORED_FIELD_DROPDOWNS) && !!transactionID && !isReadOnly && !didConfirm && action !== CONST.IOU.ACTION.EDIT; + if (shouldUseDropdownRows) { return ( - + !!transactionID && ( + + ) + } isInteractive={!isReadOnly} sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.ATTENDEES_FIELD} /> diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx new file mode 100644 index 000000000000..b96317b6734f --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown.tsx @@ -0,0 +1,77 @@ +/** + * The attendees row's dropdown on the expense confirmation form: the attendee list anchored to the row, holding the + * selection until the user applies it to the draft expense. + */ +import AttendeePickerModal from '@components/AttendeePicker/AttendeePickerModal'; + +import useOnyx from '@hooks/useOnyx'; +import useReportOwnerAsAttendee from '@hooks/useReportOwnerAsAttendee'; + +import {setMoneyRequestAttendees} from '@libs/actions/IOU/MoneyRequest'; +import {getOriginalAttendees} from '@libs/TransactionUtils'; + +import type {IOUType} from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import {deepEqual} from 'fast-equals'; +import React, {useState} from 'react'; + +import type {ExpenseFieldDropdownRenderProps} from './ExpenseFieldDropdown'; + +type AttendeeFieldDropdownProps = ExpenseFieldDropdownRenderProps & { + /** ID of the draft expense the attendees are written to */ + transactionID: string; + + /** Type of the expense, which the list's empty state is worded for */ + iouType: IOUType; +}; + +/** + * The attendee list, in the container anchored to the expense form's attendees row. Mounted only once the row is + * opened. The selection is kept here while the user picks and written to the draft when applied, as the full page + * writes it on save; dismissing the container drops the unapplied selection. + */ +function AttendeeFieldDropdown({transactionID, iouType, isVisible, onClose, ...popoverProps}: AttendeeFieldDropdownProps) { + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); + const reportOwnerAsAttendee = useReportOwnerAsAttendee(draftTransaction); + const originalAttendees = getOriginalAttendees(draftTransaction, reportOwnerAsAttendee); + + // The list starts from the draft's attendees, the creator when it has none, as the full page does. The row keeps this + // dropdown mounted once it has been opened, so the selection is dropped each time it opens or closes: a selection + // dismissed without Apply must not come back on the next open. + const [pickedAttendees, setPickedAttendees] = useState(); + const [wasVisible, setWasVisible] = useState(isVisible); + if (wasVisible !== isVisible) { + setWasVisible(isVisible); + setPickedAttendees(undefined); + } + const attendees = pickedAttendees ?? originalAttendees; + + const applyAttendees = () => { + // An expense needs at least one attendee, which the full page enforces at save. The list shows the same error, and + // the Apply button is disabled, so this only guards the Enter key. + if (attendees.length === 0) { + return; + } + if (!deepEqual(originalAttendees, attendees)) { + setMoneyRequestAttendees(transactionID, attendees, true); + } + onClose(); + }; + + return ( + + ); +} + +export default AttendeeFieldDropdown; diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 00abb59de608..d660d8c9a40c 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -27,6 +27,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Attendee} from '@src/types/onyx/IOU'; import getEmptyArray from '@src/types/utils/getEmptyArray'; +import type {ValueOf} from 'type-fest'; + import {SafeString} from 'expensify-common'; import React, {useEffect, useState} from 'react'; @@ -45,9 +47,27 @@ type MoneyRequestAttendeesSelectorProps = { /** Empty selection shows no error until the user tries to save */ shouldDeferEmptySelectionError?: boolean; + + /** Label of the search input. Defaults to "Name, email, or phone number" */ + searchInputLabel?: string; + + /** Text of the button that confirms the selection. Defaults to "Save" */ + confirmButtonText?: string; + + /** Size of the button that confirms the selection. Defaults to large, as in the footer of a full page */ + confirmButtonSize?: ValueOf; }; -function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdded, iouType, shouldDeferEmptySelectionError = false}: MoneyRequestAttendeesSelectorProps) { +function MoneyRequestAttendeeSelector({ + attendees = [], + onFinish, + onAttendeesAdded, + iouType, + shouldDeferEmptySelectionError = false, + searchInputLabel, + confirmButtonText, + confirmButtonSize = CONST.BUTTON_SIZE.LARGE, +}: MoneyRequestAttendeesSelectorProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -163,12 +183,12 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde ); @@ -224,7 +244,7 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde const shouldShowListEmptyContent = optionLength === 0 && !shouldShowLoadingPlaceholder; const textInputOptions = { - label: translate('selectionList.nameEmailOrPhoneNumber'), + label: searchInputLabel ?? translate('selectionList.nameEmailOrPhoneNumber'), hint: offlineMessage, value: searchTerm, onChangeText: setSearchTerm, diff --git a/tests/unit/components/AttendeePickerModalTest.tsx b/tests/unit/components/AttendeePickerModalTest.tsx new file mode 100644 index 000000000000..a92c2c583855 --- /dev/null +++ b/tests/unit/components/AttendeePickerModalTest.tsx @@ -0,0 +1,122 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import AttendeePickerModal from '@components/AttendeePicker/AttendeePickerModal'; +import type PopoverWithMeasuredContentProps from '@components/PopoverWithMeasuredContent/types'; + +import type useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; + +import type MoneyRequestAttendeeSelector from '@pages/iou/request/MoneyRequestAttendeeSelector'; + +import CONST from '@src/CONST'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import type {ComponentProps} from 'react'; + +import React from 'react'; + +let mockIsSmallScreenWidth = false; +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({isSmallScreenWidth: mockIsSmallScreenWidth, shouldUseNarrowLayout: mockIsSmallScreenWidth, isInLandscapeMode: false}))); +jest.mock('@hooks/useWindowDimensions', () => jest.fn(() => ({windowWidth: 1200, windowHeight: 800}))); +jest.mock('@hooks/useViewportOffsetTop', () => jest.fn(() => 0)); +jest.mock('@hooks/useKeyboardState', () => jest.fn(() => ({isKeyboardActive: false, keyboardActiveHeight: 0}))); +jest.mock('@hooks/useSafeAreaPaddings', () => jest.fn(() => ({paddingTop: 0, paddingBottom: 0}))); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); + +// The list itself is covered elsewhere; here only what the container hands it, and the screen status it reads, matter. +type SelectorProps = ComponentProps; +let mockSelectorProps: SelectorProps | undefined; +let mockDidScreenTransitionEnd: boolean | undefined; +jest.mock('@pages/iou/request/MoneyRequestAttendeeSelector', () => (props: SelectorProps) => { + mockSelectorProps = props; + // The real list only builds its options once the screen has settled, which it reads through this hook. + mockDidScreenTransitionEnd = jest.requireActual<{default: typeof useScreenWrapperTransitionStatus}>('@hooks/useScreenWrapperTransitionStatus').default().didScreenTransitionEnd; + return null; +}); + +let mockPopoverProps: PopoverWithMeasuredContentProps | undefined; +jest.mock('@components/PopoverWithMeasuredContent', () => (props: PopoverWithMeasuredContentProps) => { + mockPopoverProps = props; + return props.children; +}); + +const attendees: Attendee[] = [{email: 'creator@example.com', displayName: 'Creator', avatarUrl: ''}]; + +const renderModal = (props: Partial> = {}) => + render( + , + ); + +describe('AttendeePickerModal', () => { + beforeEach(() => { + mockPopoverProps = undefined; + mockSelectorProps = undefined; + mockDidScreenTransitionEnd = undefined; + mockIsSmallScreenWidth = false; + }); + + it('shows the attendee list with an Apply button and a "Search for someone" input, on a screen it reads as settled', () => { + // Given the expense form's attendees row + const onAttendeesChanged = jest.fn(); + + // When the list opens in the container + renderModal({onAttendeesChanged}); + + // Then it is the full page's list, started from the expense's attendees, with its button reading Apply rather than + // Save at the medium size of a pop-over footer, and its search input asking for someone. It reads the screen as + // settled, as it would on its page once the transition ended, so it builds its options rather than waiting for a + // transition the container never has + expect(mockSelectorProps?.attendees).toEqual(attendees); + expect(mockSelectorProps?.onAttendeesAdded).toBe(onAttendeesChanged); + expect(mockSelectorProps?.iouType).toBe(CONST.IOU.TYPE.SUBMIT); + expect(mockSelectorProps?.confirmButtonText).toBe('common.apply'); + expect(mockSelectorProps?.searchInputLabel).toBe('selectionList.searchForSomeone'); + expect(mockSelectorProps?.confirmButtonSize).toBe(CONST.BUTTON_SIZE.MEDIUM); + expect(mockDidScreenTransitionEnd).toBe(true); + }); + + it('applies the selection when the list confirms it', () => { + // Given the list open in the container + const onApply = jest.fn(); + const onClose = jest.fn(); + renderModal({onApply, onClose}); + + // When the user confirms, with the Apply button or the Enter key on a focused attendee + mockSelectorProps?.onFinish(); + + // Then the selection is applied; the caller decides whether to close, as it may keep the list open on an empty selection + expect(onApply).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + + 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 + renderModal(); + + // Then the pop-over is the standard size, since the list always has a search input and more rows than it can show + expect(mockPopoverProps?.popoverDimensions).toEqual({width: CONST.POPOVER_DROPDOWN_WIDTH, height: CONST.POPOVER_DROPDOWN_MAX_HEIGHT}); + }); + + it('shows a header naming the field whose back button closes the list', () => { + // Given the attendees row on a phone + mockIsSmallScreenWidth = true; + const onClose = jest.fn(); + renderModal({onClose, shouldShowHeader: true}); + + // When the back button in the header is pressed + expect(screen.getByText('iou.attendees')).toBeOnTheScreen(); + fireEvent.press(screen.getByLabelText('common.back')); + + // Then the list closes + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/components/MoneyRequestConfirmationList/AttendeeField.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/AttendeeField.test.tsx new file mode 100644 index 000000000000..36f0c9672241 --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationList/AttendeeField.test.tsx @@ -0,0 +1,160 @@ +import {render, waitFor} from '@testing-library/react-native'; + +import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; +import AttendeeField from '@components/MoneyRequestConfirmationList/sections/AttendeeField'; +import type ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import ExpenseFormLayoutContext, {dropdownRowsExpenseFormLayout} from '@components/MoneyRequestConfirmationList/sections/ExpenseFormLayoutContext'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import CONST from '@src/CONST'; +import type {IOUAction} from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type {ComponentProps} from 'react'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key, localeCompare: (a: string, b: string) => a.localeCompare(b), formatPhoneNumber: (value: string) => value})); +jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn()})); + +let mockIsAnchoredDropdownsBetaEnabled = true; +jest.mock('@hooks/usePermissions', () => () => ({ + isBetaEnabled: (beta: string) => mockIsAnchoredDropdownsBetaEnabled && beta === jest.requireActual<{default: typeof CONST}>('@src/CONST').default.BETAS.ANCHORED_FIELD_DROPDOWNS, +})); + +// Records what the row decided and renders its list straight away, so each test can read both without pressing anything. +let mockShouldOpenInDropdown: boolean | undefined; +type ExpenseFieldDropdownMockProps = Pick, 'shouldOpenInDropdown' | 'renderDropdown'>; +jest.mock('@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown', () => { + return ({shouldOpenInDropdown, renderDropdown}: ExpenseFieldDropdownMockProps) => { + const {MODAL} = jest.requireActual<{default: typeof CONST}>('@src/CONST').default; + mockShouldOpenInDropdown = shouldOpenInDropdown; + return renderDropdown({ + isVisible: true, + onClose: () => {}, + anchorPosition: {horizontal: 0, vertical: 0}, + anchorAlignment: {horizontal: MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}, + shouldMeasureAnchorPositionFromTop: true, + shouldSwitchPositionIfOverflow: false, + popoverWidth: 300, + popoverHeight: 300, + }); + }; +}); + +let mockDropdownTransactionID: string | undefined; +jest.mock('@components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown', () => ({transactionID}: {transactionID: string}) => { + mockDropdownTransactionID = transactionID; + return null; +}); + +const TRANSACTION_ID = '1'; +const REPORT_ID = 'report1'; + +type RenderParams = { + action?: IOUAction; + isReadOnly?: boolean; + transactionID?: string; + didConfirm?: boolean; +}; + +const renderAttendeesRow = ({action = CONST.IOU.ACTION.CREATE, isReadOnly = false, transactionID = TRANSACTION_ID, didConfirm = false}: RenderParams = {}) => + render( + + + + + + + , + ); + +describe('AttendeeField', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockIsAnchoredDropdownsBetaEnabled = true; + mockShouldOpenInDropdown = undefined; + mockDropdownTransactionID = undefined; + await Onyx.clear(); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, {transactionID: TRANSACTION_ID, reportID: REPORT_ID}); + await waitForBatchedUpdates(); + }); + + it('opens the attendee list in place while creating an expense', async () => { + // Given a draft expense on the create form, with the anchored dropdowns beta on + // When the form renders the attendees row + renderAttendeesRow(); + + // Then the row opens its list in a container anchored to it, for this expense, rather than pushing the full-page selector + await waitFor(() => { + expect(mockShouldOpenInDropdown).toBe(true); + }); + expect(mockDropdownTransactionID).toBe(TRANSACTION_ID); + }); + + it('keeps the full-page selector when the beta is off', async () => { + // Given the same draft, for a user without the anchored dropdowns beta + mockIsAnchoredDropdownsBetaEnabled = false; + + // When the form renders the attendees row + renderAttendeesRow(); + + // Then the row opens the full-page selector as before + await waitFor(() => { + expect(mockShouldOpenInDropdown).toBe(false); + }); + }); + + it('keeps the full-page selector for a saved expense being edited', async () => { + // Given a saved expense whose attendees are being edited, which the dropdown cannot save since it writes to the draft + // When the form renders the attendees row + renderAttendeesRow({action: CONST.IOU.ACTION.EDIT}); + + // Then the row opens the full page, which saves through the API + await waitFor(() => { + expect(mockShouldOpenInDropdown).toBe(false); + }); + }); + + it('does not open the list for a read-only row', async () => { + // Given a form whose attendees cannot be changed + // When the form renders the attendees row + renderAttendeesRow({isReadOnly: true}); + + // Then the row opens nothing, in place or otherwise + await waitFor(() => { + expect(mockShouldOpenInDropdown).toBe(false); + }); + }); + + it('does not open the list in place once the expense is submitted', async () => { + // Given a form whose Submit button has been pressed, so its draft is being submitted and cleared + // When the form renders the attendees row + renderAttendeesRow({didConfirm: true}); + + // Then the row no longer opens the list in place, which would write to that draft on Apply + await waitFor(() => { + expect(mockShouldOpenInDropdown).toBe(false); + }); + }); +}); diff --git a/tests/unit/components/MoneyRequestConfirmationList/AttendeeFieldDropdown.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/AttendeeFieldDropdown.test.tsx new file mode 100644 index 000000000000..d6897204a3ab --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationList/AttendeeFieldDropdown.test.tsx @@ -0,0 +1,186 @@ +import {act, render} from '@testing-library/react-native'; + +import type {AttendeePickerModalProps} from '@components/AttendeePicker/AttendeePickerModal'; +import AttendeeFieldDropdown from '@components/MoneyRequestConfirmationList/sections/AttendeeFieldDropdown'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {setMoneyRequestAttendees} from '@libs/actions/IOU/MoneyRequest'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Attendee} from '@src/types/onyx/IOU'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; + +// Only the write matters here, so the action is stubbed to record what it is asked to save. +jest.mock('@libs/actions/IOU/MoneyRequest', () => ({ + ...jest.requireActual>('@libs/actions/IOU/MoneyRequest'), + setMoneyRequestAttendees: jest.fn(), +})); + +// The list is covered by the AttendeePickerModal tests; here only what the dropdown hands it, and what it does when the selection is applied, matter. +type MockModalProps = Pick; +let mockModalProps: MockModalProps | undefined; +jest.mock('@components/AttendeePicker/AttendeePickerModal', () => (props: MockModalProps) => { + mockModalProps = props; + return null; +}); + +const TRANSACTION_ID = '1'; +const REPORT_ID = 'report1'; +const CURRENT_USER_ACCOUNT_ID = 1; +const CURRENT_USER_LOGIN = 'creator@example.com'; +const creator: Attendee = {email: CURRENT_USER_LOGIN, displayName: 'Creator', avatarUrl: ''}; +const guest: Attendee = {email: 'guest@example.com', displayName: 'Guest', avatarUrl: ''}; + +const buildDropdown = (onClose = jest.fn(), isVisible = true) => ( + + + +); + +const renderDropdown = (onClose = jest.fn()) => render(buildDropdown(onClose)); + +describe('AttendeeFieldDropdown', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockModalProps = undefined; + jest.mocked(setMoneyRequestAttendees).mockClear(); + await Onyx.clear(); + // The expense sits on a report the creator owns, which is where the list finds the creator to start from. + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {accountID: CURRENT_USER_ACCOUNT_ID, email: CURRENT_USER_LOGIN}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: {[CURRENT_USER_ACCOUNT_ID]: {accountID: CURRENT_USER_ACCOUNT_ID, login: CURRENT_USER_LOGIN, displayName: 'Creator'}}, + [`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}` as const]: {reportID: REPORT_ID, ownerAccountID: CURRENT_USER_ACCOUNT_ID}, + }); + await waitForBatchedUpdates(); + }); + + it('writes the selection to the draft expense when applied, and closes', async () => { + // Given a draft expense with the creator as its only attendee + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + comment: {attendees: [creator]}, + }); + await waitForBatchedUpdates(); + const onClose = jest.fn(); + + // When the attendees row's dropdown opens on the create form and the user adds a guest + renderDropdown(onClose); + await waitForBatchedUpdates(); + expect(mockModalProps?.attendees).toEqual([creator]); + expect(mockModalProps?.iouType).toBe(CONST.IOU.TYPE.SUBMIT); + expect(mockModalProps?.shouldShowHeader).toBe(true); + act(() => mockModalProps?.onAttendeesChanged([creator, guest])); + + // Then the list is handed the new selection and stays open, and nothing is written yet + expect(mockModalProps?.attendees).toEqual([creator, guest]); + expect(setMoneyRequestAttendees).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + + // When the user applies the selection + act(() => mockModalProps?.onApply()); + + // Then the draft is saved with both, as the full page does on Save, and the list closes + expect(setMoneyRequestAttendees).toHaveBeenCalledTimes(1); + expect(setMoneyRequestAttendees).toHaveBeenCalledWith(TRANSACTION_ID, [creator, guest], true); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('closes without writing when the applied selection is unchanged', async () => { + // Given a draft expense with the creator as its only attendee + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + comment: {attendees: [creator]}, + }); + await waitForBatchedUpdates(); + const onClose = jest.fn(); + + // When the dropdown opens and the user applies the selection as it is + renderDropdown(onClose); + await waitForBatchedUpdates(); + act(() => mockModalProps?.onApply()); + + // Then the list closes and the draft is left alone, as the full page skips the write when nothing changed + expect(onClose).toHaveBeenCalledTimes(1); + expect(setMoneyRequestAttendees).not.toHaveBeenCalled(); + }); + + it('reopens from the draft after being dismissed with an unapplied selection', async () => { + // Given a draft expense with the creator as its only attendee + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + comment: {attendees: [creator]}, + }); + await waitForBatchedUpdates(); + const onClose = jest.fn(); + + // When the user adds a guest, dismisses the list without applying, and opens it again. The row keeps the dropdown + // mounted once it has been opened, so it is the same component instance each time. + const {rerender} = render(buildDropdown(onClose)); + await waitForBatchedUpdates(); + act(() => mockModalProps?.onAttendeesChanged([creator, guest])); + rerender(buildDropdown(onClose, false)); + rerender(buildDropdown(onClose, true)); + + // Then the list starts from the draft again, as the dismissed selection was never applied + expect(mockModalProps?.attendees).toEqual([creator]); + expect(setMoneyRequestAttendees).not.toHaveBeenCalled(); + }); + + it('starts from the creator when the draft has no attendees yet', async () => { + // Given a draft expense that has not had its attendees touched + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, {transactionID: TRANSACTION_ID, reportID: REPORT_ID}); + await waitForBatchedUpdates(); + + // When the dropdown opens + renderDropdown(); + await waitForBatchedUpdates(); + + // Then the list starts with the creator selected, as the full page does, since the creator always attends + expect(mockModalProps?.attendees).toEqual([expect.objectContaining({email: CURRENT_USER_LOGIN, displayName: 'Creator'})]); + }); + + it('stays open and leaves the draft alone when an empty selection is applied', async () => { + // Given a draft expense with the creator as its attendee + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + comment: {attendees: [creator]}, + }); + await waitForBatchedUpdates(); + const onClose = jest.fn(); + + // When the user deselects the creator too, which the list flags as needing at least one attendee, and confirms with Enter + renderDropdown(onClose); + await waitForBatchedUpdates(); + act(() => mockModalProps?.onAttendeesChanged([])); + act(() => mockModalProps?.onApply()); + + // Then nothing is written and the list stays open showing the empty selection, since an expense needs an attendee and + // the full page never saves none + expect(setMoneyRequestAttendees).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(mockModalProps?.attendees).toEqual([]); + }); +});