diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c43cd9656bce..e31529d84e30 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9000,6 +9000,7 @@ const CONST = { SUBMIT: 'submit', APPROVE: 'approve', PAY: 'pay', + REJECTED: 'rejected', EXPORT: 'export', STATEMENTS: 'statements', UNAPPROVED_CASH: 'unapprovedCash', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 9610d2390aeb..51facbedfec4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -276,6 +276,9 @@ const ONYXKEYS = { /** Whether the "For You" section has ever shown an actionable to-do (keeps the section visible once seen) */ NVP_HAS_SEEN_FOR_YOU_TODO: 'nvp_hasSeenForYouTodo', + /** Whether the user has ever had a rejected expense (keeps the "Rejected" suggested search visible once seen) */ + NVP_HAS_SEEN_REJECTED_SUGGESTED_SEARCH: 'nvp_hasSeenRejectedSuggestedSearch', + /** The insight the user last selected in the Home page insights card (defaults to Spend over time) */ NVP_HOME_SELECTED_INSIGHT: 'nvp_homeSelectedInsight', @@ -1911,6 +1914,7 @@ type OnyxValuesMapping = { [ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING]: Record; [ONYXKEYS.HAS_LOADED_APP]: boolean; [ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO]: boolean; + [ONYXKEYS.NVP_HAS_SEEN_REJECTED_SUGGESTED_SEARCH]: boolean; [ONYXKEYS.NVP_HOME_SELECTED_INSIGHT]: | typeof CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME | typeof CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS diff --git a/src/hooks/useHasRejectedExpense.ts b/src/hooks/useHasRejectedExpense.ts new file mode 100644 index 000000000000..bfaafc0b55c8 --- /dev/null +++ b/src/hooks/useHasRejectedExpense.ts @@ -0,0 +1,83 @@ +import {hasReportBeenRejectedToSubmitter, isCurrentUserSubmitter} from '@libs/ReportUtils'; +import {isExpenseUnreported} from '@libs/TransactionUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, Transaction, TransactionViolations} from '@src/types/onyx'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import {sessionEmailAndAccountIDSelector} from '@selectors/Session'; +// We need direct access to useOnyx from react-native-onyx to avoid reading search snapshots instead of live data +// eslint-disable-next-line no-restricted-imports +import {useOnyx} from 'react-native-onyx'; + +type RejectedTransactionsLocation = { + /** Whether a rejected expense is unreported */ + hasUnreportedRejectedTransaction: boolean; + + /** IDs of the reports that hold a rejected expense */ + reportIDs: string[]; +}; + +// Approvers and admins also keep the rejected-expense violation of expenses they rejected, so skip the current user's own rejections. +const rejectedTransactionIDsSelector = + (currentUserEmail: string | undefined) => + (allTransactionViolations: OnyxCollection): string[] => + Object.entries(allTransactionViolations ?? {}) + .filter(([, transactionViolations]) => + transactionViolations?.some( + (violation) => violation.name === CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE && (!currentUserEmail || violation.data?.rejectedBy !== currentUserEmail), + ), + ) + .map(([key]) => key.replace(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, '')); + +// The violation doesn't say who submitted the expense, so find where each rejected expense lives to check its owner. +const rejectedTransactionsLocationSelector = + (rejectedTransactionIDs: string[]) => + (allTransactions: OnyxCollection): RejectedTransactionsLocation => { + const location: RejectedTransactionsLocation = {hasUnreportedRejectedTransaction: false, reportIDs: []}; + for (const transactionID of rejectedTransactionIDs) { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + continue; + } + // Rejecting an expense on an instantly submitted report moves it out of the report. Unreported expenses are + // only synced to their owner. + if (!transaction.reportID || isExpenseUnreported(transaction)) { + location.hasUnreportedRejectedTransaction = true; + continue; + } + location.reportIDs.push(transaction.reportID); + } + return location; + }; + +// A whole-report rejection doesn't add a violation to the report's transactions, so it has to be checked on the report. +const hasOwnRejectedReportSelector = + (currentUserAccountID: number | undefined, rejectedTransactionReportIDs: string[]) => + (allReports: OnyxCollection): boolean => + Object.values(allReports ?? {}).some( + (report) => + !!currentUserAccountID && + isCurrentUserSubmitter(report, currentUserAccountID) && + (hasReportBeenRejectedToSubmitter(report) || (!!report?.reportID && rejectedTransactionReportIDs.includes(report.reportID))), + ); + +/** + * Whether the current user has at least one of their own expenses rejected, either on its own or as part of a rejected report. + * The last selector returns a boolean so consumers only re-render when the answer changes. + */ +function useHasRejectedExpense(): boolean { + const [session] = useOnyx(ONYXKEYS.SESSION, {selector: sessionEmailAndAccountIDSelector}); + const [rejectedTransactionIDs = getEmptyArray()] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {selector: rejectedTransactionIDsSelector(session?.email)}); + const [rejectedTransactionsLocation] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {selector: rejectedTransactionsLocationSelector(rejectedTransactionIDs)}); + const [hasOwnRejectedReport = false] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + selector: hasOwnRejectedReportSelector(session?.accountID, rejectedTransactionsLocation?.reportIDs ?? getEmptyArray()), + }); + + return !!rejectedTransactionsLocation?.hasUnreportedRejectedTransaction || hasOwnRejectedReport; +} + +export default useHasRejectedExpense; diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 8d1cb9d21b84..0c2aa5035851 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -1,3 +1,4 @@ +import {setHasSeenRejectedSuggestedSearch} from '@libs/actions/Search'; import {createTypeMenuSections, omitInsightsPageMenuItems, omitTravelApprovalMenuItems} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -13,6 +14,7 @@ import {useEffect, useState} from 'react'; import useCardFeedsForDisplay from './useCardFeedsForDisplay'; import useCreateEmptyReportConfirmation from './useCreateEmptyReportConfirmation'; +import useHasRejectedExpense from './useHasRejectedExpense'; import useHasReportAwaitingApproval from './useHasReportAwaitingApproval'; import useMappedPolicies from './useMappedPolicies'; import useNetwork from './useNetwork'; @@ -77,6 +79,21 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { // A report awaiting the current user's approval makes the "Needs approval" suggested search relevant even when they // are not part of the policy's approval workflow (e.g. an approver chosen manually on a single report). const hasReportAwaitingApproval = useHasReportAwaitingApproval(isScreenFocused); + + // The "Rejected" suggested search appears once the user has a rejected expense, then stays visible for good so it + // doesn't come and go as expenses are rejected and resolved. + const hasRejectedExpense = useHasRejectedExpense(); + const [hasSeenRejectedSuggestedSearch = false, hasSeenRejectedSuggestedSearchMetadata] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_REJECTED_SUGGESTED_SEARCH); + const isHasSeenRejectedSuggestedSearchLoading = hasSeenRejectedSuggestedSearchMetadata.status === 'loading'; + const shouldShowRejectedSuggestion = hasRejectedExpense || hasSeenRejectedSuggestedSearch; + + useEffect(() => { + if (isHasSeenRejectedSuggestedSearchLoading || !hasRejectedExpense || hasSeenRejectedSuggestedSearch) { + return; + } + setHasSeenRejectedSuggestedSearch(); + }, [isHasSeenRejectedSuggestedSearchLoading, hasRejectedExpense, hasSeenRejectedSuggestedSearch]); + const [pendingReportCreation, setPendingReportCreation] = useState<{policyID: string; policyName?: string; onConfirm: (shouldDismissEmptyReportsConfirmation: boolean) => void} | null>( null, ); @@ -118,6 +135,7 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { isTrackIntentUser: isTrackIntentUser ?? false, hasReportAwaitingApproval, policyCategories: allPolicyCategories, + shouldShowRejectedSuggestion, }); return typeMenuSections; diff --git a/src/languages/de.ts b/src/languages/de.ts index 993ff2e23d9c..169504792481 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -10319,6 +10319,10 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc title: 'Keine zu bezahlenden Ausgaben', subtitle: 'Glückwunsch! Du hast die Ziellinie überquert.', }, + emptyRejectedResults: { + title: 'Keine abgelehnten Ausgaben', + subtitle: 'Nichts zu korrigieren. Gute Arbeit!', + }, emptyExportResults: { title: 'Keine Spesen zum Exportieren', subtitle: 'Zeit, es ruhig angehen zu lassen – gute Arbeit.', @@ -10578,6 +10582,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc submit: 'Entwürfe', approve: 'Genehmigung erforderlich', pay: 'Zahlungsbereit', + rejected: 'Abgelehnt', accounting: 'Buchhaltung', export: 'Export ausstehend', unapprovedCash: 'Kassenabgrenzungen', diff --git a/src/languages/el.ts b/src/languages/el.ts index fee6ffe37aa8..13ed8bb54d87 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10466,6 +10466,7 @@ ${reportName}`, submit: 'Πρόχειρα', approve: 'Χρειάζεται έγκριση', pay: 'Έτοιμο για πληρωμή', + rejected: 'Απορρίφθηκε', accounting: 'Λογιστική', export: 'Αναμονή για εξαγωγή', unapprovedCash: 'Δεδουλευμένα μετρητών', @@ -10525,6 +10526,10 @@ ${reportName}`, title: 'Δεν υπάρχουν έξοδα προς πληρωμή', subtitle: 'Συγχαρητήρια! Περάσατε τη γραμμή τερματισμού.', }, + emptyRejectedResults: { + title: 'Δεν υπάρχουν απορριφθέντα έξοδα', + subtitle: 'Τίποτα για διόρθωση. Μπράβο!', + }, emptyExportResults: { title: 'Δεν υπάρχουν έξοδα για εξαγωγή', subtitle: 'Ώρα να χαλαρώσετε, μπράβο σας.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 3e642a8324cc..2d3df74b31f4 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10490,6 +10490,7 @@ const translations = { submit: 'Drafts', approve: 'Needs approval', pay: 'Ready to pay', + rejected: 'Rejected', accounting: 'Accounting', export: 'Awaiting export', unapprovedCash: 'Cash accruals', @@ -10549,6 +10550,10 @@ const translations = { title: 'No expenses to pay', subtitle: 'Congrats! You crossed the finish line.', }, + emptyRejectedResults: { + title: 'No rejected expenses', + subtitle: 'Nothing to fix. Nice work!', + }, emptyExportResults: { title: 'No expenses to export', subtitle: 'Time to take it easy, nice work.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 3dfc39d5e25a..145d96fa27c2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10101,6 +10101,7 @@ ${reportName}`, submit: 'Borradores', approve: 'Necesita aprobación', pay: 'Listo para pagar', + rejected: 'Rechazado', accounting: 'Contabilidad', export: 'Pendiente de exportación', unapprovedCash: 'Acumulaciones de efectivo', @@ -10156,6 +10157,10 @@ ${reportName}`, title: 'No hay gastos para pagar', subtitle: '¡Felicidades! Has cruzado la línea de meta.', }, + emptyRejectedResults: { + title: 'No hay gastos rechazados', + subtitle: 'Nada que corregir. ¡Buen trabajo!', + }, emptyExportResults: { title: 'No hay gastos para exportar', subtitle: 'Es hora de relajarse, buen trabajo.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 68e02553033f..da0ace964598 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10345,6 +10345,10 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e title: 'Aucune dépense à payer', subtitle: 'Félicitations ! Vous avez franchi la ligne d’arrivée.', }, + emptyRejectedResults: { + title: 'Aucune dépense rejetée', + subtitle: 'Rien à corriger. Beau travail !', + }, emptyExportResults: { title: 'Aucune dépense à exporter', subtitle: 'Il est temps de se détendre, beau travail.', @@ -10604,6 +10608,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e submit: 'Brouillons', approve: 'Nécessite une approbation', pay: 'Prêt à payer', + rejected: 'Rejeté', accounting: 'Comptabilité', export: 'En attente d’exportation', unapprovedCash: 'Provisions de trésorerie', diff --git a/src/languages/it.ts b/src/languages/it.ts index eda2caf166a5..7ffc642b8034 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10273,6 +10273,10 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, title: 'Nessuna spesa da pagare', subtitle: 'Congratulazioni! Hai tagliato il traguardo.', }, + emptyRejectedResults: { + title: 'Nessuna spesa rifiutata', + subtitle: 'Niente da correggere. Ottimo lavoro!', + }, emptyExportResults: { title: 'Nessuna spesa da esportare', subtitle: 'È il momento di rilassarsi, bel lavoro.', @@ -10532,6 +10536,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, submit: 'Bozze', approve: 'Richiede approvazione', pay: 'Pronto per pagare', + rejected: 'Rifiutato', accounting: 'Contabilità', export: 'In attesa di esportazione', unapprovedCash: 'Ratei e risconti in contanti', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1b5e22ebd6ba..d895db162d85 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10121,6 +10121,10 @@ ${reportName}`, title: '支払う経費はありません', subtitle: 'おめでとうございます!ゴールに到達しました。', }, + emptyRejectedResults: { + title: '却下された経費はありません', + subtitle: '修正が必要なものはありません。お疲れさまでした!', + }, emptyExportResults: { title: 'エクスポートする経費はありません', subtitle: 'ひと休みする時間です、お疲れさまでした。', @@ -10370,6 +10374,7 @@ ${reportName}`, submit: '下書き', approve: '承認が必要', pay: '支払いの準備完了', + rejected: '却下済み', accounting: '会計', export: 'エクスポート待ち', unapprovedCash: '現金発生主義', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6098fa8fb7d1..fff120a7f391 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10242,6 +10242,10 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, title: 'Geen uitgaven om te betalen', subtitle: 'Gefeliciteerd! Je hebt de finish gehaald.', }, + emptyRejectedResults: { + title: 'Geen afgekeurde uitgaven', + subtitle: 'Niets om te corrigeren. Goed gedaan!', + }, emptyExportResults: { title: 'Geen uitgaven om te exporteren', subtitle: 'Tijd om rustig aan te doen, goed gedaan.', @@ -10501,6 +10505,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, submit: 'Concepten', approve: 'Moet worden goedgekeurd', pay: 'Klaar om te betalen', + rejected: 'Afgekeurd', accounting: 'Boekhouding', export: 'Wachten op export', unapprovedCash: 'Kasverplichtingen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a8ad9f9451cb..b4df9eb5d0e0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -10258,6 +10258,10 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, title: 'Brak wydatków do opłacenia', subtitle: 'Gratulacje! Przekroczyłeś linię mety.', }, + emptyRejectedResults: { + title: 'Brak odrzuconych wydatków', + subtitle: 'Nie ma nic do poprawienia. Dobra robota!', + }, emptyExportResults: { title: 'Brak wydatków do wyeksportowania', subtitle: 'Czas trochę odetchnąć, dobra robota.', @@ -10523,6 +10527,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, submit: 'Szkice', approve: 'Wymaga zatwierdzenia', pay: 'Gotowe do zapłaty', + rejected: 'Odrzucone', accounting: 'Księgowość', export: 'Oczekuje na eksport', unapprovedCash: 'Rozliczenia kasowe', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9cbab68aa188..a4c4c81e2212 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10235,6 +10235,10 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, title: 'Nenhuma despesa para pagar', subtitle: 'Parabéns! Você cruzou a linha de chegada.', }, + emptyRejectedResults: { + title: 'Nenhuma despesa rejeitada', + subtitle: 'Nada para corrigir. Bom trabalho!', + }, emptyExportResults: { title: 'Nenhuma despesa para exportar', subtitle: 'Hora de relaxar, bom trabalho.', @@ -10494,6 +10498,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, submit: 'Rascunhos', approve: 'Precisa de aprovação', pay: 'Pronto para pagar', + rejected: 'Rejeitado', accounting: 'Contabilidade', export: 'Aguardando exportação', unapprovedCash: 'Provisões de caixa', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 3c774217671f..c40e62e7dc33 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9854,6 +9854,10 @@ ${reportName}`, title: '暂无可支付的报销', subtitle: '恭喜!你已冲过终点线。', }, + emptyRejectedResults: { + title: '暂无被拒绝的报销', + subtitle: '没有需要修正的内容。干得好!', + }, emptyExportResults: { title: '没有可导出的报销费用', subtitle: '该放松一下了,干得不错。', @@ -10101,6 +10105,7 @@ ${reportName}`, submit: '草稿', approve: '待审批', pay: '准备付款', + rejected: '已拒绝', accounting: '会计', export: '等待导出', unapprovedCash: '现金应计', diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index a22ba9bd612a..28f7721b0534 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -310,6 +310,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_EXACT_ONLINE_WAITLIST, ONYXKEYS.NVP_HAS_SEEDED_MY_EXPENSES_SEARCH, ONYXKEYS.NVP_HAS_SEEN_FOR_YOU_TODO, + ONYXKEYS.NVP_HAS_SEEN_REJECTED_SUGGESTED_SEARCH, ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING, ONYXKEYS.NVP_HOME_SELECTED_INSIGHT, ONYXKEYS.NVP_LAST_ANDROID_LOGIN, diff --git a/src/libs/SearchSuggestionUtils.ts b/src/libs/SearchSuggestionUtils.ts index f12c263dddd1..8edf26acfd2f 100644 --- a/src/libs/SearchSuggestionUtils.ts +++ b/src/libs/SearchSuggestionUtils.ts @@ -50,6 +50,7 @@ const SEARCH_TYPE_MENU_ICON_NAMES = [ 'Document', 'Pencil', 'ThumbsUp', + 'ThumbsDown', 'CheckCircle', 'Plane', ] as const satisfies readonly ExpensifyIconName[]; @@ -259,6 +260,28 @@ function getSuggestedSearches( return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID; }, }, + [CONST.SEARCH.SEARCH_KEYS.REJECTED]: { + key: CONST.SEARCH.SEARCH_KEYS.REJECTED, + translationPath: 'search.tabs.rejected', + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + icon: 'ThumbsDown', + searchQuery: buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + declined: CONST.SEARCH.BOOLEAN.YES, + }), + get searchQueryJSON() { + return buildSearchQueryJSON(this.searchQuery); + }, + get hash() { + return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID; + }, + get similarSearchHash() { + return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + get recentSearchHash() { + return this.searchQueryJSON?.recentSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + }, [CONST.SEARCH.SEARCH_KEYS.EXPORT]: { key: CONST.SEARCH.SEARCH_KEYS.EXPORT, translationPath: 'search.tabs.export', diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 698cbbcc6d2d..1310e739552e 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -714,6 +714,7 @@ function isPolicyEligibleForTopCategories(policy: OnyxTypes.Policy): boolean { * * @param policyCategories - Needed for migrated Control workspaces where `areRulesEnabled` is undefined; the Violations * by submitter insight then falls back to Classic category rules via `arePolicyRulesEnabled`. + * @param shouldShowRejectedSuggestion - True once the user has had a rejected expense; it doesn't depend on any policy. */ function getSuggestedSearchesVisibility( currentUserEmail: string | undefined, @@ -723,6 +724,7 @@ function getSuggestedSearchesVisibility( hasReportAwaitingApproval = false, isTrackIntentUser = false, policyCategories?: OnyxCollection, + shouldShowRejectedSuggestion = false, ): {visibility: Record, boolean>; hasEligibleGroupPolicies: boolean; shouldShowExpensifyCard: boolean} { let shouldShowSubmitSuggestion = false; let shouldShowPaySuggestion = false; @@ -821,6 +823,7 @@ function getSuggestedSearchesVisibility( [CONST.SEARCH.SEARCH_KEYS.SUBMIT]: shouldShowSubmitSuggestion, [CONST.SEARCH.SEARCH_KEYS.PAY]: shouldShowPaySuggestion, [CONST.SEARCH.SEARCH_KEYS.APPROVE]: shouldShowApproveSuggestion, + [CONST.SEARCH.SEARCH_KEYS.REJECTED]: shouldShowRejectedSuggestion, [CONST.SEARCH.SEARCH_KEYS.EXPORT]: shouldShowExportSuggestion, [CONST.SEARCH.SEARCH_KEYS.STATEMENTS]: shouldShowStatementsSuggestion, [CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH]: shouldShowUnapprovedCashSuggestion, @@ -4824,6 +4827,7 @@ type TypeMenuSectionsParams = { isTrackIntentUser: boolean; hasReportAwaitingApproval?: boolean; policyCategories?: OnyxCollection; + shouldShowRejectedSuggestion?: boolean; }; function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuSection[] { @@ -4841,6 +4845,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS isTrackIntentUser, hasReportAwaitingApproval = false, policyCategories, + shouldShowRejectedSuggestion = false, } = params; const typeMenuSections: SearchTypeMenuSection[] = []; @@ -4848,7 +4853,16 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS visibility: suggestedSearchesVisibility, hasEligibleGroupPolicies, shouldShowExpensifyCard, - } = getSuggestedSearchesVisibility(currentUserEmail, cardFeedsByPolicy, policies, defaultExpensifyCard, hasReportAwaitingApproval, isTrackIntentUser, policyCategories); + } = getSuggestedSearchesVisibility( + currentUserEmail, + cardFeedsByPolicy, + policies, + defaultExpensifyCard, + hasReportAwaitingApproval, + isTrackIntentUser, + policyCategories, + shouldShowRejectedSuggestion, + ); const suggestedSearches = getSuggestedSearches(currentUserAccountID, defaultCardFeed?.id, shouldShowExpensifyCard, activeExpensifyCardFeedID); const hasAnyPolicyWithWorkflowsEnabled = Object.values(policies ?? {}).some((policy) => policy?.areWorkflowsEnabled); const isTrackIntentWithWorkflowsDisabled = isTrackIntentUser && !hasAnyPolicyWithWorkflowsEnabled; @@ -4910,6 +4924,16 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS } } + if (suggestedSearchesVisibility[CONST.SEARCH.SEARCH_KEYS.REJECTED]) { + expenseReportsSection.menuItems.push({ + ...suggestedSearches[CONST.SEARCH.SEARCH_KEYS.REJECTED], + emptyState: { + title: 'search.searchResults.emptyRejectedResults.title', + subtitle: 'search.searchResults.emptyRejectedResults.subtitle', + }, + }); + } + if (expenseReportsSection.menuItems.length > 0) { typeMenuSections.push(expenseReportsSection); } diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index c338d679dd29..e171464658f6 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -126,6 +126,7 @@ import {approveMoneyRequest} from './IOU/ReportWorkflow'; import {isCurrencySupportedForGlobalReimbursement} from './Policy/Policy'; import {setOptimisticTransactionThread} from './Report'; import {saveLastSearchParams} from './ReportNavigation'; +import {setNameValuePair} from './User'; type OnyxSearchResponse = { data: SearchResults['data']; @@ -998,6 +999,11 @@ function seedMyExpensesSearch(currentUserAccountID: number, searchName: string, write(WRITE_COMMANDS.SAVE_SEARCH, {jsonQuery, savedSearchID: queryJSON.hash.toString(), newName: searchName}, {optimisticData, failureData, successData}); } +/** Persists a one-time NVP the first time the user has a rejected expense, so the "Rejected" suggested search stays visible across sessions. */ +function setHasSeenRejectedSuggestedSearch() { + setNameValuePair(ONYXKEYS.NVP_HAS_SEEN_REJECTED_SUGGESTED_SEARCH, true, false); +} + function deleteSavedSearch(savedSearchID: string) { const optimisticData: Array> = [ { @@ -2732,6 +2738,7 @@ export { parseExpenseFilters, saveSearch, seedMyExpensesSearch, + setHasSeenRejectedSuggestedSearch, search, getFooterConvertedAmounts, clearFooterConversion, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 772e3c953690..d90ebae60f07 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -9498,6 +9498,39 @@ describe('SearchUIUtils', () => { expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.EXPORT); }); + it('should show the Rejected suggested search in the Expense reports section only when shouldShowRejectedSuggestion is true', () => { + // Given a user with no policies, so no policy-based signal can affect the Rejected item + const params = { + currentUserEmail: adminEmail, + currentUserAccountID: adminAccountID, + cardFeedsByPolicy: {}, + defaultCardFeed: undefined, + policies: {}, + savedSearches: {}, + isOffline: false, + defaultExpensifyCard: undefined, + draftTransactionIDs: [], + isTrackIntentUser: false, + }; + + // When the user has never had a rejected expense + const sectionsWithoutRejected = SearchUIUtils.createTypeMenuSections(params); + + // Then the Rejected item is hidden + const keysWithoutRejected = sectionsWithoutRejected.flatMap((section) => section.menuItems.map((item) => item.key)); + expect(keysWithoutRejected).not.toContain(CONST.SEARCH.SEARCH_KEYS.REJECTED); + + // When the user has (or has had) a rejected expense + const sectionsWithRejected = SearchUIUtils.createTypeMenuSections({...params, shouldShowRejectedSuggestion: true}); + + // Then the Rejected item shows in the Expense reports section with its empty state, using the declined filter + const expenseReportsSection = sectionsWithRejected.find((section) => section.translationPath === 'search.tabs.expenseReports'); + const rejectedItem = expenseReportsSection?.menuItems.find((item) => item.key === CONST.SEARCH.SEARCH_KEYS.REJECTED); + expect(rejectedItem).toBeDefined(); + expect(rejectedItem?.searchQuery).toContain('declined:yes'); + expect(rejectedItem?.emptyState?.title).toBe('search.searchResults.emptyRejectedResults.title'); + }); + it('should keep Submit visible without CTA buttons for group workspaces that are not CTA-eligible', () => { const sections = SearchUIUtils.createTypeMenuSections({ currentUserEmail: adminEmail, @@ -11224,6 +11257,21 @@ describe('SearchUIUtils', () => { expect(withReport.visibility.approve).toBe(true); }); + test('Should show the Rejected suggestion only when shouldShowRejectedSuggestion is true', () => { + // Given a user without any policies, since the Rejected suggestion doesn't depend on policy settings + // When the caller hasn't seen a rejected expense + const hidden = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, {}, undefined); + + // Then the suggestion stays hidden + expect(hidden.visibility.rejected).toBe(false); + + // When the caller reports a current or previously seen rejected expense + const shown = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, {}, undefined, false, false, undefined, true); + + // Then the suggestion is visible + expect(shown.visibility.rejected).toBe(true); + }); + test('Should not throw when employeeList contains a null entry', () => { // Given an approval-enabled policy whose employeeList holds a null entry, which Onyx can leave behind when // several policy updates are applied in one batch diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 10dfc4cd2779..23f19499c6ad 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -195,6 +195,7 @@ const spendIcons = { Document: mockIcon, Pencil: mockIcon, ThumbsUp: mockIcon, + ThumbsDown: mockIcon, CheckCircle: mockIcon, UserEye: mockIcon, Plane: mockIcon, diff --git a/tests/unit/useHasRejectedExpenseTest.tsx b/tests/unit/useHasRejectedExpenseTest.tsx new file mode 100644 index 000000000000..b883182aab06 --- /dev/null +++ b/tests/unit/useHasRejectedExpenseTest.tsx @@ -0,0 +1,167 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useHasRejectedExpense from '@hooks/useHasRejectedExpense'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, Transaction, TransactionViolations} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import {createRandomReport} from '../utils/collections/reports'; +import createRandomTransaction from '../utils/collections/transaction'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const CURRENT_USER_ACCOUNT_ID = 1; +const CURRENT_USER_EMAIL = 'submitter@mail.com'; +const OTHER_USER_ACCOUNT_ID = 2; +const OTHER_USER_EMAIL = 'approver@mail.com'; + +const createExpenseReport = (reportID: string, ownerAccountID: number, overrides: Partial = {}): Report => ({ + ...createRandomReport(1), + reportID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + ownerAccountID, + nextStep: undefined, + ...overrides, +}); + +const createTransaction = (transactionID: string, reportID: string): Transaction => ({ + ...createRandomTransaction(1), + transactionID, + reportID, +}); + +const createRejectedViolations = (rejectedBy: string): TransactionViolations => [ + { + name: CONST.VIOLATIONS.AUTO_REPORTED_REJECTED_EXPENSE, + type: CONST.VIOLATION_TYPES.WARNING, + data: {rejectedBy, comment: ''}, + showInReview: true, + }, +]; + +const seedRejectedExpense = (transactionID: string, reportID: string, rejectedBy = OTHER_USER_EMAIL) => + Promise.all([ + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, createTransaction(transactionID, reportID)), + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, createRejectedViolations(rejectedBy)), + ]); + +const renderRejectedExpenseHook = async () => { + const hook = renderHook(() => useHasRejectedExpense()); + await act(async () => { + await waitForBatchedUpdates(); + }); + return hook; +}; + +describe('useHasRejectedExpense', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: CURRENT_USER_ACCOUNT_ID}); + await waitForBatchedUpdates(); + }); + + it('returns false when the user has no rejected expense', async () => { + // Given an open report owned by the current user with no rejection + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}report_1`, createExpenseReport('report_1', CURRENT_USER_ACCOUNT_ID)); + await waitForBatchedUpdates(); + + // When the hook runs + const {result} = await renderRejectedExpenseHook(); + + // Then there is nothing for the Rejected suggestion to show + expect(result.current).toBe(false); + }); + + it('returns true when an expense on a report the user owns was rejected', async () => { + // Given an approver rejected one expense on the current user's report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}report_1`, createExpenseReport('report_1', CURRENT_USER_ACCOUNT_ID)); + await seedRejectedExpense('trans_1', 'report_1'); + await waitForBatchedUpdates(); + + // When the hook runs + const {result} = await renderRejectedExpenseHook(); + + // Then the submitter sees the Rejected suggestion + expect(result.current).toBe(true); + }); + + it('returns true when a rejected expense was moved out of its report', async () => { + // Given an expense rejected from an instantly submitted report, which leaves it unreported + await seedRejectedExpense('trans_1', CONST.REPORT.UNREPORTED_REPORT_ID); + await waitForBatchedUpdates(); + + // When the hook runs + const {result} = await renderRejectedExpenseHook(); + + // Then the expense still counts, because unreported expenses are only synced to their owner + expect(result.current).toBe(true); + }); + + it('returns true when a whole report the user owns was rejected', async () => { + // Given an approver rejected the current user's whole report back to them + await Onyx.set( + `${ONYXKEYS.COLLECTION.REPORT}report_1`, + createExpenseReport('report_1', CURRENT_USER_ACCOUNT_ID, {nextStep: {icon: CONST.NEXT_STEP.ICONS.HOURGLASS, messageKey: CONST.NEXT_STEP.MESSAGE_KEY.REJECTED_REPORT}}), + ); + await waitForBatchedUpdates(); + + // When the hook runs + const {result} = await renderRejectedExpenseHook(); + + // Then the submitter sees the Rejected suggestion + expect(result.current).toBe(true); + }); + + it('returns false for an approver who rejected another user expense or report', async () => { + // Given the current user is the approver who rejected another user's expense and whole report + await Onyx.set(ONYXKEYS.SESSION, {email: OTHER_USER_EMAIL, accountID: OTHER_USER_ACCOUNT_ID}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}report_1`, createExpenseReport('report_1', CURRENT_USER_ACCOUNT_ID)); + await Onyx.set( + `${ONYXKEYS.COLLECTION.REPORT}report_2`, + createExpenseReport('report_2', CURRENT_USER_ACCOUNT_ID, {nextStep: {icon: CONST.NEXT_STEP.ICONS.HOURGLASS, messageKey: CONST.NEXT_STEP.MESSAGE_KEY.REJECTED_REPORT}}), + ); + await seedRejectedExpense('trans_1', 'report_1'); + await waitForBatchedUpdates(); + + // When the hook runs + const {result} = await renderRejectedExpenseHook(); + + // Then none of the approver's own expenses were rejected, so the suggestion stays hidden + expect(result.current).toBe(false); + }); + + it('returns false for an admin who did not reject the expense but can see its violation', async () => { + // Given a second admin keeps the rejected-expense violation of another user's expense + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@mail.com', accountID: 3}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}report_1`, createExpenseReport('report_1', CURRENT_USER_ACCOUNT_ID)); + await seedRejectedExpense('trans_1', 'report_1'); + await waitForBatchedUpdates(); + + // When the hook runs + const {result} = await renderRejectedExpenseHook(); + + // Then the admin doesn't own the report, so the suggestion stays hidden + expect(result.current).toBe(false); + }); + + it('returns false for an approver whose rejection left the expense unreported', async () => { + // Given the approver's local copy of an expense they rejected from an instantly submitted report + await Onyx.set(ONYXKEYS.SESSION, {email: OTHER_USER_EMAIL, accountID: OTHER_USER_ACCOUNT_ID}); + await seedRejectedExpense('trans_1', CONST.REPORT.UNREPORTED_REPORT_ID, OTHER_USER_EMAIL); + await waitForBatchedUpdates(); + + // When the hook runs + const {result} = await renderRejectedExpenseHook(); + + // Then the approver's own rejection doesn't count + expect(result.current).toBe(false); + }); +});