Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
74 changes: 74 additions & 0 deletions src/components/AttendeePicker/AttendeePickerModal.tsx
Original file line number Diff line number Diff line change
@@ -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<AnchoredSelectionListPopoverProps, 'optionCount' | 'isSearchable' | 'title' | 'backButtonSentryLabel' | 'headerActions' | 'children'>;

/**
* 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 (
<AnchoredSelectionListPopover
{...popoverProps}
// The list always has a search input and, between recents and contacts, more rows than the container shows at
// once, so the container opens at its full height rather than fitting a count.
optionCount={CONST.STANDARD_LIST_ITEM_LIMIT}
isSearchable
title={translate('iou.attendees')}
backButtonSentryLabel={CONST.SENTRY_LABEL.ATTENDEE_PICKER_MODAL.BACK_BUTTON}
>
<ScreenWrapperStatusContext.Provider value={SETTLED_SCREEN_STATUS}>
<MoneyRequestAttendeeSelector
Comment thread
DylanDylann marked this conversation as resolved.
Resolved
attendees={attendees}
onAttendeesAdded={onAttendeesChanged}
onFinish={onApply}
iouType={iouType}
searchInputLabel={translate('selectionList.searchForSomeone')}
confirmButtonText={translate('common.apply')}
confirmButtonSize={CONST.BUTTON_SIZE.MEDIUM}
/>
</ScreenWrapperStatusContext.Provider>
</AnchoredSelectionListPopover>
);
}

export default AttendeePickerModal;
export type {AttendeePickerModalProps};
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -78,16 +84,32 @@ 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 (
<ExpenseFieldRow
<ExpenseFieldDropdown
name={attendeesDescription}
valueComponent={attendeePills}
// The creator is always an attendee, so the row reads as filled in from the start.
hasValueComponent
accessibilityLabel={attendeesAccessibilityLabel}
errorText={shouldDisplayAttendeesError ? translate(formError as TranslationPaths) : ''}
onPress={openAttendeePage}
shouldOpenInDropdown={shouldOpenInDropdown}
listHeaderHeight={SELECTION_LIST_POPOVER_HEADER_HEIGHT}
renderDropdown={(dropdownProps) =>
!!transactionID && (
<AttendeeFieldDropdown
{...dropdownProps}
transactionID={transactionID}
iouType={iouType}
/>
)
}
isInteractive={!isReadOnly}
sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.ATTENDEES_FIELD}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 & {
Comment thread
DylanDylann marked this conversation as resolved.
Resolved
/** 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}`);
Comment thread
DylanDylann marked this conversation as resolved.
Resolved
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<Attendee[]>();
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 (
<AttendeePickerModal
{...popoverProps}
isVisible={isVisible}
onClose={onClose}
shouldShowHeader
attendees={attendees}
onAttendeesChanged={setPickedAttendees}
onApply={applyAttendees}
iouType={iouType}
/>
);
}

export default AttendeeFieldDropdown;
28 changes: 24 additions & 4 deletions src/pages/iou/request/MoneyRequestAttendeeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<typeof CONST.BUTTON_SIZE>;
};

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();
Expand Down Expand Up @@ -163,12 +183,12 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde
<Button
variant={CONST.BUTTON_VARIANT.SUCCESS}
onPress={confirmSelection}
size={CONST.BUTTON_SIZE.LARGE}
size={confirmButtonSize}
isDisabled={shouldShowErrorMessage}
sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.ATTENDEES_SAVE_BUTTON}
>
<Button.KeyboardShortcut />
<Button.Text>{translate('common.save')}</Button.Text>
<Button.Text>{confirmButtonText ?? translate('common.save')}</Button.Text>
</Button>
</>
);
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading