From 1782153e021bb4f3b24c93f03d7d8a50129c723c Mon Sep 17 00:00:00 2001 From: "Krishna (via MelvinBot)" Date: Tue, 20 Oct 2026 04:02:48 +0000 Subject: [PATCH 1/3] Add a Rejected suggested search that appears once the user has a rejected expense Co-authored-by: Krishna --- src/CONST/index.ts | 1 + src/ONYXKEYS.ts | 4 ++ src/hooks/useHasRejectedExpense.ts | 29 ++++++++++++++ src/hooks/useSearchTypeMenuSections.ts | 19 ++++++++++ src/languages/de.ts | 5 +++ src/languages/el.ts | 5 +++ src/languages/en.ts | 5 +++ src/languages/es.ts | 5 +++ src/languages/fr.ts | 5 +++ src/languages/it.ts | 5 +++ src/languages/ja.ts | 5 +++ src/languages/nl.ts | 5 +++ src/languages/pl.ts | 5 +++ src/languages/pt-BR.ts | 5 +++ src/languages/zh-hans.ts | 5 +++ src/libs/ExportOnyxState/masking.ts | 1 + src/libs/SearchSuggestionUtils.ts | 23 ++++++++++++ src/libs/SearchUIUtils.ts | 26 ++++++++++++- src/libs/actions/Search.ts | 7 ++++ tests/unit/Search/SearchUIUtilsTest.ts | 48 ++++++++++++++++++++++++ tests/unit/SearchRouterNavigationTest.ts | 1 + 21 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useHasRejectedExpense.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a295f031849a..e94441770cc4 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8588,6 +8588,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 3e1c1496626d..496380bfab13 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -267,6 +267,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', @@ -1866,6 +1869,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..c52e8fa544f3 --- /dev/null +++ b/src/hooks/useHasRejectedExpense.ts @@ -0,0 +1,29 @@ +import {hasReportBeenRejectedToSubmitter} from '@libs/ReportUtils'; +import {hasTransactionBeenRejected} from '@libs/TransactionUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, TransactionViolations} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; +// 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'; + +const hasRejectedTransactionSelector = (allTransactionViolations: OnyxCollection): boolean => + Object.values(allTransactionViolations ?? {}).some((transactionViolations) => hasTransactionBeenRejected(transactionViolations)); + +// A whole-report rejection doesn't add a violation to the report's transactions, so it has to be checked on the report. +const hasRejectedReportSelector = (allReports: OnyxCollection): boolean => Object.values(allReports ?? {}).some((report) => hasReportBeenRejectedToSubmitter(report)); + +/** + * Whether the user currently has at least one rejected expense, either rejected on its own or as part of a rejected report. + * The selectors return a boolean so consumers only re-render when the answer changes. + */ +function useHasRejectedExpense(): boolean { + const [hasRejectedTransaction = false] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {selector: hasRejectedTransactionSelector}); + const [hasRejectedReport = false] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: hasRejectedReportSelector}); + + return hasRejectedTransaction || hasRejectedReport; +} + +export default useHasRejectedExpense; diff --git a/src/hooks/useSearchTypeMenuSections.ts b/src/hooks/useSearchTypeMenuSections.ts index 40d372f51e96..d666b1b08c0c 100644 --- a/src/hooks/useSearchTypeMenuSections.ts +++ b/src/hooks/useSearchTypeMenuSections.ts @@ -1,3 +1,4 @@ +import {setHasSeenRejectedSuggestedSearch} from '@libs/actions/Search'; import {createTypeMenuSections, omitInsightsPageMenuItems} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -13,6 +14,7 @@ import {useCallback, useEffect, useMemo, 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'; @@ -76,6 +78,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, ); @@ -122,6 +139,7 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { isTrackIntentUser: isTrackIntentUser ?? false, hasReportAwaitingApproval, policyCategories: allPolicyCategories, + shouldShowRejectedSuggestion, }), [ currentUserLoginAndAccountID?.email, @@ -137,6 +155,7 @@ const useSearchTypeMenuSections = (isScreenFocused = true) => { isTrackIntentUser, hasReportAwaitingApproval, allPolicyCategories, + shouldShowRejectedSuggestion, ], ); diff --git a/src/languages/de.ts b/src/languages/de.ts index aff85e69838e..7cabe5d09a99 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -10168,6 +10168,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.', @@ -10419,6 +10423,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 cda27383978a..0091b9416089 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10316,6 +10316,7 @@ ${reportName}`, submit: 'Πρόχειρα', approve: 'Χρειάζεται έγκριση', pay: 'Έτοιμο για πληρωμή', + rejected: 'Απορρίφθηκε', accounting: 'Λογιστική', export: 'Αναμονή για εξαγωγή', unapprovedCash: 'Δεδουλευμένα μετρητών', @@ -10373,6 +10374,10 @@ ${reportName}`, title: 'Δεν υπάρχουν έξοδα προς πληρωμή', subtitle: 'Συγχαρητήρια! Περάσατε τη γραμμή τερματισμού.', }, + emptyRejectedResults: { + title: 'Δεν υπάρχουν απορριφθέντα έξοδα', + subtitle: 'Τίποτα για διόρθωση. Μπράβο!', + }, emptyExportResults: { title: 'Δεν υπάρχουν έξοδα για εξαγωγή', subtitle: 'Ώρα να χαλαρώσετε, μπράβο σας.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 237106085e90..981a44615a40 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10332,6 +10332,7 @@ const translations = { submit: 'Drafts', approve: 'Needs approval', pay: 'Ready to pay', + rejected: 'Rejected', accounting: 'Accounting', export: 'Awaiting export', unapprovedCash: 'Cash accruals', @@ -10389,6 +10390,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 d7ad5a7a7bd2..886e5ebaf586 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9951,6 +9951,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', @@ -10004,6 +10005,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 a089625e7b51..b09157f4d72e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10198,6 +10198,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.', @@ -10449,6 +10453,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 6eb2a2e9909e..4b1aa31fd4f4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10119,6 +10119,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.', @@ -10370,6 +10374,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 58a13bfcc91e..e7bfac7aaf82 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -9976,6 +9976,10 @@ ${reportName}`, title: '支払う経費はありません', subtitle: 'おめでとうございます!ゴールに到達しました。', }, + emptyRejectedResults: { + title: '却下された経費はありません', + subtitle: '修正が必要なものはありません。お疲れさまでした!', + }, emptyExportResults: { title: 'エクスポートする経費はありません', subtitle: 'ひと休みする時間です、お疲れさまでした。', @@ -10217,6 +10221,7 @@ ${reportName}`, submit: '下書き', approve: '承認が必要', pay: '支払いの準備完了', + rejected: '却下済み', accounting: '会計', export: 'エクスポート待ち', unapprovedCash: '現金発生主義', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 55d382cb1f87..7c2a8a018291 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10086,6 +10086,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.', @@ -10337,6 +10341,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 17a89282fbea..1e78be91bb0c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -10096,6 +10096,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.', @@ -10353,6 +10357,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 274d62903144..775f3ee5146c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10083,6 +10083,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.', @@ -10334,6 +10338,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 79dafb4321f5..ea3662ec68fc 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9709,6 +9709,10 @@ ${reportName}`, title: '暂无可支付的报销', subtitle: '恭喜!你已冲过终点线。', }, + emptyRejectedResults: { + title: '暂无被拒绝的报销', + subtitle: '没有需要修正的内容。干得好!', + }, emptyExportResults: { title: '没有可导出的报销费用', subtitle: '该放松一下了,干得不错。', @@ -9948,6 +9952,7 @@ ${reportName}`, submit: '草稿', approve: '待审批', pay: '准备付款', + rejected: '已拒绝', accounting: '会计', export: '等待导出', unapprovedCash: '现金应计', diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 19414a189282..051bc30494d1 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -291,6 +291,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED, 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 bc1e8229e471..ffd3cd8b11b2 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', ] as const satisfies readonly ExpensifyIconName[]; @@ -258,6 +259,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 574c73b8a824..04164d4e4df3 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -705,6 +705,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, @@ -714,6 +715,7 @@ function getSuggestedSearchesVisibility( hasReportAwaitingApproval = false, isTrackIntentUser = false, policyCategories?: OnyxCollection, + shouldShowRejectedSuggestion = false, ): {visibility: Record, boolean>; hasEligibleGroupPolicies: boolean; shouldShowExpensifyCard: boolean} { let shouldShowSubmitSuggestion = false; let shouldShowPaySuggestion = false; @@ -808,6 +810,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, @@ -4786,6 +4789,7 @@ type TypeMenuSectionsParams = { isTrackIntentUser: boolean; hasReportAwaitingApproval?: boolean; policyCategories?: OnyxCollection; + shouldShowRejectedSuggestion?: boolean; }; function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuSection[] { @@ -4803,6 +4807,7 @@ function createTypeMenuSections(params: TypeMenuSectionsParams): SearchTypeMenuS isTrackIntentUser, hasReportAwaitingApproval = false, policyCategories, + shouldShowRejectedSuggestion = false, } = params; const typeMenuSections: SearchTypeMenuSection[] = []; @@ -4810,7 +4815,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; @@ -4872,6 +4886,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 3573a2d02e64..79e89d8ca9c8 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -123,6 +123,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']; @@ -985,6 +986,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> = [ { @@ -2702,6 +2708,7 @@ function setOptimisticDataForTransactionThreadPreview( export { saveSearch, seedMyExpensesSearch, + setHasSeenRejectedSuggestedSearch, search, getFooterConvertedAmounts, clearFooterConversion, diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index b8d2f954557c..e9d39689f5ef 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -9346,6 +9346,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, @@ -10908,6 +10941,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 show Top Categories when areCategoriesEnabled is true', () => { const policyKey = `policy_${policyID}`; diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 8f2ffc545e48..5ca8d07d612a 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, }; From d0aedce9b9df88d76781f0b72d63f70ba94f9df3 Mon Sep 17 00:00:00 2001 From: "Krishna (via MelvinBot)" Date: Tue, 20 Oct 2026 04:12:04 +0000 Subject: [PATCH 2/3] Fix: Oxfmt import grouping in useHasRejectedExpense Co-authored-by: Krishna --- src/hooks/useHasRejectedExpense.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useHasRejectedExpense.ts b/src/hooks/useHasRejectedExpense.ts index c52e8fa544f3..95665758f603 100644 --- a/src/hooks/useHasRejectedExpense.ts +++ b/src/hooks/useHasRejectedExpense.ts @@ -5,6 +5,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Report, TransactionViolations} from '@src/types/onyx'; import type {OnyxCollection} from 'react-native-onyx'; + // 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'; From 97af161ca9346b2edb9ada6aad51aedd444a5f15 Mon Sep 17 00:00:00 2001 From: "Krishna (via MelvinBot)" Date: Tue, 27 Oct 2026 08:43:53 +0000 Subject: [PATCH 3/3] Only show the Rejected suggestion for the current user's own rejected expenses Co-authored-by: Krishna --- src/hooks/useHasRejectedExpense.ts | 75 ++++++++-- tests/unit/useHasRejectedExpenseTest.tsx | 167 +++++++++++++++++++++++ 2 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 tests/unit/useHasRejectedExpenseTest.tsx diff --git a/src/hooks/useHasRejectedExpense.ts b/src/hooks/useHasRejectedExpense.ts index 95665758f603..bfaafc0b55c8 100644 --- a/src/hooks/useHasRejectedExpense.ts +++ b/src/hooks/useHasRejectedExpense.ts @@ -1,30 +1,83 @@ -import {hasReportBeenRejectedToSubmitter} from '@libs/ReportUtils'; -import {hasTransactionBeenRejected} from '@libs/TransactionUtils'; +import {hasReportBeenRejectedToSubmitter, isCurrentUserSubmitter} from '@libs/ReportUtils'; +import {isExpenseUnreported} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, TransactionViolations} from '@src/types/onyx'; +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'; -const hasRejectedTransactionSelector = (allTransactionViolations: OnyxCollection): boolean => - Object.values(allTransactionViolations ?? {}).some((transactionViolations) => hasTransactionBeenRejected(transactionViolations)); +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 hasRejectedReportSelector = (allReports: OnyxCollection): boolean => Object.values(allReports ?? {}).some((report) => hasReportBeenRejectedToSubmitter(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 user currently has at least one rejected expense, either rejected on its own or as part of a rejected report. - * The selectors return a boolean so consumers only re-render when the answer changes. + * 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 [hasRejectedTransaction = false] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {selector: hasRejectedTransactionSelector}); - const [hasRejectedReport = false] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: hasRejectedReportSelector}); + 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 hasRejectedTransaction || hasRejectedReport; + return !!rejectedTransactionsLocation?.hasUnreportedRejectedTransaction || hasOwnRejectedReport; } export default useHasRejectedExpense; 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); + }); +});