diff --git a/src/hooks/useConditionalCreateEmptyReportConfirmation.ts b/src/hooks/useConditionalCreateEmptyReportConfirmation.ts new file mode 100644 index 000000000000..a2c3158451eb --- /dev/null +++ b/src/hooks/useConditionalCreateEmptyReportConfirmation.ts @@ -0,0 +1,70 @@ +import {accountIDSelector} from '@selectors/Session'; +import {useCallback, useMemo} from 'react'; +import type {ReactNode} from 'react'; +import {hasEmptyReportsForPolicy, reportSummariesOnyxSelector} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; +import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation'; +import useOnyx from './useOnyx'; + +type UseConditionalCreateEmptyReportConfirmationParams = { + /** The policy ID for which the report is being created */ + policyID?: string; + /** The display name of the policy/workspace */ + policyName?: string; + /** Callback executed after the user confirms report creation */ + onCreateReport: () => void; + /** Optional callback executed when the confirmation modal is cancelled */ + onCancel?: () => void; +}; + +type UseConditionalCreateEmptyReportConfirmationResult = { + /** Function that handles report creation with optional confirmation */ + handleCreateReport: () => void; + /** Whether an empty report already exists for the provided policy */ + hasEmptyReport: boolean; + /** The confirmation modal React component to render */ + CreateReportConfirmationModal: ReactNode; +}; + +/** + * Hook that combines the empty report detection logic with the confirmation modal. + * It ensures the provided callback is only executed after the user confirms creation when necessary. + */ +export default function useConditionalCreateEmptyReportConfirmation({ + policyID, + policyName, + onCreateReport, + onCancel, +}: UseConditionalCreateEmptyReportConfirmationParams): UseConditionalCreateEmptyReportConfirmationResult { + const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: true}); + type ReportSummary = ReturnType[number]; + const [reportSummaries = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + canBeMissing: true, + selector: reportSummariesOnyxSelector, + }); + + const hasEmptyReport = useMemo(() => hasEmptyReportsForPolicy(reportSummaries, policyID, accountID), [accountID, policyID, reportSummaries]); + + const {openCreateReportConfirmation, CreateReportConfirmationModal} = useCreateEmptyReportConfirmation({ + policyID, + policyName, + onConfirm: onCreateReport, + onCancel, + }); + + const handleCreateReport = useCallback(() => { + if (hasEmptyReport) { + openCreateReportConfirmation(); + return; + } + + onCreateReport(); + }, [hasEmptyReport, onCreateReport, openCreateReportConfirmation]); + + return { + handleCreateReport, + hasEmptyReport, + CreateReportConfirmationModal, + }; +} diff --git a/src/hooks/useCreateEmptyReportConfirmation.tsx b/src/hooks/useCreateEmptyReportConfirmation.tsx new file mode 100644 index 000000000000..73c70e8a25b4 --- /dev/null +++ b/src/hooks/useCreateEmptyReportConfirmation.tsx @@ -0,0 +1,106 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import type {ReactNode} from 'react'; +import ConfirmModal from '@components/ConfirmModal'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import useLocalize from './useLocalize'; + +type UseCreateEmptyReportConfirmationParams = { + /** The policy ID for which the report is being created */ + policyID?: string; + /** The display name of the policy/workspace */ + policyName?: string; + /** Callback function to execute when user confirms report creation */ + onConfirm: () => void; + /** Optional callback function to execute when user cancels the confirmation */ + onCancel?: () => void; +}; + +type UseCreateEmptyReportConfirmationResult = { + /** Function to open the confirmation modal */ + openCreateReportConfirmation: () => void; + /** The confirmation modal React component to render */ + CreateReportConfirmationModal: ReactNode; +}; + +/** + * A React hook that provides a confirmation modal for creating empty reports. + * When a user attempts to create a new report in a workspace where they already have an empty report, + * this hook displays a confirmation modal to prevent accidental duplicate empty reports. + * + * @param params - Configuration object for the hook + * @param params.policyName - The display name of the policy/workspace + * @param params.onConfirm - Callback function to execute when user confirms report creation + * @returns An object containing: + * - openCreateReportConfirmation: Function to open the confirmation modal + * - CreateReportConfirmationModal: The confirmation modal React component to render + * + * @example + * const {openCreateReportConfirmation, CreateReportConfirmationModal} = useCreateEmptyReportConfirmation({ + * policyID: 'policy123', + * policyName: 'Engineering Team', + * onConfirm: handleCreateReport, + * }); + * + */ +export default function useCreateEmptyReportConfirmation({policyName, onConfirm, onCancel}: UseCreateEmptyReportConfirmationParams): UseCreateEmptyReportConfirmationResult { + const {translate} = useLocalize(); + const [isVisible, setIsVisible] = useState(false); + + const workspaceDisplayName = useMemo(() => (policyName?.trim().length ? policyName : translate('report.newReport.genericWorkspaceName')), [policyName, translate]); + + const handleConfirm = useCallback(() => { + onConfirm(); + setIsVisible(false); + }, [onConfirm]); + + const handleCancel = useCallback(() => { + onCancel?.(); + setIsVisible(false); + }, [onCancel]); + + const handleReportsLinkPress = useCallback(() => { + setIsVisible(false); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery({groupBy: CONST.SEARCH.GROUP_BY.REPORTS})})); + }, []); + + const openCreateReportConfirmation = useCallback(() => { + // The caller is responsible for determining if empty report confirmation + // should be shown. We simply open the modal when called. + setIsVisible(true); + }, []); + + const prompt = useMemo( + () => ( + + {translate('report.newReport.emptyReportConfirmationPrompt', {workspaceName: workspaceDisplayName})}{' '} + {translate('report.newReport.emptyReportConfirmationPromptLink')}. + + ), + [handleReportsLinkPress, translate, workspaceDisplayName], + ); + + const CreateReportConfirmationModal = useMemo( + () => ( + + ), + [handleCancel, handleConfirm, isVisible, prompt, translate], + ); + + return { + openCreateReportConfirmation, + CreateReportConfirmationModal, + }; +} diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 7d17d4642975..c896b1e697d7 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -1,14 +1,16 @@ import {createPoliciesSelector} from '@selectors/Policy'; -import {useMemo} from 'react'; +import {useCallback, useEffect, useMemo, useState} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import memoize from '@libs/memoize'; import Permissions from '@libs/Permissions'; -import {hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; +import {getPersonalDetailsForAccountID, hasEmptyReportsForPolicy, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {createTypeMenuSections} from '@libs/SearchUIUtils'; +import {createNewReport} from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, Session} from '@src/types/onyx'; +import type {PersonalDetails, Policy, Session} from '@src/types/onyx'; import useCardFeedsForDisplay from './useCardFeedsForDisplay'; +import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; @@ -59,6 +61,61 @@ const useSearchTypeMenuSections = () => { const [allBetas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const isASAPSubmitBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, allBetas); const hasViolations = hasViolationsReportUtils(undefined, transactionViolations); + const [pendingReportCreation, setPendingReportCreation] = useState<{policyID: string; policyName?: string; onConfirm: () => void} | null>(null); + + const handlePendingConfirm = useCallback(() => { + pendingReportCreation?.onConfirm(); + setPendingReportCreation(null); + }, [pendingReportCreation, setPendingReportCreation]); + + const handlePendingCancel = useCallback(() => { + setPendingReportCreation(null); + }, [setPendingReportCreation]); + + const {openCreateReportConfirmation, CreateReportConfirmationModal} = useCreateEmptyReportConfirmation({ + policyID: pendingReportCreation?.policyID, + policyName: pendingReportCreation?.policyName ?? '', + onConfirm: handlePendingConfirm, + onCancel: handlePendingCancel, + }); + + const createReportWithConfirmation = useCallback( + ({policyID, policyName, onSuccess, personalDetails}: {policyID: string; policyName?: string; onSuccess: (reportID: string) => void; personalDetails?: PersonalDetails}) => { + const accountID = currentUserLoginAndAccountID?.accountID; + if (!accountID) { + return; + } + + const personalDetailsForCreation = personalDetails ?? (getPersonalDetailsForAccountID(accountID) as PersonalDetails | undefined); + if (!personalDetailsForCreation) { + return; + } + + const executeCreate = () => { + const createdReportID = createNewReport(personalDetailsForCreation, isASAPSubmitBetaEnabled, hasViolations, policyID); + onSuccess(createdReportID); + }; + + if (hasEmptyReportsForPolicy(reports, policyID, accountID)) { + setPendingReportCreation({ + policyID, + policyName, + onConfirm: executeCreate, + }); + return; + } + + executeCreate(); + }, + [currentUserLoginAndAccountID?.accountID, hasViolations, isASAPSubmitBetaEnabled, reports, setPendingReportCreation], + ); + + useEffect(() => { + if (!pendingReportCreation) { + return; + } + openCreateReportConfirmation(); + }, [pendingReportCreation, openCreateReportConfirmation]); const typeMenuSections = useMemo( () => @@ -75,6 +132,7 @@ const useSearchTypeMenuSections = () => { isASAPSubmitBetaEnabled, hasViolations, reports, + createReportWithConfirmation, ), [ currentUserLoginAndAccountID?.email, @@ -89,10 +147,11 @@ const useSearchTypeMenuSections = () => { isASAPSubmitBetaEnabled, hasViolations, reports, + createReportWithConfirmation, ], ); - return {typeMenuSections}; + return {typeMenuSections, CreateReportConfirmationModal}; }; export default useSearchTypeMenuSections; diff --git a/src/languages/de.ts b/src/languages/de.ts index 35ae0c66a4da..f8c01cd6052c 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6399,6 +6399,11 @@ ${amount} für ${merchant} - ${date}`, newReport: { createReport: 'Bericht erstellen', chooseWorkspace: 'Wählen Sie einen Arbeitsbereich für diesen Bericht aus.', + emptyReportConfirmationTitle: 'Du hast bereits einen leeren Bericht', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => + `Möchtest du wirklich einen weiteren Bericht in ${workspaceName} erstellen? Du kannst auf deine leeren Berichte zugreifen unter`, + emptyReportConfirmationPromptLink: 'Berichte', + genericWorkspaceName: 'diesem Arbeitsbereich', }, genericCreateReportFailureMessage: 'Unerwarteter Fehler beim Erstellen dieses Chats. Bitte versuchen Sie es später erneut.', genericAddCommentFailureMessage: 'Unerwarteter Fehler beim Posten des Kommentars. Bitte versuchen Sie es später noch einmal.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 6fd4f79200c4..6e4bc573c03f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6352,6 +6352,11 @@ const translations = { newReport: { createReport: 'Create report', chooseWorkspace: 'Choose a workspace for this report.', + emptyReportConfirmationTitle: 'You already have an empty report', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => + `Are you sure you want to create another report in ${workspaceName}? You can access your empty reports in`, + emptyReportConfirmationPromptLink: 'Reports', + genericWorkspaceName: 'this workspace', }, genericCreateReportFailureMessage: 'Unexpected error creating this chat. Please try again later.', genericAddCommentFailureMessage: 'Unexpected error posting the comment. Please try again later.', diff --git a/src/languages/es.ts b/src/languages/es.ts index bbd9d16c573c..0cb7f88b7c40 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6382,6 +6382,11 @@ ${amount} para ${merchant} - ${date}`, newReport: { createReport: 'Crear informe', chooseWorkspace: 'Elige un espacio de trabajo para este informe.', + emptyReportConfirmationTitle: 'Ya tienes un informe vacío', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => + `¿Estás seguro de que quieres crear otro informe en ${workspaceName}? Puedes acceder a tus informes vacíos en`, + emptyReportConfirmationPromptLink: 'Informes', + genericWorkspaceName: 'este espacio de trabajo', }, genericCreateReportFailureMessage: 'Error inesperado al crear el chat. Por favor, inténtalo más tarde.', genericAddCommentFailureMessage: 'Error inesperado al añadir el comentario. Por favor, inténtalo más tarde.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b2d64347fb90..624d168d6b5d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6406,6 +6406,11 @@ ${amount} pour ${merchant} - ${date}`, newReport: { createReport: 'Créer un rapport', chooseWorkspace: 'Choisissez un espace de travail pour ce rapport.', + emptyReportConfirmationTitle: 'Vous avez déjà un rapport vide', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => + `Êtes-vous sûr de vouloir créer un autre rapport dans ${workspaceName} ? Vous pouvez accéder à vos rapports vides dans`, + emptyReportConfirmationPromptLink: 'Rapports', + genericWorkspaceName: 'cet espace de travail', }, genericCreateReportFailureMessage: 'Erreur inattendue lors de la création de ce chat. Veuillez réessayer plus tard.', genericAddCommentFailureMessage: 'Erreur inattendue lors de la publication du commentaire. Veuillez réessayer plus tard.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 86b4fac55911..23aa7eabf9be 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6410,6 +6410,11 @@ ${amount} per ${merchant} - ${date}`, newReport: { createReport: 'Crea rapporto', chooseWorkspace: "Scegli un'area di lavoro per questo report.", + emptyReportConfirmationTitle: 'Hai già un rapporto vuoto', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => + `Sei sicuro di voler creare un altro rapporto in ${workspaceName}? Puoi accedere ai tuoi rapporti vuoti in`, + emptyReportConfirmationPromptLink: 'Rapporti', + genericWorkspaceName: 'questo spazio di lavoro', }, genericCreateReportFailureMessage: 'Errore imprevisto durante la creazione di questa chat. Si prega di riprovare più tardi.', genericAddCommentFailureMessage: 'Errore imprevisto durante la pubblicazione del commento. Per favore riprova più tardi.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index dd61389475a7..6e02bf29146a 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6346,6 +6346,10 @@ ${date} - ${merchant}に${amount}`, newReport: { createReport: 'レポートを作成', chooseWorkspace: 'このレポートのワークスペースを選択してください。', + emptyReportConfirmationTitle: '空のレポートがすでにあります', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => `${workspaceName} で別のレポートを作成しますか? 空のレポートには次からアクセスできます`, + emptyReportConfirmationPromptLink: 'レポート', + genericWorkspaceName: 'このワークスペース', }, genericCreateReportFailureMessage: 'このチャットの作成中に予期しないエラーが発生しました。後でもう一度お試しください。', genericAddCommentFailureMessage: 'コメントの投稿中に予期しないエラーが発生しました。後でもう一度お試しください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a52a7a4ca482..7a3ac792d48d 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6396,6 +6396,11 @@ ${amount} voor ${merchant} - ${date}`, newReport: { createReport: 'Rapport maken', chooseWorkspace: 'Kies een werkruimte voor dit rapport.', + emptyReportConfirmationTitle: 'Je hebt al een leeg rapport', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => + `Weet je zeker dat je nog een rapport wilt maken in ${workspaceName}? Je kunt je lege rapporten vinden onder`, + emptyReportConfirmationPromptLink: 'Rapporten', + genericWorkspaceName: 'deze werkruimte', }, genericCreateReportFailureMessage: 'Onverwachte fout bij het maken van deze chat. Probeer het later opnieuw.', genericAddCommentFailureMessage: 'Onverwachte fout bij het plaatsen van de opmerking. Probeer het later opnieuw.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 687f503be612..f37109fb7f05 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6381,6 +6381,11 @@ ${amount} dla ${merchant} - ${date}`, newReport: { createReport: 'Utwórz raport', chooseWorkspace: 'Wybierz przestrzeń roboczą dla tego raportu.', + emptyReportConfirmationTitle: 'Masz już pusty raport', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => + `Czy na pewno chcesz utworzyć kolejny raport w ${workspaceName}? Do pustych raportów możesz przejść w`, + emptyReportConfirmationPromptLink: 'Raporty', + genericWorkspaceName: 'tej przestrzeni roboczej', }, genericCreateReportFailureMessage: 'Nieoczekiwany błąd podczas tworzenia tego czatu. Proszę spróbować ponownie później.', genericAddCommentFailureMessage: 'Nieoczekiwany błąd podczas publikowania komentarza. Spróbuj ponownie później.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 40396c933ff9..e3dbec596e3c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6390,6 +6390,11 @@ ${amount} para ${merchant} - ${date}`, newReport: { createReport: 'Criar relatório', chooseWorkspace: 'Escolha um espaço de trabalho para este relatório.', + emptyReportConfirmationTitle: 'Você já tem um relatório vazio', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => + `Tem certeza de que deseja criar outro relatório em ${workspaceName}? Você pode acessar seus relatórios vazios em`, + emptyReportConfirmationPromptLink: 'Relatórios', + genericWorkspaceName: 'este espaço de trabalho', }, genericCreateReportFailureMessage: 'Erro inesperado ao criar este chat. Por favor, tente novamente mais tarde.', genericAddCommentFailureMessage: 'Erro inesperado ao postar o comentário. Por favor, tente novamente mais tarde.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 295d28210cf7..511468f9f580 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6266,6 +6266,10 @@ ${merchant}的${amount} - ${date}`, newReport: { createReport: '创建报告', chooseWorkspace: '为此报告选择一个工作区。', + emptyReportConfirmationTitle: '你已经有一个空报告', + emptyReportConfirmationPrompt: ({workspaceName}: {workspaceName: string}) => `确定要在 ${workspaceName} 中再创建一个报告吗?你可以在以下位置访问你的空报告`, + emptyReportConfirmationPromptLink: '报告', + genericWorkspaceName: '此工作区', }, genericCreateReportFailureMessage: '创建此聊天时出现意外错误。请稍后再试。', genericAddCommentFailureMessage: '发表评论时出现意外错误。请稍后再试。', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 8347ca4877b3..0f4c2adeb8bf 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1422,6 +1422,25 @@ function getGroupPaidPoliciesWithExpenseChatEnabled(policies: OnyxCollection isPaidGroupPolicy(policy) && policy?.isPolicyExpenseChatEnabled); } +/** + * This method checks if the active policy has expense chat enabled and is a paid group policy. + * If true, it returns the active policy itself, else it returns the first policy from groupPoliciesWithChatEnabled. + * + * Further, if groupPoliciesWithChatEnabled is empty, then it returns undefined + * and the user would be taken to the workspace selection page. + */ +function getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled: Array>, activePolicy?: OnyxInputOrEntry | null): OnyxInputOrEntry | undefined { + if (activePolicy && activePolicy.isPolicyExpenseChatEnabled && isPaidGroupPolicy(activePolicy)) { + return activePolicy; + } + + if (groupPoliciesWithChatEnabled.length === 1) { + return groupPoliciesWithChatEnabled.at(0); + } + + return undefined; +} + function hasOtherControlWorkspaces(currentPolicyID: string) { const otherControlWorkspaces = Object.values(allPolicies ?? {}).filter((policy) => policy?.id !== currentPolicyID && isPolicyAdmin(policy) && isControlPolicy(policy)); return otherControlWorkspaces.length > 0; @@ -1671,6 +1690,7 @@ export { areSettingsInErrorFields, settingsPendingAction, getGroupPaidPoliciesWithExpenseChatEnabled, + getDefaultChatEnabledPolicy, getForwardsToAccount, getSubmitToAccountID, getWorkspaceAccountID, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 99363754316d..945eabfef5d2 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -8280,6 +8280,116 @@ function isEmptyReport(report: OnyxEntry, isReportArchived = false): boo return generateIsEmptyReport(report, isReportArchived); } +type ReportEmptyStateSummary = Pick & + Pick; + +function toReportEmptyStateSummary(report: Report | ReportEmptyStateSummary | undefined): ReportEmptyStateSummary | undefined { + if (!report) { + return undefined; + } + + return { + reportID: report.reportID, + policyID: report.policyID ?? undefined, + ownerAccountID: report.ownerAccountID ?? undefined, + type: report.type ?? undefined, + stateNum: report.stateNum ?? undefined, + statusNum: report.statusNum ?? undefined, + total: report.total ?? undefined, + nonReimbursableTotal: report.nonReimbursableTotal ?? undefined, + pendingAction: report.pendingAction ?? undefined, + errors: report.errors ?? undefined, + }; +} + +function getReportSummariesForEmptyCheck(reports: OnyxCollection | Array | undefined): ReportEmptyStateSummary[] { + if (!reports) { + return []; + } + + const reportsArray = Array.isArray(reports) ? reports : Object.values(reports); + return reportsArray.map((report) => toReportEmptyStateSummary(report as Report | ReportEmptyStateSummary | undefined)).filter((summary): summary is ReportEmptyStateSummary => !!summary); +} + +const reportSummariesOnyxSelector = (reports: Parameters[0]) => getReportSummariesForEmptyCheck(reports); + +/** + * Checks if there are any empty (no transactions) open expense reports for a specific policy and user. + * An empty report is defined as having zero transactions. + * This excludes reports that are being deleted or have errors. + */ +function hasEmptyReportsForPolicy( + reports: OnyxCollection | Array | undefined, + policyID: string | undefined, + accountID?: number, + reportsTransactionsParam: Record = reportsTransactions, +): boolean { + if (!accountID || !policyID) { + return false; + } + + const summaries = getReportSummariesForEmptyCheck(reports); + + return summaries.some((report) => { + if (!report.reportID || !report.policyID || report.policyID !== policyID || report.ownerAccountID !== accountID) { + return false; + } + + // Exclude reports that are being deleted or have errors + if (report.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || report.errors) { + return false; + } + + const isOpenExpense = report.type === CONST.REPORT.TYPE.EXPENSE && report.stateNum === CONST.REPORT.STATE_NUM.OPEN && report.statusNum === CONST.REPORT.STATUS_NUM.OPEN; + if (!isOpenExpense) { + return false; + } + + const transactions = getReportTransactions(report.reportID, reportsTransactionsParam); + return transactions.length === 0; + }); +} + +/** + * Returns a lookup object containing the policy IDs that have empty (no transactions) open expense reports for a specific user. + * An empty report is defined as having zero transactions. + * This excludes reports that are being deleted or have errors. + */ +function getPolicyIDsWithEmptyReportsForAccount( + reports: OnyxCollection | Array | undefined, + accountID?: number, + reportsTransactionsParam: Record = reportsTransactions, +): Record { + if (!accountID) { + return {}; + } + + const summaries = getReportSummariesForEmptyCheck(reports); + const policyLookup: Record = {}; + + summaries.forEach((report) => { + if (!report.reportID || !report.policyID || report.ownerAccountID !== accountID) { + return; + } + + if (report.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || report.errors) { + return; + } + + const isOpenExpense = report.type === CONST.REPORT.TYPE.EXPENSE && report.stateNum === CONST.REPORT.STATE_NUM.OPEN && report.statusNum === CONST.REPORT.STATUS_NUM.OPEN; + if (!isOpenExpense) { + return; + } + + const transactions = getReportTransactions(report.reportID, reportsTransactionsParam); + if (transactions.length === 0) { + policyLookup[report.policyID] = true; + } + }); + + return policyLookup; +} + /** * Check if the report is empty, meaning it has no visible messages (i.e. only a "created" report action). * No cache implementation which bypasses derived value check. @@ -12179,8 +12289,12 @@ export { getInvoicePayerName, getInvoicesChatName, getPayeeName, + getReportSummariesForEmptyCheck, + reportSummariesOnyxSelector, + getPolicyIDsWithEmptyReportsForAccount, hasActionWithErrorsForTransaction, hasAutomatedExpensifyAccountIDs, + hasEmptyReportsForPolicy, hasExpensifyGuidesEmails, hasHeldExpenses, hasIOUWaitingOnCurrentUserBankAccount, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 8da246818dc9..119aeafebf72 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1893,6 +1893,7 @@ function createTypeMenuSections( isASAPSubmitBetaEnabled: boolean, hasViolations: boolean, reports?: OnyxCollection, + createReportWithConfirmation?: (params: {policyID: string; policyName?: string; onSuccess: (reportID: string) => void; personalDetails?: OnyxTypes.PersonalDetails}) => void, ): SearchTypeMenuSection[] { const typeMenuSections: SearchTypeMenuSection[] = []; @@ -1935,10 +1936,30 @@ function createTypeMenuSections( } if (workspaceIDForReportCreation && !shouldRestrictUserBillableActions(workspaceIDForReportCreation) && personalDetails) { - const createdReportID = createNewReport(personalDetails, isASAPSubmitBetaEnabled, hasViolations, workspaceIDForReportCreation); - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()})); - }); + const policyForCreation = + policies?.[`${ONYXKEYS.COLLECTION.POLICY}${workspaceIDForReportCreation}`] ?? + groupPoliciesWithChatEnabled.find((policy) => policy?.id === workspaceIDForReportCreation); + const policyName = policyForCreation?.name ?? activePolicy?.name ?? groupPoliciesWithChatEnabled.at(0)?.name ?? ''; + + if (createReportWithConfirmation) { + createReportWithConfirmation({ + policyID: workspaceIDForReportCreation, + policyName, + personalDetails, + onSuccess: (createdReportID) => { + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate( + ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()}), + ); + }); + }, + }); + } else { + const createdReportID = createNewReport(personalDetails, isASAPSubmitBetaEnabled, hasViolations, workspaceIDForReportCreation); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()})); + }); + } return; } diff --git a/src/pages/NewReportWorkspaceSelectionPage.tsx b/src/pages/NewReportWorkspaceSelectionPage.tsx index d0da894e3359..48c6583af0cc 100644 --- a/src/pages/NewReportWorkspaceSelectionPage.tsx +++ b/src/pages/NewReportWorkspaceSelectionPage.tsx @@ -1,4 +1,6 @@ -import React, {useCallback, useMemo} from 'react'; +import {accountIDSelector} from '@selectors/Session'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {OnyxCollection} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -8,6 +10,7 @@ import SelectionList from '@components/SelectionListWithSections'; import type {ListItem, SectionListDataType} from '@components/SelectionListWithSections/types'; import UserListItem from '@components/SelectionListWithSections/UserListItem'; import Text from '@components/Text'; +import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; @@ -21,7 +24,7 @@ import type {NewReportWorkspaceSelectionNavigatorParamList} from '@libs/Navigati import {getHeaderMessageForNonUserList} from '@libs/OptionsListUtils'; import Permissions from '@libs/Permissions'; import {isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; -import {getDefaultWorkspaceAvatar, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; +import {getDefaultWorkspaceAvatar, getPolicyIDsWithEmptyReportsForAccount, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import isRHPOnSearchMoneyRequestReportPage from '@navigation/helpers/isRHPOnSearchMoneyRequestReportPage'; @@ -32,6 +35,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type WorkspaceListItem = { @@ -63,6 +67,27 @@ function NewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelectionPag const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; + const [pendingPolicySelection, setPendingPolicySelection] = useState<{policy: WorkspaceListItem; shouldShowEmptyReportConfirmation: boolean} | null>(null); + const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: true}); + + const policiesWithEmptyReportsSelector = useMemo(() => { + if (!accountID) { + const emptyLookup: Record = {}; + return () => emptyLookup; + } + + return (reports: OnyxCollection) => getPolicyIDsWithEmptyReportsForAccount(reports, accountID); + }, [accountID]); + + const [policiesWithEmptyReports] = useOnyx( + ONYXKEYS.COLLECTION.REPORT, + { + canBeMissing: true, + selector: policiesWithEmptyReportsSelector, + }, + [policiesWithEmptyReportsSelector], + ); + const navigateToNewReport = useCallback( (optimisticReportID: string) => { if (isRHPOnReportInSearch) { @@ -78,15 +103,8 @@ function NewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelectionPag [isRHPOnReportInSearch, shouldUseNarrowLayout], ); - const selectPolicy = useCallback( - (policyID?: string) => { - if (!policyID) { - return; - } - if (shouldRestrictUserBillableActions(policyID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyID)); - return; - } + const createReport = useCallback( + (policyID: string) => { const optimisticReportID = createNewReport(currentUserPersonalDetails, isASAPSubmitBetaEnabled, hasViolations, policyID); const selectedTransactionsKeys = Object.keys(selectedTransactions); @@ -133,6 +151,70 @@ function NewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelectionPag ], ); + const handleConfirmCreateReport = useCallback(() => { + if (!pendingPolicySelection?.policy.policyID) { + return; + } + + createReport(pendingPolicySelection.policy.policyID); + setPendingPolicySelection(null); + }, [createReport, pendingPolicySelection?.policy.policyID]); + + const handleCancelCreateReport = useCallback(() => { + setPendingPolicySelection(null); + }, []); + + const {openCreateReportConfirmation, CreateReportConfirmationModal} = useCreateEmptyReportConfirmation({ + policyID: pendingPolicySelection?.policy.policyID, + policyName: pendingPolicySelection?.policy.text ?? '', + onConfirm: handleConfirmCreateReport, + onCancel: handleCancelCreateReport, + }); + + useEffect(() => { + if (!pendingPolicySelection) { + return; + } + + const {policy, shouldShowEmptyReportConfirmation} = pendingPolicySelection; + const policyID = policy.policyID; + + if (!policyID) { + return; + } + + if (!shouldShowEmptyReportConfirmation) { + // No empty report confirmation needed - create report directly and clear pending selection + // policyID is guaranteed to be defined by the check above + createReport(policyID); + setPendingPolicySelection(null); + return; + } + + // Empty report confirmation needed - open confirmation modal (modal handles clearing pending selection via onConfirm/onCancel) + openCreateReportConfirmation(); + }, [createReport, openCreateReportConfirmation, pendingPolicySelection]); + + const selectPolicy = useCallback( + (policy?: WorkspaceListItem) => { + if (!policy?.policyID) { + return; + } + + if (shouldRestrictUserBillableActions(policy.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.policyID)); + return; + } + + // Capture the decision about whether to show empty report confirmation + setPendingPolicySelection({ + policy, + shouldShowEmptyReportConfirmation: !!policiesWithEmptyReports?.[policy.policyID], + }); + }, + [policiesWithEmptyReports], + ); + const usersWorkspaces = useMemo(() => { if (!policies || isEmptyObject(policies)) { return []; @@ -189,6 +271,7 @@ function NewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelectionPag title={translate('report.newReport.createReport')} onBackButtonPress={Navigation.goBack} /> + {CreateReportConfirmationModal} {shouldShowLoadingIndicator ? ( ) : ( @@ -197,7 +280,7 @@ function NewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelectionPag ListItem={UserListItem} sections={sections} - onSelectRow={(option) => selectPolicy(option.policyID)} + onSelectRow={selectPolicy} textInputLabel={usersWorkspaces.length >= CONST.STANDARD_LIST_ITEM_LIMIT ? translate('common.search') : undefined} textInputValue={searchTerm} onChangeText={setSearchTerm} diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 99d9ae707f12..600074c950b7 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -1,4 +1,6 @@ -import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {accountIDSelector} from '@selectors/Session'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {ReactNode} from 'react'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, ImageStyle, Text as RNText, TextStyle, ViewStyle} from 'react-native'; import {Linking, View} from 'react-native'; @@ -19,6 +21,7 @@ import type {SearchGroupBy} from '@components/Search/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnboardingTaskInformation from '@hooks/useOnboardingTaskInformation'; @@ -35,8 +38,8 @@ import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import {hasSeenTourSelector, tryNewDotOnyxSelector} from '@libs/onboardingSelectors'; import Permissions from '@libs/Permissions'; -import {areAllGroupPoliciesExpenseChatDisabled, getGroupPaidPoliciesWithExpenseChatEnabled, isPaidGroupPolicy, isPolicyMember} from '@libs/PolicyUtils'; -import {generateReportID, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils'; +import {areAllGroupPoliciesExpenseChatDisabled, getDefaultChatEnabledPolicy, getGroupPaidPoliciesWithExpenseChatEnabled, isPaidGroupPolicy, isPolicyMember} from '@libs/PolicyUtils'; +import {generateReportID, hasEmptyReportsForPolicy, hasViolations as hasViolationsReportUtils, reportSummariesOnyxSelector} from '@libs/ReportUtils'; import type {SearchTypeMenuSection} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {showContextMenu} from '@pages/home/report/ContextMenu/ReportActionContextMenu'; @@ -46,6 +49,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {IntroSelected, PersonalDetails, Policy, Transaction} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; type EmptySearchViewProps = { similarSearchHash: number; @@ -59,11 +63,11 @@ type EmptySearchViewContentProps = EmptySearchViewProps & { typeMenuSections: SearchTypeMenuSection[]; allPolicies: OnyxCollection; isUserPaidPolicyMember: boolean; - activePolicyID: string | undefined; activePolicy: OnyxEntry; groupPoliciesWithChatEnabled: readonly never[] | Array>; introSelected: OnyxEntry; hasSeenTour: boolean; + searchMenuCreateReportConfirmationModal: ReactNode; }; type EmptySearchViewItem = { @@ -90,9 +94,11 @@ const tripsFeatures: FeatureListItem[] = [ }, ]; +type ReportSummary = ReturnType[number]; + function EmptySearchView({similarSearchHash, type, groupBy, hasResults}: EmptySearchViewProps) { const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {typeMenuSections} = useSearchTypeMenuSections(); + const {typeMenuSections, CreateReportConfirmationModal: SearchMenuCreateReportConfirmationModal} = useSearchTypeMenuSections(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: false}); @@ -134,11 +140,11 @@ function EmptySearchView({similarSearchHash, type, groupBy, hasResults}: EmptySe typeMenuSections={typeMenuSections} allPolicies={allPolicies} isUserPaidPolicyMember={isUserPaidPolicyMember} - activePolicyID={activePolicyID} activePolicy={activePolicy} groupPoliciesWithChatEnabled={groupPoliciesWithChatEnabled} introSelected={introSelected} hasSeenTour={hasSeenTour} + searchMenuCreateReportConfirmationModal={SearchMenuCreateReportConfirmationModal} /> ); @@ -156,17 +162,20 @@ function EmptySearchViewContent({ typeMenuSections, allPolicies, isUserPaidPolicyMember, - activePolicyID, activePolicy, groupPoliciesWithChatEnabled, introSelected, hasSeenTour, + searchMenuCreateReportConfirmationModal, }: EmptySearchViewContentProps) { const theme = useTheme(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const styles = useThemeStyles(); - const contextMenuAnchor = useRef(null); + const [contextMenuAnchor, setContextMenuAnchor] = useState(null); + const handleContextMenuAnchorRef = useCallback((node: RNText | null) => { + setContextMenuAnchor(node); + }, []); const [modalVisible, setModalVisible] = useState(false); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const [allBetas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); @@ -188,17 +197,60 @@ function EmptySearchViewContent({ return areAllGroupPoliciesExpenseChatDisabled(allPolicies ?? {}); }, [allPolicies]); + const defaultChatEnabledPolicy = useMemo( + () => getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled as Array>, activePolicy), + [activePolicy, groupPoliciesWithChatEnabled], + ); + + const defaultChatEnabledPolicyID = defaultChatEnabledPolicy?.id; + + const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: true}); + const [reportSummaries = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + canBeMissing: true, + selector: reportSummariesOnyxSelector, + }); + const hasEmptyReport = useMemo(() => hasEmptyReportsForPolicy(reportSummaries, defaultChatEnabledPolicyID, accountID), [accountID, defaultChatEnabledPolicyID, reportSummaries]); + + const handleCreateWorkspaceReport = useCallback(() => { + if (!defaultChatEnabledPolicyID) { + return; + } + + const createdReportID = createNewReport(currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, defaultChatEnabledPolicyID); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()})); + }); + }, [currentUserPersonalDetails, hasViolations, defaultChatEnabledPolicyID, isASAPSubmitBetaEnabled]); + + const {openCreateReportConfirmation: openCreateReportFromSearch, CreateReportConfirmationModal} = useCreateEmptyReportConfirmation({ + policyID: defaultChatEnabledPolicyID, + policyName: defaultChatEnabledPolicy?.name ?? '', + onConfirm: handleCreateWorkspaceReport, + }); + + const handleCreateReportClick = useCallback(() => { + if (hasEmptyReport) { + openCreateReportFromSearch(); + } else { + handleCreateWorkspaceReport(); + } + }, [hasEmptyReport, handleCreateWorkspaceReport, openCreateReportFromSearch]); + const typeMenuItems = useMemo(() => { return typeMenuSections.map((section) => section.menuItems).flat(); }, [typeMenuSections]); const tripViewChildren = useMemo(() => { const onLongPress = (event: GestureResponderEvent | MouseEvent) => { + if (!contextMenuAnchor) { + return; + } + showContextMenu({ type: CONST.CONTEXT_MENU_TYPES.LINK, event, selection: CONST.BOOK_TRAVEL_DEMO_URL, - contextMenuAnchor: contextMenuAnchor.current, + contextMenuAnchor, }); }; @@ -217,7 +269,7 @@ function EmptySearchViewContent({ onPress={() => { Linking.openURL(CONST.BOOK_TRAVEL_DEMO_URL); }} - ref={contextMenuAnchor} + ref={handleContextMenuAnchorRef} > {translate('travel.bookADemo')} @@ -247,7 +299,7 @@ function EmptySearchViewContent({ ); - }, [styles, translate]); + }, [contextMenuAnchor, handleContextMenuAnchorRef, styles, translate]); // Default 'Folder' lottie animation, along with its background styles const defaultViewItemHeader = useMemo( @@ -320,15 +372,7 @@ function EmptySearchViewContent({ buttonText: translate('quickAction.createReport'), buttonAction: () => { interceptAnonymousUser(() => { - let workspaceIDForReportCreation: string | undefined; - - if (activePolicy && activePolicy.isPolicyExpenseChatEnabled && isPaidGroupPolicy(activePolicy)) { - // If the user's default workspace is a paid group workspace with chat enabled, we create a report with it by default - workspaceIDForReportCreation = activePolicyID; - } else if (groupPoliciesWithChatEnabled.length === 1) { - // If the user has only one paid group workspace with chat enabled, we create a report with it - workspaceIDForReportCreation = groupPoliciesWithChatEnabled.at(0)?.id; - } + const workspaceIDForReportCreation = defaultChatEnabledPolicyID; if (!workspaceIDForReportCreation || (shouldRestrictUserBillableActions(workspaceIDForReportCreation) && groupPoliciesWithChatEnabled.length > 1)) { // If we couldn't guess the workspace to create the report, or a guessed workspace is past it's grace period and we have other workspaces to choose from @@ -336,14 +380,12 @@ function EmptySearchViewContent({ return; } - if (!shouldRestrictUserBillableActions(workspaceIDForReportCreation)) { - const createdReportID = createNewReport(currentUserPersonalDetails, isASAPSubmitBetaEnabled, hasViolations, workspaceIDForReportCreation); - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()})); - }); - } else { + if (shouldRestrictUserBillableActions(workspaceIDForReportCreation)) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(workspaceIDForReportCreation)); + return; } + + handleCreateReportClick(); }); }, success: true, @@ -467,18 +509,16 @@ function EmptySearchViewContent({ defaultViewItemHeader, hasSeenTour, groupPoliciesWithChatEnabled, - activePolicy, - activePolicyID, - currentUserPersonalDetails, - isASAPSubmitBetaEnabled, - hasViolations, tripViewChildren, hasTransactions, shouldRedirectToExpensifyClassic, + defaultChatEnabledPolicyID, + handleCreateReportClick, ]); return ( <> + {searchMenuCreateReportConfirmationModal} + {CreateReportConfirmationModal} { - if (shouldSelectPolicy) { - Navigation.navigate(ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute(true)); - return; - } - if (policyForMovingExpensesID && shouldRestrictUserBillableActions(policyForMovingExpensesID)) { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyForMovingExpensesID)); - return; - } + const createReportForPolicy = () => { const createdReportID = createNewReport(currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policyForMovingExpensesID); const reportNextStep = allReportNextSteps?.[`${ONYXKEYS.COLLECTION.NEXT_STEP}${createdReportID}`]; changeTransactionsReport( @@ -60,13 +54,31 @@ function SearchTransactionsChangeReport() { isASAPSubmitBetaEnabled, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? '', - policyForMovingExpensesID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyForMovingExpensesID}`] : undefined, + policyForMovingExpenses, reportNextStep, ); clearSelectedTransactions(); Navigation.goBack(); }; + const {handleCreateReport, CreateReportConfirmationModal} = useConditionalCreateEmptyReportConfirmation({ + policyID: policyForMovingExpensesID, + policyName: policyForMovingExpenses?.name ?? '', + onCreateReport: createReportForPolicy, + }); + + const createReport = () => { + if (shouldSelectPolicy) { + Navigation.navigate(ROUTES.NEW_REPORT_WORKSPACE_SELECTION.getRoute(true)); + return; + } + if (policyForMovingExpensesID && shouldRestrictUserBillableActions(policyForMovingExpensesID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyForMovingExpensesID)); + return; + } + handleCreateReport(); + }; + const selectReport = (item: TransactionGroupListItem) => { if (selectedTransactionsKeys.length === 0) { return; @@ -101,15 +113,18 @@ function SearchTransactionsChangeReport() { }; return ( - + <> + {CreateReportConfirmationModal} + + ); } diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index c70e5e314efd..a8592b6e014b 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -47,7 +47,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const {singleExecution} = useSingleExecution(); const {translate} = useLocalize(); const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {canBeMissing: true}); - const {typeMenuSections} = useSearchTypeMenuSections(); + const {typeMenuSections, CreateReportConfirmationModal} = useSearchTypeMenuSections(); const isFocused = useIsFocused(); const { shouldShowProductTrainingTooltip: shouldShowSavedSearchTooltip, @@ -210,66 +210,69 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { }, [similarSearchHash, isSavedSearchActive, typeMenuSections]); return ( - - - {typeMenuSections.map((section, sectionIndex) => ( - - {translate(section.translationPath)} + <> + {CreateReportConfirmationModal} + + + {typeMenuSections.map((section, sectionIndex) => ( + + {translate(section.translationPath)} - {section.translationPath === 'search.savedSearchesMenuItemTitle' ? ( - <> - {renderSavedSearchesSection(savedSearchesMenuItems)} - {/* DeleteConfirmModal is a stable JSX element returned by the hook. - Returning the element directly keeps the component identity across re-renders so React - can play its exit animation instead of removing it instantly. */} - {DeleteConfirmModal} - - ) : ( - <> - {section.menuItems.map((item, itemIndex) => { - const previousItemCount = typeMenuSections.slice(0, sectionIndex).reduce((acc, sec) => acc + sec.menuItems.length, 0); - const flattenedIndex = previousItemCount + itemIndex; - const focused = activeItemIndex === flattenedIndex; + {section.translationPath === 'search.savedSearchesMenuItemTitle' ? ( + <> + {renderSavedSearchesSection(savedSearchesMenuItems)} + {/* DeleteConfirmModal is a stable JSX element returned by the hook. + Returning the element directly keeps the component identity across re-renders so React + can play its exit animation instead of removing it instantly. */} + {DeleteConfirmModal} + + ) : ( + <> + {section.menuItems.map((item, itemIndex) => { + const previousItemCount = typeMenuSections.slice(0, sectionIndex).reduce((acc, sec) => acc + sec.menuItems.length, 0); + const flattenedIndex = previousItemCount + itemIndex; + const focused = activeItemIndex === flattenedIndex; - const onPress = singleExecution(() => { - clearAllFilters(); - clearSelectedTransactions(); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); - }); + const onPress = singleExecution(() => { + clearAllFilters(); + clearSelectedTransactions(); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item.searchQuery})); + }); - const isInitialItem = !initialSearchKeys.current.length || initialSearchKeys.current.includes(item.key); + const isInitialItem = !initialSearchKeys.current.length || initialSearchKeys.current.includes(item.key); - return ( - - - - ); - })} - - )} - - ))} - - + return ( + + + + ); + })} + + )} + + ))} + + + ); } diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index db552f6a7e80..6a09c3b7e693 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -1,5 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useContext, useEffect, useMemo} from 'react'; +import {accountIDSelector} from '@selectors/Session'; +import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import AttachmentPicker from '@components/AttachmentPicker'; @@ -11,6 +12,7 @@ import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; +import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -28,10 +30,12 @@ import Permissions from '@libs/Permissions'; import { canCreateTaskInReport, getPayeeName, + hasEmptyReportsForPolicy, hasViolations as hasViolationsReportUtils, isPaidGroupPolicy, isPolicyExpenseChat, isReportOwner, + reportSummariesOnyxSelector, temporary_getMoneyRequestOptions, } from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; @@ -45,6 +49,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; type MoneyRequestOptions = Record< Exclude, @@ -147,6 +152,12 @@ function AttachmentPickerWithMenuItems({ const [allBetas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const isASAPSubmitBetaEnabled = Permissions.isBetaEnabled(CONST.BETAS.ASAP_SUBMIT, allBetas); const hasViolations = hasViolationsReportUtils(undefined, transactionViolations); + const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector, canBeMissing: true}); + const [reportSummaries = getEmptyArray[number]>()] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + canBeMissing: true, + selector: reportSummariesOnyxSelector, + }); + const hasEmptyReport = useMemo(() => hasEmptyReportsForPolicy(reportSummaries, report?.policyID, accountID), [accountID, report?.policyID, reportSummaries]); const selectOption = useCallback( (onSelected: () => void, shouldRestrictAction: boolean) => { @@ -160,6 +171,23 @@ function AttachmentPickerWithMenuItems({ [policy], ); + const {openCreateReportConfirmation, CreateReportConfirmationModal} = useCreateEmptyReportConfirmation({ + policyID: report?.policyID, + policyName: policy?.name ?? '', + onConfirm: () => selectOption(() => createNewReport(currentUserPersonalDetails, isASAPSubmitBetaEnabled, hasViolations, report?.policyID, true), true), + }); + + const openCreateReportConfirmationRef = useRef(openCreateReportConfirmation); + openCreateReportConfirmationRef.current = openCreateReportConfirmation; + + const handleCreateReport = useCallback(() => { + if (hasEmptyReport) { + openCreateReportConfirmationRef.current(); + } else { + createNewReport(currentUserPersonalDetails, isASAPSubmitBetaEnabled, hasViolations, report?.policyID, true); + } + }, [currentUserPersonalDetails, hasEmptyReport, isASAPSubmitBetaEnabled, hasViolations, report?.policyID]); + const teacherUnitePolicyID = isProduction ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; const isTeachersUniteReport = report?.policyID === teacherUnitePolicyID; @@ -236,17 +264,17 @@ function AttachmentPickerWithMenuItems({ return moneyRequestOptionsList.flat().filter((item, index, self) => index === self.findIndex((t) => t.text === item.text)); }, [ - translate, - shouldUseNarrowLayout, - report, + isDelegateAccessRestricted, + isReportArchived, + isRestrictedToPreferredPolicy, + lastDistanceExpenseType, policy, + report, reportParticipantIDs, selectOption, - isDelegateAccessRestricted, + shouldUseNarrowLayout, showDelegateNoAccessModal, - isReportArchived, - lastDistanceExpenseType, - isRestrictedToPreferredPolicy, + translate, ]); const createReportOption: PopoverMenuItem[] = useMemo(() => { @@ -258,10 +286,11 @@ function AttachmentPickerWithMenuItems({ { icon: Expensicons.Document, text: translate('report.newReport.createReport'), - onSelected: () => selectOption(() => createNewReport(currentUserPersonalDetails, isASAPSubmitBetaEnabled, hasViolations, report?.policyID), true), + shouldCallAfterModalHide: shouldUseNarrowLayout, + onSelected: () => selectOption(() => handleCreateReport(), true), }, ]; - }, [currentUserPersonalDetails, report, selectOption, translate, isASAPSubmitBetaEnabled, hasViolations]); + }, [handleCreateReport, report, selectOption, shouldUseNarrowLayout, translate]); /** * Determines if we can show the task option @@ -358,6 +387,7 @@ function AttachmentPickerWithMenuItems({ ]; return ( <> + {CreateReportConfirmationModal} diff --git a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx index c4ff1c951ca4..53c118a8c7e9 100644 --- a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx @@ -13,6 +13,7 @@ import FloatingReceiptButton from '@components/FloatingReceiptButton'; import * as Expensicons from '@components/Icon/Expensicons'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; +import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -42,13 +43,24 @@ import Permissions from '@libs/Permissions'; import { areAllGroupPoliciesExpenseChatDisabled, canSendInvoice as canSendInvoicePolicyUtils, + getDefaultChatEnabledPolicy, getGroupPaidPoliciesWithExpenseChatEnabled, isPaidGroupPolicy, isPolicyMember, shouldShowPolicy, } from '@libs/PolicyUtils'; import {getQuickActionIcon, getQuickActionTitle, isQuickActionAllowed} from '@libs/QuickActionUtils'; -import {generateReportID, getDisplayNameForParticipant, getIcons, getReportName, getWorkspaceChats, hasViolations as hasViolationsReportUtils, isPolicyExpenseChat} from '@libs/ReportUtils'; +import { + generateReportID, + getDisplayNameForParticipant, + getIcons, + getReportName, + getWorkspaceChats, + hasEmptyReportsForPolicy, + hasViolations as hasViolationsReportUtils, + isPolicyExpenseChat, + reportSummariesOnyxSelector, +} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import isOnSearchMoneyRequestReportPage from '@navigation/helpers/isOnSearchMoneyRequestReportPage'; import variables from '@styles/variables'; @@ -62,6 +74,7 @@ import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {QuickActionName} from '@src/types/onyx/QuickAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; type PolicySelector = Pick; @@ -113,6 +126,10 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref const [quickActionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${quickAction?.chatReportID}`, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + const [reportSummaries = getEmptyArray[number]>()] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + canBeMissing: true, + selector: reportSummariesOnyxSelector, + }); const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, {canBeMissing: true}); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); @@ -180,6 +197,44 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref }, [allPolicies]); const shouldShowCreateReportOption = shouldRedirectToExpensifyClassic || groupPoliciesWithChatEnabled.length > 0; + const defaultChatEnabledPolicy = useMemo( + () => getDefaultChatEnabledPolicy(groupPoliciesWithChatEnabled as Array>, activePolicy), + [activePolicy, groupPoliciesWithChatEnabled], + ); + + const defaultChatEnabledPolicyID = defaultChatEnabledPolicy?.id; + + const hasEmptyReportForDefaultChatEnabledPolicy = useMemo( + () => hasEmptyReportsForPolicy(reportSummaries, defaultChatEnabledPolicyID, session?.accountID), + [defaultChatEnabledPolicyID, reportSummaries, session?.accountID], + ); + + const handleCreateWorkspaceReport = useCallback(() => { + if (!defaultChatEnabledPolicyID) { + return; + } + + if (isReportInSearch) { + clearLastSearchParams(); + } + + const createdReportID = createNewReport(currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, defaultChatEnabledPolicyID); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate( + isSearchTopmostFullScreenRoute() + ? ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()}) + : ROUTES.REPORT_WITH_ID.getRoute(createdReportID, undefined, undefined, Navigation.getActiveRoute()), + {forceReplace: isReportInSearch}, + ); + }); + }, [currentUserPersonalDetails, hasViolations, defaultChatEnabledPolicyID, isASAPSubmitBetaEnabled, isReportInSearch]); + + const {openCreateReportConfirmation: openFabCreateReportConfirmation, CreateReportConfirmationModal: FabCreateReportConfirmationModal} = useCreateEmptyReportConfirmation({ + policyID: defaultChatEnabledPolicyID, + policyName: defaultChatEnabledPolicy?.name ?? '', + onConfirm: handleCreateWorkspaceReport, + }); + const shouldShowNewWorkspaceButton = Object.values(allPolicies ?? {}).every((policy) => !shouldShowPolicy(policy as OnyxEntry, !!isOffline, session?.email)); const quickActionAvatars = useMemo(() => { @@ -484,19 +539,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref return; } - let workspaceIDForReportCreation: string | undefined; - - if (activePolicy && activePolicy.isPolicyExpenseChatEnabled && isPaidGroupPolicy(activePolicy)) { - // If the user's default workspace is a paid group workspace with chat enabled, we create a report with it by default - workspaceIDForReportCreation = activePolicyID; - } else if (groupPoliciesWithChatEnabled.length === 1) { - // If the user has only one paid group workspace with chat enabled, we create a report with it - workspaceIDForReportCreation = groupPoliciesWithChatEnabled.at(0)?.id; - } - - if (isReportInSearch) { - clearLastSearchParams(); - } + const workspaceIDForReportCreation = defaultChatEnabledPolicyID; if (!workspaceIDForReportCreation || (shouldRestrictUserBillableActions(workspaceIDForReportCreation) && groupPoliciesWithChatEnabled.length > 1)) { // If we couldn't guess the workspace to create the report, or a guessed workspace is past it's grace period and we have other workspaces to choose from @@ -505,18 +548,16 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref } if (!shouldRestrictUserBillableActions(workspaceIDForReportCreation)) { - const createdReportID = createNewReport(currentUserPersonalDetails, isASAPSubmitBetaEnabled, hasViolations, workspaceIDForReportCreation); - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate( - isSearchTopmostFullScreenRoute() - ? ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID: createdReportID, backTo: Navigation.getActiveRoute()}) - : ROUTES.REPORT_WITH_ID.getRoute(createdReportID, undefined, undefined, Navigation.getActiveRoute()), - {forceReplace: isReportInSearch}, - ); - }); - } else { - Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(workspaceIDForReportCreation)); + // Check if empty report confirmation should be shown + if (hasEmptyReportForDefaultChatEnabledPolicy) { + openFabCreateReportConfirmation(); + } else { + handleCreateWorkspaceReport(); + } + return; } + + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(workspaceIDForReportCreation)); }); }, }, @@ -596,6 +637,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, ref return ( + {FabCreateReportConfirmationModal} { if (selectedTransactionIDs.length === 0 || item.value === reportID) { @@ -77,6 +79,21 @@ function IOURequestEditReport({route}: IOURequestEditReportProps) { Navigation.dismissModal(); }; + const createReportForPolicy = () => { + if (!policyForMovingExpensesID) { + return; + } + + const createdReportID = createNewReport(currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policyForMovingExpensesID); + selectReport({value: createdReportID}); + }; + + const {handleCreateReport, CreateReportConfirmationModal} = useConditionalCreateEmptyReportConfirmation({ + policyID: policyForMovingExpensesID, + policyName: policyForMovingExpenses?.name ?? '', + onCreateReport: createReportForPolicy, + }); + const createReport = () => { if (!policyForMovingExpensesID && !shouldSelectPolicy) { return; @@ -89,20 +106,22 @@ function IOURequestEditReport({route}: IOURequestEditReportProps) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyForMovingExpensesID)); return; } - const createdReportID = createNewReport(currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policyForMovingExpensesID); - selectReport({value: createdReportID}); + handleCreateReport(); }; return ( - + <> + {CreateReportConfirmationModal} + + ); } diff --git a/src/pages/iou/request/step/IOURequestStepReport.tsx b/src/pages/iou/request/step/IOURequestStepReport.tsx index 3a11275b4686..f219924dc932 100644 --- a/src/pages/iou/request/step/IOURequestStepReport.tsx +++ b/src/pages/iou/request/step/IOURequestStepReport.tsx @@ -3,6 +3,7 @@ import {InteractionManager} from 'react-native'; import {useSession} from '@components/OnyxListItemProvider'; import {useSearchContext} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionListWithSections/types'; +import useConditionalCreateEmptyReportConfirmation from '@hooks/useConditionalCreateEmptyReportConfirmation'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; @@ -56,6 +57,7 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { const {policyForMovingExpensesID, shouldSelectPolicy} = usePolicyForMovingExpenses(); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const hasViolations = hasViolationsReportUtils(undefined, transactionViolations); + const policyForMovingExpenses = policyForMovingExpensesID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyForMovingExpensesID}`] : undefined; useRestartOnReceiptFailure(transaction, reportIDFromRoute, iouType, action); const handleGoBack = () => { @@ -174,6 +176,21 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, reportOrDraftReport, transaction); + const createReportForPolicy = () => { + if (!policyForMovingExpensesID) { + return; + } + + const createdReportID = createNewReport(currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policyForMovingExpensesID); + handleRegularReportSelection({value: createdReportID}); + }; + + const {handleCreateReport, CreateReportConfirmationModal} = useConditionalCreateEmptyReportConfirmation({ + policyID: policyForMovingExpensesID, + policyName: policyForMovingExpenses?.name ?? '', + onCreateReport: createReportForPolicy, + }); + const createReport = () => { if (!policyForMovingExpensesID && !shouldSelectPolicy) { return; @@ -187,24 +204,26 @@ function IOURequestStepReport({route, transaction}: IOURequestStepReportProps) { Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyForMovingExpensesID)); return; } - const createdReportID = createNewReport(currentUserPersonalDetails, hasViolations, isASAPSubmitBetaEnabled, policyForMovingExpensesID); - handleRegularReportSelection({value: createdReportID}); + handleCreateReport(); }; return ( - + <> + {CreateReportConfirmationModal} + + ); } diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 2bc541985cde..63b4d00b36e3 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -2,7 +2,7 @@ import {beforeAll} from '@jest/globals'; import {renderHook} from '@testing-library/react-native'; import {addDays, format as formatDate} from 'date-fns'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; import {putOnHold} from '@libs/actions/IOU'; @@ -54,6 +54,7 @@ import { getParticipantsList, getPolicyExpenseChat, getPolicyExpenseChatName, + getPolicyIDsWithEmptyReportsForAccount, getReasonAndReportActionThatRequiresAttention, getReportActionActorAccountID, getReportIDFromLink, @@ -63,6 +64,7 @@ import { getSearchReportName, getWorkspaceIcon, getWorkspaceNameUpdatedMessage, + hasEmptyReportsForPolicy, hasReceiptError, isAllowedToApproveExpenseReport, isArchivedNonExpenseReport, @@ -8199,4 +8201,260 @@ describe('ReportUtils', () => { expect(actorAccountID).toEqual(123); }); }); + + describe('hasEmptyReportsForPolicy', () => { + const policyID = 'workspace-001'; + const otherPolicyID = 'workspace-002'; + const accountID = 987654; + const otherAccountID = 123456; + + const buildReport = (overrides: Partial = {}): Report => ({ + reportID: overrides.reportID ?? 'report-1', + policyID, + ownerAccountID: accountID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 0, + nonReimbursableTotal: 0, + pendingAction: null, + errors: undefined, + ...overrides, + }); + + const toCollection = (...reports: Report[]): OnyxCollection => + reports.reduce>((acc, report, index) => { + acc[report.reportID ?? String(index)] = report; + return acc; + }, {}); + + const createTransactionForReport = (reportID: string, index = 0): Transaction => ({ + ...createRandomTransaction(index), + reportID, + transactionID: `${reportID}-transaction-${index}`, + }); + + it('returns false when policyID is missing or accountID invalid', () => { + const reportID = 'report-1'; + const reports = toCollection(buildReport({reportID})); + const transactions: Record = { + [reportID]: [], + }; + + expect(hasEmptyReportsForPolicy(reports, undefined, accountID, transactions)).toBe(false); + expect(hasEmptyReportsForPolicy(reports, policyID, Number.NaN, transactions)).toBe(false); + expect(hasEmptyReportsForPolicy(reports, policyID, CONST.DEFAULT_NUMBER_ID, transactions)).toBe(false); + }); + + it('returns true when an owned open expense report has no transactions', () => { + const reportID = 'empty-report'; + const reports = toCollection(buildReport({reportID})); + const transactions: Record = { + [reportID]: [], + }; + + expect(hasEmptyReportsForPolicy(reports, policyID, accountID, transactions)).toBe(true); + }); + + it('returns false when an owned expense report already has transactions', () => { + const reportID = 'with-transaction'; + const reports = toCollection(buildReport({reportID})); + const transactions: Record = { + [reportID]: [createTransactionForReport(reportID)], + }; + + expect(hasEmptyReportsForPolicy(reports, policyID, accountID, transactions)).toBe(false); + }); + + it('ignores reports owned by other users or policies', () => { + const reports = toCollection(buildReport({reportID: 'other-owner', ownerAccountID: otherAccountID}), buildReport({reportID: 'other-policy', policyID: otherPolicyID})); + const transactions: Record = { + 'other-owner': [], + 'other-policy': [], + }; + + expect(hasEmptyReportsForPolicy(reports, policyID, accountID, transactions)).toBe(false); + }); + + it('ignores reports that are not open expense reports even if they have no transactions', () => { + const reports = toCollection( + buildReport({reportID: 'closed', statusNum: CONST.REPORT.STATUS_NUM.CLOSED}), + buildReport({reportID: 'approved', stateNum: CONST.REPORT.STATE_NUM.APPROVED}), + buildReport({reportID: 'chat', type: CONST.REPORT.TYPE.CHAT}), + ); + const transactions: Record = { + closed: [], + approved: [], + chat: [], + }; + + expect(hasEmptyReportsForPolicy(reports, policyID, accountID, transactions)).toBe(false); + }); + + it('ignores reports flagged for deletion or with errors', () => { + const reports = toCollection( + buildReport({reportID: 'pending-delete', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), + buildReport({reportID: 'with-errors', errors: {test: 'error'}}), + ); + + const transactions: Record = { + 'pending-delete': [], + 'with-errors': [], + }; + + expect(hasEmptyReportsForPolicy(reports, policyID, accountID, transactions)).toBe(false); + }); + + it('returns true when at least one qualifying report exists among mixed data', () => { + const reports = toCollection(buildReport({reportID: 'valid-empty'}), buildReport({reportID: 'with-transaction'}), buildReport({reportID: 'other', policyID: otherPolicyID})); + + const transactions: Record = { + 'valid-empty': [], + 'with-transaction': [createTransactionForReport('with-transaction')], + other: [], + }; + + expect(hasEmptyReportsForPolicy(reports, policyID, accountID, transactions)).toBe(true); + }); + + it('returns false when accountID is the default one', () => { + const reports = toCollection(buildReport({reportID: 'valid-empty'}), buildReport({reportID: 'with-transaction'}), buildReport({reportID: 'other', policyID: otherPolicyID})); + + const transactions: Record = { + 'valid-empty': [], + 'with-transaction': [createTransactionForReport('with-transaction')], + other: [], + }; + + expect(hasEmptyReportsForPolicy(reports, policyID, CONST.DEFAULT_NUMBER_ID, transactions)).toBe(false); + }); + + it('supports minimal report summaries array', () => { + const reportID = 'summary-report'; + const minimalReports = [ + { + reportID, + policyID, + ownerAccountID: accountID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 0, + nonReimbursableTotal: 0, + pendingAction: null, + errors: undefined, + }, + ]; + + const transactions: Record = { + [reportID]: [], + }; + + expect(hasEmptyReportsForPolicy(minimalReports, policyID, accountID, transactions)).toBe(true); + }); + }); + + describe('getPolicyIDsWithEmptyReportsForAccount', () => { + const policyID = 'workspace-001'; + const otherPolicyID = 'workspace-002'; + const accountID = 555555; + const otherAccountID = 999999; + + const buildReport = (overrides: Partial = {}): Report => ({ + reportID: overrides.reportID ?? 'report-1', + policyID, + ownerAccountID: accountID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 0, + nonReimbursableTotal: 0, + pendingAction: null, + errors: undefined, + ...overrides, + }); + + const toCollection = (...reports: Report[]): OnyxCollection => + reports.reduce>((acc, report, index) => { + acc[report.reportID ?? String(index)] = report; + return acc; + }, {}); + + const createTransactionForReport = (reportID: string, index = 0): Transaction => ({ + ...createRandomTransaction(index), + reportID, + transactionID: `${reportID}-txn-${index}`, + }); + + it('returns empty object when accountID is missing', () => { + const reportID = 'empty'; + const reports = toCollection(buildReport({reportID})); + const transactions: Record = { + [reportID]: [], + }; + + expect(getPolicyIDsWithEmptyReportsForAccount(reports, undefined, transactions)).toEqual({}); + }); + + it('marks policy IDs that have empty reports owned by the user', () => { + const reportA = 'policy-a'; + const reportB = 'policy-b'; + const reports = toCollection(buildReport({reportID: reportA, policyID}), buildReport({reportID: reportB, policyID: otherPolicyID})); + const transactions: Record = { + [reportA]: [], + [reportB]: [], + }; + + expect(getPolicyIDsWithEmptyReportsForAccount(reports, accountID, transactions)).toEqual({ + [policyID]: true, + [otherPolicyID]: true, + }); + }); + + it('supports minimal summaries input', () => { + const reportID = 'summary-report'; + const summaries = [ + { + reportID, + policyID, + ownerAccountID: accountID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total: 0, + nonReimbursableTotal: 0, + pendingAction: null, + errors: undefined, + }, + ]; + + const transactions: Record = { + [reportID]: [], + }; + + expect(getPolicyIDsWithEmptyReportsForAccount(summaries, accountID, transactions)).toEqual({ + [policyID]: true, + }); + }); + + it('ignores reports that do not qualify', () => { + const reports = toCollection( + buildReport({reportID: 'with-money', total: 100}), + buildReport({reportID: 'other-owner', ownerAccountID: otherAccountID}), + buildReport({reportID: 'pending-delete', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), + buildReport({reportID: 'with-errors', errors: {message: 'error'}}), + buildReport({reportID: 'chat', type: CONST.REPORT.TYPE.CHAT}), + ); + + const transactions: Record = { + 'with-money': [createTransactionForReport('with-money')], + 'other-owner': [], + 'pending-delete': [], + 'with-errors': [], + chat: [], + }; + + expect(getPolicyIDsWithEmptyReportsForAccount(reports, accountID, transactions)).toEqual({}); + }); + }); }); diff --git a/tests/unit/useCreateEmptyReportConfirmationTest.tsx b/tests/unit/useCreateEmptyReportConfirmationTest.tsx new file mode 100644 index 000000000000..63ad3b765819 --- /dev/null +++ b/tests/unit/useCreateEmptyReportConfirmationTest.tsx @@ -0,0 +1,205 @@ +import {act, render, renderHook} from '@testing-library/react-native'; +import type {ReactElement, ReactNode} from 'react'; +import useCreateEmptyReportConfirmation from '@hooks/useCreateEmptyReportConfirmation'; +import Navigation from '@libs/Navigation/Navigation'; +import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +type MockConfirmModalProps = { + prompt: ReactNode; + confirmText?: string; + cancelText?: string; + isVisible?: boolean; + onConfirm?: () => void | Promise; + onCancel?: () => void; + title?: string; +}; + +type MockTextLinkProps = { + children?: ReactNode; + onPress?: () => void; + onLongPress?: (event: unknown) => void; +}; + +type MockReactModule = { + createElement: (...args: unknown[]) => ReactElement; +}; + +const mockTranslate = jest.fn((key: string, params?: Record) => (params?.workspaceName ? `${key}:${params.workspaceName}` : key)); + +let mockTextLinkProps: MockTextLinkProps | undefined; + +jest.mock('@hooks/useLocalize', () => () => ({ + translate: mockTranslate, +})); + +jest.mock('@components/ConfirmModal', () => { + const mockReact: MockReactModule = jest.requireActual('react'); + return ({prompt, confirmText, cancelText, isVisible, onConfirm, onCancel, title}: MockConfirmModalProps) => + mockReact.createElement('mock-confirm-modal', {prompt, confirmText, cancelText, isVisible, onConfirm, onCancel, title}, null); +}); + +jest.mock('@components/Text', () => { + const mockReact: MockReactModule = jest.requireActual('react'); + return ({children}: {children?: ReactNode}) => mockReact.createElement('mock-text', null, children); +}); + +jest.mock('@components/TextLink', () => { + const mockReact: MockReactModule = jest.requireActual('react'); + return (props: MockTextLinkProps) => { + mockTextLinkProps = props; + const {children, onPress, onLongPress} = props; + return mockReact.createElement('mock-text-link', {onPress, onLongPress}, children); + }; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), +})); + +type HookValue = ReturnType; + +type MockConfirmModalElement = ReactElement; + +function getModal(hookValue: HookValue): MockConfirmModalElement { + return hookValue.CreateReportConfirmationModal as MockConfirmModalElement; +} + +function getRequiredHandler unknown>(handler: T | undefined, name: string): T { + if (!handler) { + throw new Error(`${name} handler was not provided`); + } + return handler; +} + +const policyID = 'policy-123'; +const policyName = 'Engineering Team'; + +const expectedSearchRoute = ROUTES.SEARCH_ROOT.getRoute({ + query: buildCannedSearchQuery({groupBy: CONST.SEARCH.GROUP_BY.REPORTS}), +}); + +describe('useCreateEmptyReportConfirmation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockTranslate.mockClear(); + mockTextLinkProps = undefined; + }); + + it('modal is hidden by default and opens on demand', () => { + const onConfirm = jest.fn(); + const {result} = renderHook(() => + useCreateEmptyReportConfirmation({ + policyID, + policyName, + onConfirm, + }), + ); + + let modal = getModal(result.current); + expect(modal.props.isVisible).toBe(false); + + act(() => { + result.current.openCreateReportConfirmation(); + }); + + modal = getModal(result.current); + expect(modal.props.isVisible).toBe(true); + }); + + it('invokes onConfirm and resets state after completion', () => { + const onConfirm = jest.fn(); + const {result} = renderHook(() => + useCreateEmptyReportConfirmation({ + policyID, + policyName, + onConfirm, + }), + ); + + act(() => { + result.current.openCreateReportConfirmation(); + }); + + let modal = getModal(result.current); + const confirmHandler = getRequiredHandler(modal.props.onConfirm, 'onConfirm'); + + act(() => { + confirmHandler(); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + + modal = getModal(result.current); + expect(modal.props.isVisible).toBe(false); + }); + + it('calls onCancel when cancellation occurs', () => { + const onConfirm = jest.fn(); + const onCancel = jest.fn(); + + const {result} = renderHook(() => + useCreateEmptyReportConfirmation({ + policyID, + policyName, + onConfirm, + onCancel, + }), + ); + + act(() => { + result.current.openCreateReportConfirmation(); + }); + + const modal = getModal(result.current); + const cancelHandler = getRequiredHandler(modal.props.onCancel, 'onCancel'); + + act(() => { + cancelHandler(); + }); + + expect(onConfirm).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledTimes(1); + + const updatedModal = getModal(result.current); + expect(updatedModal.props.isVisible).toBe(false); + }); + + it('navigates to reports search when link in prompt is pressed', () => { + const onConfirm = jest.fn(); + const {result} = renderHook(() => + useCreateEmptyReportConfirmation({ + policyID, + policyName: '', + onConfirm, + }), + ); + + const modal = getModal(result.current); + const {unmount} = render(modal.props.prompt as ReactElement); + const onPress = mockTextLinkProps?.onPress; + + expect(onPress).toBeDefined(); + + act(() => { + onPress?.(); + }); + + expect(Navigation.navigate).toHaveBeenCalledWith(expectedSearchRoute); + unmount(); + }); + + it('falls back to generic workspace name in translations when necessary', () => { + const onConfirm = jest.fn(); + renderHook(() => + useCreateEmptyReportConfirmation({ + policyID, + policyName: ' ', + onConfirm, + }), + ); + + expect(mockTranslate).toHaveBeenCalledWith('report.newReport.genericWorkspaceName'); + }); +});