diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 00965d197937..a34f2bc7d0fc 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -425,7 +425,7 @@ function MoneyRequestConfirmationList({ text = translate('common.next'); } } else if (isTypeTrackExpense) { - text = translate('iou.trackExpense'); + text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.splitExpense'); } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { @@ -434,7 +434,7 @@ function MoneyRequestConfirmationList({ text = translate('iou.submitAmount', {amount: formattedAmount}); } } else { - const translationKey = isTypeSplit ? 'iou.splitAmount' : 'iou.submitAmount'; + const translationKey = isTypeSplit ? 'iou.splitAmount' : 'iou.createExpenseWithAmount'; text = translate(translationKey, {amount: formattedAmount}); } return [ diff --git a/src/languages/en.ts b/src/languages/en.ts index 3d35fed3e0a4..29856127bc0a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -864,6 +864,7 @@ const translations = { createExpense: 'Create expense', trackExpense: 'Track expense', chooseRecipient: 'Choose recipient', + createExpenseWithAmount: ({amount}: {amount: string}) => `Create ${amount} expense`, confirmDetails: 'Confirm details', pay: 'Pay', cancelPayment: 'Cancel payment', diff --git a/src/languages/es.ts b/src/languages/es.ts index cb7f53424958..33bc1fe81b09 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -860,6 +860,7 @@ const translations = { trackExpense: 'Seguimiento de gastos', chooseRecipient: 'Elige destinatario', confirmDetails: 'Confirma los detalles', + createExpenseWithAmount: ({amount}: {amount: string}) => `Crear un gasto de ${amount}`, pay: 'Pagar', cancelPayment: 'Cancelar el pago', cancelPaymentConfirmation: '¿Estás seguro de que quieres cancelar este pago?', diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index b588b3dd5359..9d391172e537 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -108,6 +108,8 @@ type GetOptionsConfig = { action?: IOUAction; recentAttendees?: Attendee[]; shouldBoldTitleByDefault?: boolean; + shouldSeparateWorkspaceChat?: boolean; + shouldSeparateSelfDMChat?: boolean; }; type GetUserToInviteConfig = { @@ -138,6 +140,8 @@ type Options = { personalDetails: ReportUtils.OptionData[]; userToInvite: ReportUtils.OptionData | null; currentUserOption: ReportUtils.OptionData | null | undefined; + workspaceChats?: ReportUtils.OptionData[]; + selfDMChat?: ReportUtils.OptionData | undefined; }; type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; showPersonalDetails?: boolean}; @@ -165,7 +169,7 @@ type OrderReportOptionsConfig = { preferRecentExpenseReports?: boolean; }; -type ReportAndPersonalDetailOptions = Pick; +type ReportAndPersonalDetailOptions = Pick; /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can @@ -1001,6 +1005,23 @@ function orderReportOptionsWithSearch( ); } +function orderWorkspaceOptions(options: ReportUtils.OptionData[]): ReportUtils.OptionData[] { + return lodashOrderBy( + options, + [ + (option) => { + // Put default workspace on top + if (option.isPolicyExpenseChat && option.policyID === activePolicyID) { + return 0; + } + + return 1; + }, + ], + ['asc'], + ); +} + function sortComparatorReportOptionByArchivedStatus(option: ReportUtils.OptionData) { return option.private_isArchived ? 1 : 0; } @@ -1028,10 +1049,12 @@ function orderOptions(options: ReportAndPersonalDetailOptions, searchValue?: str orderedReportOptions = orderReportOptions(options.recentReports); } const orderedPersonalDetailsOptions = orderPersonalDetailsOptions(options.personalDetails); + const orderedWorkspaceChats = orderWorkspaceOptions(options?.workspaceChats ?? []); return { recentReports: orderedReportOptions, personalDetails: orderedPersonalDetailsOptions, + workspaceChats: orderedWorkspaceChats, }; } @@ -1140,6 +1163,8 @@ function getValidOptions( action, recentAttendees, shouldBoldTitleByDefault = true, + shouldSeparateSelfDMChat = false, + shouldSeparateWorkspaceChat = false, }: GetOptionsConfig = {}, ): Options { const topmostReportId = Navigation.getTopmostReportId() ?? '-1'; @@ -1215,6 +1240,12 @@ function getValidOptions( return true; }); + let workspaceChats: ReportUtils.OptionData[] = []; + + if (shouldSeparateWorkspaceChat) { + workspaceChats = allReportOptions.filter((option) => option.isOwnPolicyExpenseChat && !option.private_isArchived); + } + const allPersonalDetailsOptions = includeP2P ? options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail && (includeDomainEmail || !Str.isDomainEmail(detail.login))) : []; @@ -1325,6 +1356,15 @@ function getValidOptions( } const currentUserOption = allPersonalDetailsOptions.find((personalDetailsOption) => personalDetailsOption.login === currentUserLogin); + let selfDMChat: ReportUtils.OptionData | undefined; + + if (shouldSeparateWorkspaceChat) { + recentReportOptions = recentReportOptions.filter((option) => !option.isPolicyExpenseChat); + } + if (shouldSeparateSelfDMChat) { + selfDMChat = recentReportOptions.find((option) => option.isSelfDM); + recentReportOptions = recentReportOptions.filter((option) => !option.isSelfDM); + } return { personalDetails: personalDetailsOptions, @@ -1333,6 +1373,8 @@ function getValidOptions( // User to invite is generated by the search input of a user. // As this function isn't concerned with any search input yet, this is null (will be set when using filterOptions). userToInvite: null, + workspaceChats, + selfDMChat, }; } @@ -1573,6 +1615,7 @@ function formatSectionsFromSearchTerm( filteredPersonalDetails: ReportUtils.OptionData[], personalDetails: OnyxEntry = {}, shouldGetOptionDetails = false, + filteredWorkspaceChats: ReportUtils.OptionData[] = [], ): SectionForSearchTerm { // We show the selected participants at the top of the list when there is no search term or maximum number of participants has already been selected // However, if there is a search term we remove the selected participants from the top of the list unless they are part of the search results @@ -1598,8 +1641,9 @@ function formatSectionsFromSearchTerm( const selectedParticipantsWithoutDetails = selectedOptions.filter((participant) => { const accountID = participant.accountID ?? null; const isPartOfSearchTerm = getPersonalDetailSearchTerms(participant).join(' ').toLowerCase().includes(cleanSearchTerm); - const isReportInRecentReports = filteredRecentReports.some((report) => report.accountID === accountID); + const isReportInRecentReports = filteredRecentReports.some((report) => report.accountID === accountID) || filteredWorkspaceChats.some((report) => report.accountID === accountID); const isReportInPersonalDetails = filteredPersonalDetails.some((personalDetail) => personalDetail.accountID === accountID); + return isPartOfSearchTerm && !isReportInRecentReports && !isReportInPersonalDetails; }); @@ -1685,6 +1729,23 @@ function filterReports(reports: ReportUtils.OptionData[], searchTerms: string[]) return filteredReports; } +function filterWorkspaceChats(reports: ReportUtils.OptionData[], searchTerms: string[]): ReportUtils.OptionData[] { + const filteredReports = searchTerms.reduceRight( + (items, term) => + filterArrayByMatch(items, term, (item) => { + const values: string[] = []; + if (item.text) { + values.push(item.text); + } + return uniqFast(values); + }), + // We start from all unfiltered reports: + reports, + ); + + return filteredReports; +} + function filterPersonalDetails(personalDetails: ReportUtils.OptionData[], searchTerms: string[]): ReportUtils.OptionData[] { return searchTerms.reduceRight( (items, term) => @@ -1736,6 +1797,34 @@ function filterUserToInvite(options: Omit, searchValue: }); } +function filterSelfDMChat(report: ReportUtils.OptionData, searchTerms: string[]): ReportUtils.OptionData | undefined { + const isMatch = searchTerms.every((term) => { + const values: string[] = []; + + if (report.text) { + values.push(report.text); + } + if (report.login) { + values.push(report.login); + values.push(report.login.replace(CONST.EMAIL_SEARCH_REGEX, '')); + } + if (report.isThread) { + if (report.alternateText) { + values.push(report.alternateText); + } + } else if (!!report.isChatRoom || !!report.isPolicyExpenseChat) { + if (report.subtitle) { + values.push(report.subtitle); + } + } + + // Remove duplicate values and check if the term matches any value + return uniqFast(values).some((value) => value.includes(term)); + }); + + return isMatch ? report : undefined; +} + function filterOptions(options: Options, searchInputValue: string, config?: FilterUserToInviteConfig): Options { const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue))); const searchValue = parsedPhoneNumber.possible && parsedPhoneNumber.number?.e164 ? parsedPhoneNumber.number.e164 : searchInputValue.toLowerCase(); @@ -1753,12 +1842,17 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt searchValue, config, ); + const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); + + const selfDMChat = options.selfDMChat ? filterSelfDMChat(options.selfDMChat, searchTerms) : undefined; return { personalDetails, recentReports, userToInvite, currentUserOption, + workspaceChats, + selfDMChat, }; } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 4fa704944bc9..c89d7b52b6df 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -347,7 +347,7 @@ function isInviteOrRemovedAction( /** * Returns whether the comment is a thread parent message/the first message in a thread */ -function isThreadParentMessage(reportAction: OnyxEntry, reportID: string): boolean { +function isThreadParentMessage(reportAction: OnyxEntry, reportID: string | undefined): boolean { const {childType, childVisibleActionCount = 0, childReportID} = reportAction ?? {}; return childType === CONST.REPORT.TYPE.CHAT && (childVisibleActionCount > 0 || String(childReportID) === reportID); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 589c5f1a6adc..5596fe905d9f 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -6409,7 +6409,7 @@ function isReportNotFound(report: OnyxEntry): boolean { /** * Check if the report is the parent report of the currently viewed report or at least one child report has report action */ -function shouldHideReport(report: OnyxEntry, currentReportId: string): boolean { +function shouldHideReport(report: OnyxEntry, currentReportId: string | undefined): boolean { const currentReport = getReportOrDraftReport(currentReportId); const parentReport = getParentReport(!isEmptyObject(currentReport) ? currentReport : undefined); const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? {}; @@ -6579,7 +6579,7 @@ function hasReportErrorsOtherThanFailedReceipt(report: Report, doesReportHaveVio type ShouldReportBeInOptionListParams = { report: OnyxEntry; - currentReportId: string; + currentReportId: string | undefined; isInFocusMode: boolean; betas: OnyxEntry; policies: OnyxCollection; diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 07e34d9692b1..dfa0d7f37b8b 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -7,8 +7,6 @@ import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import EmptySelectionListContent from '@components/EmptySelectionListContent'; import FormHelpMessage from '@components/FormHelpMessage'; -import * as Expensicons from '@components/Icon/Expensicons'; -import MenuItem from '@components/MenuItem'; import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; @@ -34,6 +32,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Participant} from '@src/types/onyx/IOU'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; type MoneyRequestParticipantsSelectorProps = { /** Callback to request parent modal to go to next step, which should be split */ @@ -42,9 +41,6 @@ type MoneyRequestParticipantsSelectorProps = { /** Callback to add participants in MoneyRequestModal */ onParticipantsAdded: (value: Participant[]) => void; - /** Callback to navigate to Track Expense confirmation flow */ - onTrackExpensePress?: () => void; - /** Selected participants from MoneyRequestModal with login */ participants?: Participant[] | typeof CONST.EMPTY_ARRAY; @@ -53,20 +49,9 @@ type MoneyRequestParticipantsSelectorProps = { /** The action of the IOU, i.e. create, split, move */ action: IOUAction; - - /** Whether we should display the Track Expense button at the top of the participants list */ - shouldDisplayTrackExpenseButton?: boolean; }; -function MoneyRequestParticipantsSelector({ - participants = CONST.EMPTY_ARRAY, - onTrackExpensePress, - onFinish, - onParticipantsAdded, - iouType, - action, - shouldDisplayTrackExpenseButton, -}: MoneyRequestParticipantsSelectorProps) { +function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onFinish, onParticipantsAdded, iouType, action}: MoneyRequestParticipantsSelectorProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -123,6 +108,9 @@ function MoneyRequestParticipantsSelector({ includeP2P: !isCategorizeOrShareAction, includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, action, + shouldSeparateSelfDMChat: true, + shouldSeparateWorkspaceChat: true, + includeSelfDM: true, }, ); @@ -142,6 +130,8 @@ function MoneyRequestParticipantsSelector({ personalDetails: [], currentUserOption: null, headerMessage: '', + workspaceChats: [], + selfDMChat: null, }; } @@ -168,7 +158,7 @@ function MoneyRequestParticipantsSelector({ const formatResults = OptionsListUtils.formatSectionsFromSearchTerm( debouncedSearchTerm, - participants.map((participant) => ({...participant, reportID: participant.reportID ?? '-1'})), + participants.map((participant) => ({...participant, reportID: participant.reportID})) as ReportUtils.OptionData[], chatOptions.recentReports, chatOptions.personalDetails, personalDetails, @@ -177,6 +167,17 @@ function MoneyRequestParticipantsSelector({ newSections.push(formatResults.section); + newSections.push({ + title: translate('workspace.common.workspace'), + data: chatOptions.workspaceChats ?? [], + shouldShow: (chatOptions.workspaceChats ?? []).length > 0, + }); + newSections.push({ + title: translate('workspace.invoices.paymentMethods.personal'), + data: chatOptions.selfDMChat ? [chatOptions.selfDMChat] : [], + shouldShow: !!chatOptions.selfDMChat, + }); + newSections.push({ title: translate('common.recents'), data: chatOptions.recentReports, @@ -191,7 +192,11 @@ function MoneyRequestParticipantsSelector({ if ( chatOptions.userToInvite && - !OptionsListUtils.isCurrentUser({...chatOptions.userToInvite, accountID: chatOptions.userToInvite?.accountID ?? -1, status: chatOptions.userToInvite?.status ?? undefined}) + !OptionsListUtils.isCurrentUser({ + ...chatOptions.userToInvite, + accountID: chatOptions.userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, + status: chatOptions.userToInvite?.status ?? undefined, + }) ) { newSections.push({ title: undefined, @@ -204,7 +209,7 @@ function MoneyRequestParticipantsSelector({ } const headerMessage = OptionsListUtils.getHeaderMessage( - (chatOptions.personalDetails ?? []).length + (chatOptions.recentReports ?? []).length !== 0, + (chatOptions.personalDetails ?? []).length + (chatOptions.recentReports ?? []).length + (chatOptions.workspaceChats ?? []).length !== 0 || !isEmptyObject(chatOptions.selfDMChat), !!chatOptions?.userToInvite, debouncedSearchTerm.trim(), participants.some((participant) => OptionsListUtils.getPersonalDetailSearchTerms(participant).join(' ').toLowerCase().includes(cleanSearchTerm)), @@ -218,6 +223,8 @@ function MoneyRequestParticipantsSelector({ participants, chatOptions.recentReports, chatOptions.personalDetails, + chatOptions.selfDMChat, + chatOptions.workspaceChats, chatOptions.userToInvite, personalDetails, translate, @@ -233,7 +240,7 @@ function MoneyRequestParticipantsSelector({ (option: Participant) => { const newParticipants: Participant[] = [ { - ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText', 'policyID'), + ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText', 'policyID', 'isSelfDM', 'text', 'phoneNumber'), selected: true, iouType, }, @@ -250,7 +257,10 @@ function MoneyRequestParticipantsSelector({ } onParticipantsAdded(newParticipants); - onFinish(); + + 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], @@ -336,6 +346,7 @@ function MoneyRequestParticipantsSelector({ sections.forEach((section) => { length += section.data.length; }); + return length; }, [areOptionsInitialized, sections]); @@ -343,22 +354,6 @@ function MoneyRequestParticipantsSelector({ const shouldShowReferralBanner = !isDismissed && iouType !== CONST.IOU.TYPE.INVOICE && !shouldShowListEmptyContent; - const headerContent = useMemo(() => { - if (!shouldDisplayTrackExpenseButton) { - return; - } - - // We only display the track expense button if the user is coming from the combined submit/track flow. - return ( - - ); - }, [shouldDisplayTrackExpenseButton, translate, onTrackExpensePress]); - const footerContent = useMemo(() => { if (isDismissed && !shouldShowSplitBillErrorMessage && !participants.length) { return; @@ -444,7 +439,6 @@ function MoneyRequestParticipantsSelector({ shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} onSelectRow={onSelectRow} shouldSingleExecuteRowSelect - headerContent={headerContent} footerContent={footerContent} listEmptyContent={} headerMessage={header} diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 6a67a1040f1b..df2f39158056 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -69,7 +69,6 @@ function IOURequestStepParticipants({ const selfDMReportID = useMemo(() => ReportUtils.findSelfDMReportID(), []); const [selfDMReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReportID}`); - const shouldDisplayTrackExpenseButton = !!selfDMReportID && iouType === CONST.IOU.TYPE.CREATE; const receiptFilename = transaction?.filename; const receiptPath = transaction?.receipt?.source; @@ -88,10 +87,31 @@ function IOURequestStepParticipants({ IOU.navigateToStartStepIfScanFileCannotBeRead(receiptFilename ?? '', receiptPath ?? '', () => {}, iouRequestType, iouType, transactionID, reportID, receiptType ?? ''); }, [receiptType, receiptPath, receiptFilename, iouRequestType, iouType, transactionID, reportID, action]); + const trackExpense = useCallback(() => { + // If coming from the combined submit/track flow and the user proceeds to just track the expense, + // we will use the track IOU type in the confirmation flow. + if (!selfDMReportID) { + return; + } + + const rateID = DistanceRequestUtils.getCustomUnitRateID(selfDMReportID); + IOU.setCustomUnitRateID(transactionID, rateID); + IOU.setMoneyRequestParticipantsFromReport(transactionID, selfDMReport); + const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID); + Navigation.navigate(iouConfirmationPageRoute); + }, [action, selfDMReport, selfDMReportID, transactionID]); + const addParticipant = useCallback( (val: Participant[]) => { HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS); + const firstParticipant = val.at(0); + + if (firstParticipant?.isSelfDM) { + trackExpense(); + return; + } + const firstParticipantReportID = val.at(0)?.reportID; const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID); const isInvoice = iouType === CONST.IOU.TYPE.INVOICE && ReportUtils.isInvoiceRoomWithID(firstParticipantReportID); @@ -110,7 +130,7 @@ function IOURequestStepParticipants({ // When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step. selectedReportID.current = firstParticipantReportID ?? reportID; }, - [iouType, reportID, transactionID], + [iouType, reportID, trackExpense, transactionID], ); const handleNavigation = useCallback( @@ -163,21 +183,6 @@ function IOURequestStepParticipants({ IOUUtils.navigateToStartMoneyRequestStep(iouRequestType, iouType, transactionID, reportID, action); }, [iouRequestType, iouType, transactionID, reportID, action]); - const trackExpense = () => { - // If coming from the combined submit/track flow and the user proceeds to just track the expense, - // we will use the track IOU type in the confirmation flow. - if (!selfDMReportID) { - return; - } - - const rateID = DistanceRequestUtils.getCustomUnitRateID(selfDMReportID); - IOU.setCustomUnitRateID(transactionID, rateID); - IOU.setMoneyRequestParticipantsFromReport(transactionID, selfDMReport); - const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID); - - handleNavigation(iouConfirmationPageRoute); - }; - useEffect(() => { const isCategorizing = action === CONST.IOU.ACTION.CATEGORIZE; const isShareAction = action === CONST.IOU.ACTION.SHARE; @@ -206,10 +211,8 @@ function IOURequestStepParticipants({ participants={isSplitRequest ? participants : []} onParticipantsAdded={addParticipant} onFinish={goToNextStep} - onTrackExpensePress={trackExpense} iouType={iouType} action={action} - shouldDisplayTrackExpenseButton={shouldDisplayTrackExpenseButton} /> );