From 11e98e9d9d18a135c3ae7add44d02450f469e18c Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Mon, 29 Sep 2025 12:08:38 +0800 Subject: [PATCH 01/17] feat: refactor participant selector and vacation delegate page --- .../MoneyRequestParticipantsSelector.tsx | 386 +++++++----------- .../CustomStatus/VacationDelegatePage.tsx | 110 ++--- 2 files changed, 180 insertions(+), 316 deletions(-) diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 36e8aad40760..249a1577857b 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -15,38 +15,26 @@ import FormHelpMessage from '@components/FormHelpMessage'; import {UserPlus} from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import {useOptionsList} from '@components/OptionListContextProvider'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import SelectionList from '@components/SelectionListWithSections'; import InviteMemberListItem from '@components/SelectionListWithSections/InviteMemberListItem'; import type {SelectionListHandle} from '@components/SelectionListWithSections/types'; import useContactImport from '@hooks/useContactImport'; -import useDebouncedState from '@hooks/useDebouncedState'; import useDismissedReferralBanners from '@hooks/useDismissedReferralBanners'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getPlatform from '@libs/getPlatform'; import goToSettings from '@libs/goToSettings'; import {isMovingTransactionFromTrackExpense} from '@libs/IOUUtils'; -import memoize from '@libs/memoize'; import Navigation from '@libs/Navigation/Navigation'; import type {Option, Section} from '@libs/OptionsListUtils'; -import { - filterAndOrderOptions, - formatSectionsFromSearchTerm, - getHeaderMessage, - getParticipantsOption, - getPersonalDetailSearchTerms, - getPolicyExpenseReportOption, - getValidOptions, - isCurrentUser, - orderOptions, -} from '@libs/OptionsListUtils'; +import {formatSectionsFromSearchTerm, getHeaderMessage, getParticipantsOption, getPersonalDetailSearchTerms, getPolicyExpenseReportOption, isCurrentUser} from '@libs/OptionsListUtils'; import {isPaidGroupPolicy as isPaidGroupPolicyUtil} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import {isInvoiceRoom} from '@libs/ReportUtils'; @@ -61,8 +49,6 @@ import type {Participant} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import ImportContactButton from './ImportContactButton'; -const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'MoneyRequestParticipantsSelector.getValidOptions'}); - type MoneyRequestParticipantsSelectorProps = { /** Callback to request parent modal to go to next step, which should be split */ onFinish?: (value?: string) => void; @@ -102,30 +88,23 @@ function MoneyRequestParticipantsSelector({ }: MoneyRequestParticipantsSelectorProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const {contactPermissionState, contacts, setContactPermissionState, importAndSaveContacts} = useContactImport(); const platform = getPlatform(); const isNative = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS; const showImportContacts = isNative && !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const referralContentType = CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE; const {isOffline} = useNetwork(); const personalDetails = usePersonalDetails(); const {isDismissed} = useDismissedReferralBanners({referralContentType}); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); - const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const policy = usePolicy(activePolicyID); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {canBeMissing: true, initWithStoredValues: false}); const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true, selector: emailSelector}); - const {options, areOptionsInitialized, initializeOptions} = useOptionsList({ - shouldInitialize: didScreenTransitionEnd, - }); const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {canBeMissing: true, selector: reportsSelector}); const [textInputAutoFocus, setTextInputAutoFocus] = useState(!isNative); const selectionListRef = useRef(null); - const cleanSearchTerm = useMemo(() => debouncedSearchTerm.trim().toLowerCase(), [debouncedSearchTerm]); const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; const isPaidGroupPolicy = useMemo(() => isPaidGroupPolicyUtil(policy), [policy]); @@ -135,120 +114,113 @@ function MoneyRequestParticipantsSelector({ const hasBeenAddedToNudgeMigration = !!tryNewDot?.nudgeMigration?.timestamp; const canShowManagerMcTest = useMemo(() => !hasBeenAddedToNudgeMigration && action !== CONST.IOU.ACTION.SUBMIT, [hasBeenAddedToNudgeMigration, action]); - useEffect(() => { - searchInServer(debouncedSearchTerm.trim()); - }, [debouncedSearchTerm]); - - useEffect(() => { - // This is necessary to ensure the options list is always up to date - // e.g. if the approver was changed in the policy, we need to update the options list - initializeOptions(); - }, [initializeOptions]); - - const defaultOptions = useMemo(() => { - if (!areOptionsInitialized || !didScreenTransitionEnd) { - return { - userToInvite: null, - recentReports: [], - personalDetails: [], - currentUserOption: null, - headerMessage: '', - }; - } - - const optionList = memoizedGetValidOptions( - { - reports: options.reports, - personalDetails: options.personalDetails.concat(contacts), - }, - { - betas, - selectedOptions: participants as Participant[], - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - - // If we are using this component in the "Submit expense" or the combined submit/track flow then we pass the includeOwnedWorkspaceChats argument so that the current user - // sees the option to submit an expense from their admin on their own Expense Chat. - includeOwnedWorkspaceChats: iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT, - - // Sharing with an accountant involves inviting them to the workspace and that requires admin access. - excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, - - // Per diem expenses should only be submitted to workspaces, not individual users - includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest, - includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, - action, - shouldSeparateSelfDMChat: iouType !== CONST.IOU.TYPE.INVOICE, - shouldSeparateWorkspaceChat: true, - includeSelfDM: !isMovingTransactionFromTrackExpense(action) && iouType !== CONST.IOU.TYPE.INVOICE, - canShowManagerMcTest, - isPerDiemRequest, - showRBR: false, - }, - ); + /** + * Adds a single participant to the expense + * + * @param {Object} option + */ + const addSingleParticipant = useCallback( + (option: Participant & Option) => { + const newParticipants: Participant[] = [ + { + ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText', 'policyID', 'isSelfDM', 'text', 'phoneNumber', 'displayName'), + selected: true, + iouType, + }, + ]; - const orderedOptions = orderOptions(optionList); + if (iouType === CONST.IOU.TYPE.INVOICE) { + const policyID = option.item && isInvoiceRoom(option.item) ? option.policyID : getInvoicePrimaryWorkspace(currentUserLogin)?.id; + newParticipants.push({ + policyID, + isSender: true, + selected: false, + iouType, + }); + } - return { - ...optionList, - ...orderedOptions, - }; - }, [ - action, - contacts, - areOptionsInitialized, - betas, - didScreenTransitionEnd, - iouType, - isCategorizeOrShareAction, - options.personalDetails, - options.reports, - participants, - isPerDiemRequest, - canShowManagerMcTest, - ]); + onParticipantsAdded(newParticipants); - const chatOptions = useMemo(() => { - if (!areOptionsInitialized) { - return { - userToInvite: null, - recentReports: [], - personalDetails: [], - currentUserOption: null, - headerMessage: '', - workspaceChats: [], - selfDMChat: null, - }; - } + if (!option.isSelfDM) { + onFinish(); + } + }, + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to trigger this callback when iouType changes + [onFinish, onParticipantsAdded, currentUserLogin], + ); - const newOptions = filterAndOrderOptions(defaultOptions, debouncedSearchTerm, countryCode, { - canInviteUser: !isCategorizeOrShareAction && !isPerDiemRequest, + // Configuration for useSearchSelector hook + const getValidOptionsConfig = useMemo( + () => ({ selectedOptions: participants as Participant[], excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - preferPolicyExpenseChat: isPaidGroupPolicy, - preferRecentExpenseReports: action === CONST.IOU.ACTION.CREATE, + // If we are using this component in the "Submit expense" or the combined submit/track flow then we pass the includeOwnedWorkspaceChats argument so that the current user + // sees the option to submit an expense from their admin on their own Expense Chat. + includeOwnedWorkspaceChats: iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT, + // Sharing with an accountant involves inviting them to the workspace and that requires admin access. + excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, + // Per diem expenses should only be submitted to workspaces, not individual users + includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest, + includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, + action, + shouldSeparateSelfDMChat: iouType !== CONST.IOU.TYPE.INVOICE, + shouldSeparateWorkspaceChat: true, + includeSelfDM: !isMovingTransactionFromTrackExpense(action) && iouType !== CONST.IOU.TYPE.INVOICE, + canShowManagerMcTest, + isPerDiemRequest, + showRBR: false, + }), + [participants, iouType, action, isCategorizeOrShareAction, isPerDiemRequest, canShowManagerMcTest], + ); + + const {searchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized, onListEndReached, contactState} = + useSearchSelector({ + selectionMode: isIOUSplit ? CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI : CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, + includeUserToInvite: !isCategorizeOrShareAction && !isPerDiemRequest, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + includeRecentReports: true, + getValidOptionsConfig, + shouldInitialize: didScreenTransitionEnd, + enablePhoneContacts: isNative, + contactOptions: contacts, + initialSelected: participants as OptionData[], + onSelectionChange: (options: OptionData[]) => { + if (isIOUSplit) { + onParticipantsAdded(options as Participant[]); + } + }, + onSingleSelect: (option: OptionData) => { + if (!isIOUSplit) { + addSingleParticipant(option as Participant & Option); + } + }, }); - return newOptions; - }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, participants, isPaidGroupPolicy, isCategorizeOrShareAction, action, isPerDiemRequest, countryCode]); + + const cleanSearchTerm = useMemo(() => searchTerm.trim().toLowerCase(), [searchTerm]); + + useEffect(() => { + searchInServer(searchTerm.trim()); + }, [searchTerm]); const inputHelperText = useMemo( () => getHeaderMessage( - (chatOptions.personalDetails ?? []).length + (chatOptions.recentReports ?? []).length + (chatOptions.workspaceChats ?? []).length !== 0 || - !isEmptyObject(chatOptions.selfDMChat), - !!chatOptions?.userToInvite, - debouncedSearchTerm.trim(), - participants.some((participant) => getPersonalDetailSearchTerms(participant).join(' ').toLowerCase().includes(cleanSearchTerm)), + (availableOptions.personalDetails ?? []).length + (availableOptions.recentReports ?? []).length + (availableOptions.workspaceChats ?? []).length !== 0 || + !isEmptyObject(availableOptions.selfDMChat), + !!availableOptions?.userToInvite, + searchTerm.trim(), + selectedOptions.some((participant) => getPersonalDetailSearchTerms(participant).join(' ').toLowerCase().includes(cleanSearchTerm)), ), [ - chatOptions.personalDetails, - chatOptions.recentReports, - chatOptions.selfDMChat, - chatOptions?.userToInvite, - chatOptions.workspaceChats, + availableOptions.personalDetails, + availableOptions.recentReports, + availableOptions.selfDMChat, + availableOptions?.userToInvite, + availableOptions.workspaceChats, cleanSearchTerm, - debouncedSearchTerm, - participants, + searchTerm, + selectedOptions, ], ); /** @@ -257,59 +229,52 @@ function MoneyRequestParticipantsSelector({ */ const [sections, header] = useMemo(() => { const newSections: Section[] = []; - if (!areOptionsInitialized || !didScreenTransitionEnd) { + if (!areOptionsInitialized) { return [newSections, '']; } - const formatResults = formatSectionsFromSearchTerm( - debouncedSearchTerm, - participants.map((participant) => ({...participant, reportID: participant.reportID})) as OptionData[], - chatOptions.recentReports, - chatOptions.personalDetails, - personalDetails, - true, - undefined, - reportAttributesDerived, - ); - - newSections.push(formatResults.section); + // Selected options section (for multi-select mode) + if (isIOUSplit && selectedOptionsForDisplay.length > 0) { + const formatResults = formatSectionsFromSearchTerm(searchTerm, selectedOptionsForDisplay, [], [], personalDetails, true, undefined, reportAttributesDerived); + newSections.push(formatResults.section); + } newSections.push({ title: translate('workspace.common.workspace'), - data: chatOptions.workspaceChats ?? [], - shouldShow: (chatOptions.workspaceChats ?? []).length > 0, + data: availableOptions.workspaceChats ?? [], + shouldShow: (availableOptions.workspaceChats ?? []).length > 0, }); newSections.push({ title: translate('workspace.invoices.paymentMethods.personal'), - data: chatOptions.selfDMChat ? [chatOptions.selfDMChat] : [], - shouldShow: !!chatOptions.selfDMChat, + data: availableOptions.selfDMChat ? [availableOptions.selfDMChat] : [], + shouldShow: !!availableOptions.selfDMChat, }); newSections.push({ title: translate('common.recents'), - data: isPerDiemRequest ? chatOptions.recentReports.filter((report) => report.isPolicyExpenseChat) : chatOptions.recentReports, - shouldShow: (isPerDiemRequest ? chatOptions.recentReports.filter((report) => report.isPolicyExpenseChat) : chatOptions.recentReports).length > 0, + data: isPerDiemRequest ? availableOptions.recentReports.filter((report) => report.isPolicyExpenseChat) : availableOptions.recentReports, + shouldShow: (isPerDiemRequest ? availableOptions.recentReports.filter((report) => report.isPolicyExpenseChat) : availableOptions.recentReports).length > 0, }); newSections.push({ title: translate('common.contacts'), - data: chatOptions.personalDetails, - shouldShow: chatOptions.personalDetails.length > 0 && !isPerDiemRequest, + data: availableOptions.personalDetails, + shouldShow: availableOptions.personalDetails.length > 0 && !isPerDiemRequest, }); if ( - chatOptions.userToInvite && + availableOptions.userToInvite && !isCurrentUser({ - ...chatOptions.userToInvite, - accountID: chatOptions.userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, - status: chatOptions.userToInvite?.status ?? undefined, + ...availableOptions.userToInvite, + accountID: availableOptions.userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, + status: availableOptions.userToInvite?.status ?? undefined, }) && !isPerDiemRequest ) { newSections.push({ title: undefined, - data: [chatOptions.userToInvite].map((participant) => { + data: [availableOptions.userToInvite].map((participant) => { const isPolicyExpenseChat = participant?.isPolicyExpenseChat ?? false; return isPolicyExpenseChat ? getPolicyExpenseReportOption(participant, reportAttributesDerived) : getParticipantsOption(participant, personalDetails); }), @@ -325,105 +290,38 @@ function MoneyRequestParticipantsSelector({ return [newSections, headerMessage]; }, [ areOptionsInitialized, - didScreenTransitionEnd, - debouncedSearchTerm, - participants, - chatOptions.recentReports, - chatOptions.personalDetails, - chatOptions.workspaceChats, - chatOptions.selfDMChat, - chatOptions.userToInvite, + searchTerm, + selectedOptionsForDisplay, + availableOptions.recentReports, + availableOptions.personalDetails, + availableOptions.workspaceChats, + availableOptions.selfDMChat, + availableOptions.userToInvite, personalDetails, translate, isPerDiemRequest, showImportContacts, reportAttributesDerived, inputHelperText, + isIOUSplit, ]); - /** - * Adds a single participant to the expense - * - * @param {Object} option - */ - const addSingleParticipant = useCallback( - (option: Participant & Option) => { - const newParticipants: Participant[] = [ - { - ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText', 'policyID', 'isSelfDM', 'text', 'phoneNumber', 'displayName'), - selected: true, - iouType, - }, - ]; - - if (iouType === CONST.IOU.TYPE.INVOICE) { - const policyID = option.item && isInvoiceRoom(option.item) ? option.policyID : getInvoicePrimaryWorkspace(currentUserLogin)?.id; - newParticipants.push({ - policyID, - isSender: true, - selected: false, - iouType, - }); - } - - onParticipantsAdded(newParticipants); - - if (!option.isSelfDM) { - onFinish(); - } - }, - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to trigger this callback when iouType changes - [onFinish, onParticipantsAdded, currentUserLogin], - ); - /** * Removes a selected option from list if already selected. If not already selected add this option to the list. * @param {Object} option */ const addParticipantToSelection = useCallback( (option: Participant) => { - const isOptionSelected = (selectedOption: Participant) => { - if (selectedOption.accountID && selectedOption.accountID === option?.accountID) { - return true; - } - - if (selectedOption.reportID && selectedOption.reportID === option?.reportID) { - return true; - } - - return false; - }; - const isOptionInList = participants.some(isOptionSelected); - let newSelectedOptions: Participant[]; - - if (isOptionInList) { - newSelectedOptions = lodashReject(participants, isOptionSelected); - } else { - newSelectedOptions = [ - ...participants, - { - accountID: option.accountID, - login: option.login, - isPolicyExpenseChat: option.isPolicyExpenseChat, - reportID: option.reportID, - selected: true, - searchText: option.searchText, - iouType, - }, - ]; - } - - onParticipantsAdded(newSelectedOptions); + toggleSelection(option as OptionData); }, - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to trigger this callback when iouType changes - [participants, onParticipantsAdded], + [toggleSelection], ); // Right now you can't split a request with a workspace and other additional participants // This is getting properly fixed in https://github.com/Expensify/App/issues/27508, but as a stop-gap to prevent // the app from crashing on native when you try to do this, we'll going to hide the button if you have a workspace and other participants - const hasPolicyExpenseChatParticipant = participants.some((participant) => participant.isPolicyExpenseChat); - const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant; + const hasPolicyExpenseChatParticipant = selectedOptions.some((participant) => participant.isPolicyExpenseChat); + const shouldShowSplitBillErrorMessage = selectedOptions.length > 1 && hasPolicyExpenseChatParticipant; const isAllowedToSplit = ![CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK, CONST.IOU.TYPE.INVOICE].some((option) => option === iouType) && @@ -431,8 +329,8 @@ function MoneyRequestParticipantsSelector({ const handleConfirmSelection = useCallback( (keyEvent?: GestureResponderEvent | KeyboardEvent, option?: Participant) => { - const shouldAddSingleParticipant = option && !participants.length; - if (shouldShowSplitBillErrorMessage || (!participants.length && !option)) { + const shouldAddSingleParticipant = option && !selectedOptions.length; + if (shouldShowSplitBillErrorMessage || (!selectedOptions.length && !option)) { return; } @@ -443,7 +341,7 @@ function MoneyRequestParticipantsSelector({ onFinish(CONST.IOU.TYPE.SPLIT); }, - [shouldShowSplitBillErrorMessage, onFinish, addSingleParticipant, participants], + [shouldShowSplitBillErrorMessage, onFinish, addSingleParticipant, selectedOptions], ); const showLoadingPlaceholder = useMemo(() => !areOptionsInitialized || !didScreenTransitionEnd, [areOptionsInitialized, didScreenTransitionEnd]); @@ -469,7 +367,7 @@ function MoneyRequestParticipantsSelector({ }, [importAndSaveContacts, setContactPermissionState]); const footerContent = useMemo(() => { - if (isDismissed && !shouldShowSplitBillErrorMessage && !participants.length) { + if (isDismissed && !shouldShowSplitBillErrorMessage && !selectedOptions.length) { return; } @@ -478,7 +376,7 @@ function MoneyRequestParticipantsSelector({ {shouldShowReferralBanner && !isCategorizeOrShareAction && ( )} @@ -490,7 +388,7 @@ function MoneyRequestParticipantsSelector({ /> )} - {!!participants.length && !isCategorizeOrShareAction && ( + {!!selectedOptions.length && !isCategorizeOrShareAction && (