From bd957244c0639ba170e262b1c172c3bd8a946da3 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 21 Aug 2026 14:30:55 +0530 Subject: [PATCH 01/51] Surface "Create a rule" prompt on expense edits Signed-off-by: krishna2323 --- src/CONST/index.ts | 10 +++ src/ONYXKEYS.ts | 4 + src/ROUTES.ts | 3 +- .../MerchantRuleSuggestionBanner.tsx | 80 +++++++++++++++++++ .../ReportActionItem/MoneyRequestView.tsx | 11 ++- src/hooks/useMerchantRuleSuggestion.ts | 66 +++++++++++++++ src/languages/en.ts | 2 + src/libs/MerchantRuleSuggestionUtils.ts | 76 ++++++++++++++++++ src/libs/Navigation/types.ts | 1 + src/libs/actions/IOU/UpdateMoneyRequest.ts | 7 ++ src/libs/actions/MerchantRuleSuggestion.ts | 31 +++++++ src/pages/inbox/report/ReportFooter.tsx | 9 +++ .../MerchantRules/AddMerchantRulePage.tsx | 1 + .../MerchantRules/MerchantRulePageBase.tsx | 10 ++- src/setup/index.ts | 1 + src/types/onyx/MerchantRuleSuggestion.ts | 29 +++++++ src/types/onyx/index.ts | 2 + 17 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 src/components/MerchantRuleSuggestionBanner.tsx create mode 100644 src/hooks/useMerchantRuleSuggestion.ts create mode 100644 src/libs/MerchantRuleSuggestionUtils.ts create mode 100644 src/libs/actions/MerchantRuleSuggestion.ts create mode 100644 src/types/onyx/MerchantRuleSuggestion.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index fb22ac621efa..559c98b106a7 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -484,6 +484,16 @@ const CONST = { MERCHANT_NAME_MAX_BYTES: 255, + /** Expense fields a merchant rule can govern, and whose edit surfaces the "Create a rule" callout on the expense */ + MERCHANT_RULE_SUGGESTION_FIELDS: { + CATEGORY: 'category', + TAG: 'tag', + TAX: 'tax', + DESCRIPTION: 'description', + BILLABLE: 'billable', + REIMBURSABLE: 'reimbursable', + }, + MASKED_PAN_PREFIX: 'XXXXXXXXXXXX', REQUEST_PREVIEW: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 08c248fd7e6a..3e74cec7dc07 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -898,6 +898,9 @@ const ONYXKEYS = { /** Session-scoped loading flags for company cards page and feeds. * Registered as RAM-only in `setup/index.ts`. */ RAM_ONLY_COMPANY_CARDS_LOADING_STATE: 'companyCardsLoadingState_', + /** Session-scoped record of a merchant-rule-governed field just edited on an expense, keyed by transactionID. + * Drives the "Create a rule" callout. Registered as RAM-only in `setup/index.ts`. */ + RAM_ONLY_MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion_', /** Pagination cursors for a report's action list. */ REPORT_PAGINATION_STATE: 'reportPaginationState_', REPORT_ACTIONS: 'reportActions_', @@ -1476,6 +1479,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; [ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE]: OnyxTypes.ReportLoadingState; [ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE]: OnyxTypes.CompanyCardsLoadingState; + [ONYXKEYS.COLLECTION.RAM_ONLY_MERCHANT_RULE_SUGGESTION]: OnyxTypes.MerchantRuleSuggestion; [ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE]: OnyxTypes.ReportPaginationState; [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; [ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: OnyxTypes.ReportActionsDrafts; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 40f4a9c07fde..ca93c35ce36e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3796,7 +3796,8 @@ const ROUTES = { }, RULES_MERCHANT_NEW: { route: 'workspaces/:policyID/rules/merchant-rules/new', - getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/merchant-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, + getRoute: (policyID: string, categoryName?: string, backTo?: string) => + getUrlWithBackToParam(`workspaces/${policyID}/rules/merchant-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, backTo), }, RULES_MERCHANT_IMPORT: { route: 'workspaces/:policyID/rules/merchant-rules/import', diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx new file mode 100644 index 000000000000..1c1bbedfc17c --- /dev/null +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -0,0 +1,80 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {dismissMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; +import {setDraftMerchantRule} from '@libs/actions/User'; +import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import ROUTES from '@src/ROUTES'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; + +import Banner from './Banner'; +import Text from './Text'; +import TextLink from './TextLink'; + +type MerchantRuleSuggestionBannerProps = { + /** The report hosting the expense detail view: a transaction thread or its parent expense report */ + reportID: string | undefined; + + /** The workspace the expense belongs to */ + policyID: string | undefined; + + /** Styles for the banner container */ + containerStyles?: StyleProp; +}; + +/** + * Offers a workspace admin the chance to turn an expense edit into a merchant rule, right on the expense they just + * edited. Renders nothing unless there is a qualifying edit to act on. + */ +function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles}: MerchantRuleSuggestionBannerProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); + const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); + + if (!suggestion || !transaction || !policyID) { + return null; + } + + const createRule = () => { + const draft = getMerchantRuleDraftFromTransaction(transaction, suggestion.field, policy); + if (!draft) { + return; + } + // Seed the draft the rule editor reads, then open the same flow used from workspace settings + setDraftMerchantRule(draft); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID, undefined, Navigation.getActiveRoute())); + }; + + return ( + dismissMerchantRuleSuggestion(suggestion.transactionID)} + content={ + + + {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} + + {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} + + } + /> + ); +} + +MerchantRuleSuggestionBanner.displayName = 'MerchantRuleSuggestionBanner'; + +export default MerchantRuleSuggestionBanner; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index ca5415e11225..c7a912c26753 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -3,6 +3,7 @@ import HighlightableMenuItemWithTopDescription from '@components/HighlightableMe import Icon from '@components/Icon'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails, usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider'; @@ -1156,7 +1157,7 @@ function MoneyRequestView({ // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); + const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const {wideRHPRouteKeys} = useWideRHPState(); const route = useRoute(); // The receipt is hidden only for the MoneyRequestView rendered inside a wide RHP, where the wide RHP's left @@ -1173,6 +1174,14 @@ function MoneyRequestView({ {shouldShowAnimatedBackground && } <> + {/* On wide layouts this callout renders above the composer in the report footer instead, per design */} + {shouldUseNarrowLayout && !readonly && !isFromReviewDuplicates && !isFromMergeTransaction && ( + + )} {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( { + if (!canCreateMerchantRule) { + return undefined; + } + return Object.values(suggestions ?? {}).find((entry) => !!entry && !entry.isDismissed && entry.reportIDs.includes(reportID)); + }, [canCreateMerchantRule, reportID, suggestions]); + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(suggestion?.transactionID)}`); + + // A merchant rule matches on merchant, so an expense without one (e.g. a receipt still scanning) can't seed a rule + if (!suggestion || !transaction || isMerchantMissing(transaction)) { + return {suggestion: undefined, transaction: undefined, policy: undefined}; + } + + return {suggestion, transaction, policy}; +} + +export default useMerchantRuleSuggestion; diff --git a/src/languages/en.ts b/src/languages/en.ts index 261d9a2d40bc..ff97ca80783d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8003,6 +8003,8 @@ const translations = { subtitle: 'Set merchant rules so expenses arrive correctly coded and require less cleanup.', addRule: 'Add merchant rule', findRule: 'Find merchant rule', + createRuleFromExpenseAction: 'Create a rule', + createRuleFromExpensePrompt: 'to apply your changes to all expenses that match your criteria.', addRuleTitle: 'Add rule', editRuleTitle: 'Edit rule', importRulesTitle: 'Import merchant rules', diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts new file mode 100644 index 000000000000..8d8430b12c02 --- /dev/null +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -0,0 +1,76 @@ +import Parser from '@libs/Parser'; +import {resolveCurrentTaxCode} from '@libs/PolicyUtils'; +import {getCategory, getDescription, getMerchant, getTag, isMerchantMissing} from '@libs/TransactionUtils'; + +import CONST from '@src/CONST'; +import type {MerchantRuleForm} from '@src/types/form'; +import type {Policy, Transaction} from '@src/types/onyx'; +import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; + +import type {OnyxEntry} from 'react-native-onyx'; + +/** + * Builds the merchant-rule draft that pre-seeds the rule creation flow when an admin turns an expense edit into a + * rule. Returns undefined when the expense has no merchant to match on, since a rule cannot be saved without one. + */ +function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry, field: MerchantRuleSuggestionField, policy: OnyxEntry): Partial | undefined { + if (!transaction || isMerchantMissing(transaction)) { + return undefined; + } + + const draft: Partial = {merchantToMatch: getMerchant(transaction)}; + + switch (field) { + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY: { + const category = getCategory(transaction); + if (category) { + draft.category = category; + } + break; + } + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG: { + // Multi-level tags stay in the transaction's colon-joined form, which is what the rule form expects too + const tag = getTag(transaction); + if (tag) { + draft.tag = tag; + } + break; + } + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX: { + // The rule form stores the key of the tax in `policy.taxRates.taxes`, which is the same value a + // transaction stores as its taxCode — but the transaction can carry a code that has since been renamed. + const taxCode = transaction.taxCode ? resolveCurrentTaxCode(policy, transaction.taxCode) : undefined; + if (taxCode && policy?.taxRates?.taxes?.[taxCode]) { + draft.tax = taxCode; + } + break; + } + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION: { + // An expense description is stored as HTML, while the rule form edits markdown + const description = getDescription(transaction); + if (description) { + draft.comment = Parser.htmlToMarkdown(description); + } + break; + } + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE: + if (transaction.billable !== undefined) { + draft.billable = transaction.billable; + } + break; + case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE: + if (transaction.reimbursable !== undefined) { + draft.reimbursable = transaction.reimbursable; + } + break; + default: + break; + } + + return draft; +} + +export { + // eslint-disable-next-line import/prefer-default-export -- keeps the file consistent as more merchant-rule suggestion helpers are added + getMerchantRuleDraftFromTransaction, +}; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 128d1b1d2b04..b0fa2a4b4a1e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1634,6 +1634,7 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { policyID: string; categoryName?: string; + backTo?: Routes; }; [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { policyID: string; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 792e0b588733..75c39d0bbb88 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -40,6 +40,7 @@ import { } from '@libs/TransactionUtils'; import ViolationsUtils, {syncCustomUnitRateOutOfDateRangeViolation} from '@libs/Violations/ViolationsUtils'; +import {trackMerchantRuleSuggestion} from '@userActions/MerchantRuleSuggestion'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {stringifyWaypointsForAPI} from '@userActions/Transaction'; @@ -405,6 +406,7 @@ function updateMoneyRequestBillable({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, [transactionThreadReport.reportID, parentReport?.reportID]); } function updateMoneyRequestReimbursable({ @@ -470,6 +472,7 @@ function updateMoneyRequestReimbursable({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, [transactionThreadReport.reportID, parentReport?.reportID]); } /** Updates the merchant field of an expense */ @@ -866,6 +869,7 @@ function updateMoneyRequestTag({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, [transactionThreadReport?.reportID, parentReport?.reportID]); } /** Updates the created tax amount of an expense */ @@ -995,6 +999,7 @@ function updateMoneyRequestTaxRate({ }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, [transactionThreadReport?.reportID, parentReport?.reportID]); } type UpdateMoneyRequestDistanceParams = { @@ -1229,6 +1234,7 @@ function updateMoneyRequestCategory({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, [transactionThreadReport?.reportID, parentReport?.reportID]); } /** Updates the description of an expense */ @@ -1301,6 +1307,7 @@ function updateMoneyRequestDescription({ const {params, onyxData} = data; params.description = parsedComment; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, [transactionThreadReport?.reportID, parentReport?.reportID]); } /** Updates the distance rate of an expense */ diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts new file mode 100644 index 000000000000..2395498895e9 --- /dev/null +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -0,0 +1,31 @@ +import ONYXKEYS from '@src/ONYXKEYS'; +import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; + +import Onyx from 'react-native-onyx'; + +/** + * Records that a merchant-rule-governed field was edited on an expense, so the expense detail view can offer to + * turn the edit into a merchant rule. The record is RAM-only, so it never outlives the session, and it is written + * for every user: whether the callout actually renders is decided by `useMerchantRuleSuggestion`, which requires + * workspace admin rights. + */ +function trackMerchantRuleSuggestion(transactionID: string | undefined, field: MerchantRuleSuggestionField, reportIDs: Array) { + if (!transactionID) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_MERCHANT_RULE_SUGGESTION}${transactionID}`, { + transactionID, + reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), + field, + // A fresh edit re-opens the callout even if an earlier edit on this expense was dismissed + isDismissed: false, + }); +} + +/** Dismisses the "Create a rule" callout for a single expense. */ +function dismissMerchantRuleSuggestion(transactionID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_MERCHANT_RULE_SUGGESTION}${transactionID}`, {isDismissed: true}); +} + +export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion}; diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 7d230e1f9128..cea7b76e16a1 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -2,6 +2,7 @@ import AnonymousReportFooter from '@components/AnonymousReportFooter'; import ArchivedReportFooter from '@components/ArchivedReportFooter'; import Banner from '@components/Banner'; import BlockedReportFooter from '@components/BlockedReportFooter'; +import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import OfflineIndicator from '@components/OfflineIndicator'; import SwipeableView from '@components/SwipeableView'; @@ -108,6 +109,14 @@ function ReportFooter() { ); return ( + {/* On narrow layouts the expense detail view renders this callout under its own header instead, per design */} + {!shouldUseNarrowLayout && !isComposerFullSize && ( + + )} {shouldShowEnableNotificationsBanner ? ( <> diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 74693dcbfb3c..4aadd8247b37 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -14,6 +14,7 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 840ddcf1eaea..be7f1fa90ebe 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -43,6 +43,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {MerchantRuleForm} from '@src/types/form'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; @@ -62,6 +63,8 @@ type MerchantRulePageBaseProps = { ruleID?: string; /** Pre-scopes the category default when creating a rule (e.g. from the category details RHP). */ initialCategoryName?: string; + /** Where to return after saving. Set when the rule is created outside workspace settings, e.g. from an expense. */ + backTo?: Route; titleKey: TranslationPaths; testID: string; }; @@ -111,7 +114,7 @@ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) return translate('workspace.rules.merchantRules.confirmError'); }; -function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, testID}: MerchantRulePageBaseProps) { +function MerchantRulePageBase({policyID, ruleID, initialCategoryName, backTo, titleKey, testID}: MerchantRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policy = usePolicy(policyID); @@ -307,7 +310,10 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, return; } setPolicyCodingRule(policyID, form, policy, ruleID, shouldUpdateMatchingTransactions); - if (!isEditing && isRulesRevampEnabled) { + if (backTo) { + // The rule was created from outside workspace settings (e.g. an expense), so return there instead of the Rules page + Navigation.goBack(backTo); + } else if (!isEditing && isRulesRevampEnabled) { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.EXPENSE_DEFAULTS); Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); } else { diff --git a/src/setup/index.ts b/src/setup/index.ts index 2d0f25d0f3aa..02e1326a9228 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -77,6 +77,7 @@ export default function () { ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE, + ONYXKEYS.COLLECTION.RAM_ONLY_MERCHANT_RULE_SUGGESTION, ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ONYXKEYS.RAM_ONLY_MERGE_HR_LINK_TOKEN, ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD, diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts new file mode 100644 index 000000000000..b08a1345ab9c --- /dev/null +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -0,0 +1,29 @@ +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +/** Expense field a merchant rule can govern */ +type MerchantRuleSuggestionField = ValueOf; + +/** + * Session-scoped record of a merchant-rule-governed field the user just edited on an expense. + * + * Drives the "Create a rule" callout on the expense detail view. Kept RAM-only so the callout can + * surface again on the same expense in a future session, per the product spec. + */ +type MerchantRuleSuggestion = { + /** The expense that was edited */ + transactionID: string; + + /** Reports whose expense detail view can surface the callout: the transaction thread and its parent expense report */ + reportIDs: string[]; + + /** The most recently edited field, used to pre-seed the rule */ + field: MerchantRuleSuggestionField; + + /** Whether the user dismissed the callout for this expense */ + isDismissed?: boolean; +}; + +export default MerchantRuleSuggestion; +export type {MerchantRuleSuggestionField}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index b7f60dc85dcb..46bb1bc40f0a 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -106,6 +106,7 @@ import type Login from './Login'; import type {Login as NewLogin} from './Logins'; import type Logins from './Logins'; import type MapboxAccessToken from './MapboxAccessToken'; +import type MerchantRuleSuggestion from './MerchantRuleSuggestion'; import type MergeTransaction from './MergeTransaction'; import type Modal from './Modal'; import type Network from './Network'; @@ -341,6 +342,7 @@ export type { TaxRates, TaxRatesWithDefault, Transaction, + MerchantRuleSuggestion, MergeTransaction, TransactionViolation, TransactionViolations, From 3e1ea38de3b58857ffba5b3858ba6f71391131c0 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 21 Aug 2026 14:56:26 +0530 Subject: [PATCH 02/51] Render the merchant rule prompt as a product training tooltip Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 + src/ONYXKEYS.ts | 9 ++- .../MerchantRuleSuggestionBanner.tsx | 80 ------------------- .../MerchantRuleSuggestionTooltip.tsx | 71 ++++++++++++++++ .../ProductTrainingContext/TOOLTIPS.ts | 12 ++- .../ReportActionItem/MoneyRequestView.tsx | 33 ++++---- src/hooks/useMerchantRuleSuggestion.ts | 29 +++---- src/languages/en.ts | 3 +- src/libs/MerchantRuleSuggestionUtils.ts | 8 +- src/libs/actions/MerchantRuleSuggestion.ts | 24 +++--- src/pages/inbox/report/ReportFooter.tsx | 26 +++--- src/setup/index.ts | 2 +- src/types/onyx/DismissedProductTraining.ts | 7 ++ src/types/onyx/MerchantRuleSuggestion.ts | 10 +-- 14 files changed, 164 insertions(+), 151 deletions(-) delete mode 100644 src/components/MerchantRuleSuggestionBanner.tsx create mode 100644 src/components/MerchantRuleSuggestionTooltip.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 559c98b106a7..f8f5552112b6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8394,6 +8394,7 @@ const CONST = { MILEAGE_RATE_AUTO_UPDATED: 'mileageRateAutoUpdated', REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleReceiptCouplingTooltip', REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleItemizedReceiptCouplingTooltip', + MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion', }, CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal', AGENTS_RULES_BANNER: 'agentsRulesBanner', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 3e74cec7dc07..30a05a0494e1 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -668,6 +668,10 @@ const ONYXKEYS = { /** Session-scoped flag: user dismissed the "enable notifications" banner in the Concierge chat */ RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER: 'hasDismissedConciergeNotificationBanner', + /** Session-scoped record of the merchant-rule-governed field most recently edited on an expense. + * Drives the "Create a rule" product training tooltip. Registered as RAM-only in `setup/index.ts`. */ + RAM_ONLY_MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion', + NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails', /** Stores the information about duplicated workspace */ @@ -898,9 +902,6 @@ const ONYXKEYS = { /** Session-scoped loading flags for company cards page and feeds. * Registered as RAM-only in `setup/index.ts`. */ RAM_ONLY_COMPANY_CARDS_LOADING_STATE: 'companyCardsLoadingState_', - /** Session-scoped record of a merchant-rule-governed field just edited on an expense, keyed by transactionID. - * Drives the "Create a rule" callout. Registered as RAM-only in `setup/index.ts`. */ - RAM_ONLY_MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion_', /** Pagination cursors for a report's action list. */ REPORT_PAGINATION_STATE: 'reportPaginationState_', REPORT_ACTIONS: 'reportActions_', @@ -1479,7 +1480,6 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata; [ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE]: OnyxTypes.ReportLoadingState; [ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE]: OnyxTypes.CompanyCardsLoadingState; - [ONYXKEYS.COLLECTION.RAM_ONLY_MERCHANT_RULE_SUGGESTION]: OnyxTypes.MerchantRuleSuggestion; [ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE]: OnyxTypes.ReportPaginationState; [ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportActions; [ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: OnyxTypes.ReportActionsDrafts; @@ -1749,6 +1749,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ASSIGN_CARD]: OnyxTypes.AssignCard; [ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE]: boolean; [ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER]: boolean; + [ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION]: OnyxTypes.MerchantRuleSuggestion; [ONYXKEYS.DUPLICATE_WORKSPACE]: OnyxTypes.DuplicateWorkspace; [ONYXKEYS.COPY_POLICY_SETTINGS]: OnyxTypes.CopyPolicySettings; [ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL]: string; diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx deleted file mode 100644 index 1c1bbedfc17c..000000000000 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {dismissMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; -import {setDraftMerchantRule} from '@libs/actions/User'; -import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; -import Navigation from '@libs/Navigation/Navigation'; - -import ROUTES from '@src/ROUTES'; - -import type {StyleProp, ViewStyle} from 'react-native'; - -import React from 'react'; - -import Banner from './Banner'; -import Text from './Text'; -import TextLink from './TextLink'; - -type MerchantRuleSuggestionBannerProps = { - /** The report hosting the expense detail view: a transaction thread or its parent expense report */ - reportID: string | undefined; - - /** The workspace the expense belongs to */ - policyID: string | undefined; - - /** Styles for the banner container */ - containerStyles?: StyleProp; -}; - -/** - * Offers a workspace admin the chance to turn an expense edit into a merchant rule, right on the expense they just - * edited. Renders nothing unless there is a qualifying edit to act on. - */ -function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles}: MerchantRuleSuggestionBannerProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); - const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); - - if (!suggestion || !transaction || !policyID) { - return null; - } - - const createRule = () => { - const draft = getMerchantRuleDraftFromTransaction(transaction, suggestion.field, policy); - if (!draft) { - return; - } - // Seed the draft the rule editor reads, then open the same flow used from workspace settings - setDraftMerchantRule(draft); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID, undefined, Navigation.getActiveRoute())); - }; - - return ( - dismissMerchantRuleSuggestion(suggestion.transactionID)} - content={ - - - {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} - - {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} - - } - /> - ); -} - -MerchantRuleSuggestionBanner.displayName = 'MerchantRuleSuggestionBanner'; - -export default MerchantRuleSuggestionBanner; diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx new file mode 100644 index 000000000000..758bbc3421e5 --- /dev/null +++ b/src/components/MerchantRuleSuggestionTooltip.tsx @@ -0,0 +1,71 @@ +import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {setDraftMerchantRule} from '@libs/actions/User'; +import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import variables from '@styles/variables'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import React from 'react'; + +import {useProductTrainingContext} from './ProductTrainingContext'; +import EducationalTooltip from './Tooltip/EducationalTooltip'; + +type MerchantRuleSuggestionTooltipProps = React.PropsWithChildren<{ + /** The report hosting the expense detail view: a transaction thread or its parent expense report */ + reportID: string | undefined; + + /** The workspace the expense belongs to */ + policyID: string | undefined; +}>; + +/** + * Anchors the "Create a rule" product training tooltip to the wrapped element, offering a workspace admin the chance + * to turn the expense edit they just made into a merchant rule. Renders the children untouched when there is no + * qualifying edit to act on. + */ +function MerchantRuleSuggestionTooltip({reportID, policyID, children}: MerchantRuleSuggestionTooltipProps) { + const styles = useThemeStyles(); + const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); + const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION, + !!suggestion, + ); + + const createRule = () => { + if (!suggestion || !policyID) { + return; + } + const draft = getMerchantRuleDraftFromTransaction(transaction, suggestion.field, policy); + if (!draft) { + return; + } + // Seed the draft the rule editor reads, then open the same flow used from workspace settings + setDraftMerchantRule(draft); + hideProductTrainingTooltip(); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID, undefined, Navigation.getActiveRoute())); + }; + + return ( + + {children} + + ); +} + +MerchantRuleSuggestionTooltip.displayName = 'MerchantRuleSuggestionTooltip'; + +export default MerchantRuleSuggestionTooltip; diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts index 05fca9e89df3..378a4497e6ba 100644 --- a/src/components/ProductTrainingContext/TOOLTIPS.ts +++ b/src/components/ProductTrainingContext/TOOLTIPS.ts @@ -1,3 +1,4 @@ +import {dismissMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; import {dismissProductTraining} from '@libs/actions/Welcome'; import CONST from '@src/CONST'; @@ -5,7 +6,7 @@ import type {TranslationPaths} from '@src/languages/types'; import type {ValueOf} from 'type-fest'; -const {CONCIERGE_LHN_GBR, OUTSTANDING_FILTER, ACCOUNT_SWITCHER, SCAN_TEST_DRIVE_CONFIRMATION, GPS_TOOLTIP, HAS_FILTER_NEGATION, MILEAGE_RATE_AUTO_UPDATED} = +const {CONCIERGE_LHN_GBR, OUTSTANDING_FILTER, ACCOUNT_SWITCHER, SCAN_TEST_DRIVE_CONFIRMATION, GPS_TOOLTIP, HAS_FILTER_NEGATION, MILEAGE_RATE_AUTO_UPDATED, MERCHANT_RULE_SUGGESTION} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; type ProductTrainingTooltipName = Exclude< @@ -83,6 +84,15 @@ const TOOLTIPS: Record = { priority: 800, shouldShow: () => true, }, + [MERCHANT_RULE_SUGGESTION]: { + content: 'productTrainingTooltip.merchantRuleSuggestion', + // Unlike the other tooltips this one is dismissed per expense for the session instead of account-wide, so + // that a later edit on another expense (or on the same one in a new session) can offer the rule again. + onHideTooltip: () => dismissMerchantRuleSuggestion(), + name: MERCHANT_RULE_SUGGESTION, + priority: 900, + shouldShow: ({isUserPolicyAdmin}) => isUserPolicyAdmin, + }, }; export default TOOLTIPS; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index c7a912c26753..f9baa5f261d2 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -3,7 +3,7 @@ import HighlightableMenuItemWithTopDescription from '@components/HighlightableMe import Icon from '@components/Icon'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; +import MerchantRuleSuggestionTooltip from '@components/MerchantRuleSuggestionTooltip'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails, usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider'; @@ -1165,6 +1165,10 @@ function MoneyRequestView({ // the RHP) must keep their inline receipt, so the check is scoped to this view's own route key. const isInWideRHP = wideRHPRouteKeys.includes(route.key); + // A "Create a rule" offer only makes sense on a live expense the admin just edited, not in a review or merge flow. + // On wide layouts the report footer anchors the tooltip above the composer instead. + const shouldOfferMerchantRule = shouldUseNarrowLayout && !readonly && !isFromReviewDuplicates && !isFromMergeTransaction; + // If the view is readonly, we don't need the transactionThread dependency if ((!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID) { return ; @@ -1174,21 +1178,20 @@ function MoneyRequestView({ {shouldShowAnimatedBackground && } <> - {/* On wide layouts this callout renders above the composer in the report footer instead, per design */} - {shouldUseNarrowLayout && !readonly && !isFromReviewDuplicates && !isFromMergeTransaction && ( - - )} {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( - + // The "Create a rule" tooltip anchors above the receipt on narrow layouts; on wide layouts the + // report footer anchors it above the composer instead, per design. + + + )} {isCustomUnitOutOfPolicy && isPerDiemRequest && ( diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index a4e5ef68924d..432c0bf320d2 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -6,15 +6,13 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx'; -import {useMemo} from 'react'; - import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; import usePolicyFeatureWriteAccess from './usePolicyFeatureWriteAccess'; type MerchantRuleSuggestionResult = { - /** The edit that can be turned into a merchant rule, or undefined when no callout should render */ + /** The edit that can be turned into a merchant rule, or undefined when no tooltip should render */ suggestion: MerchantRuleSuggestion | undefined; /** The edited expense, needed to pre-seed the rule */ @@ -25,8 +23,11 @@ type MerchantRuleSuggestionResult = { }; /** - * Resolves the "Create a rule" callout for an expense detail view: an admin just edited a field that merchant rules - * can govern, and hasn't dismissed the prompt for that expense. + * Resolves the "Create a rule" tooltip for an expense detail view: an admin just edited a field that merchant rules + * can govern, and hasn't dismissed the offer for that expense. + * + * Admin rights are also checked by the tooltip's own `shouldShow` in TOOLTIPS.ts, but that check is workspace-agnostic + * (it asks whether the user administers any workspace), so the policy-specific checks live here. * * @param reportID - the report hosting the expense detail view (a transaction thread or its parent expense report) * @param policyID - the workspace the expense belongs to @@ -35,23 +36,15 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const {isBetaEnabled} = usePermissions(); const {login: currentUserLogin} = useCurrentUserPersonalDetails(); - // The collection is RAM-only and holds at most one entry per expense edited this session, so subscribing to all of - // it is cheaper than resolving this report's transaction on every report render. - const [suggestions] = useOnyx(ONYXKEYS.COLLECTION.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); - // Only workspace admins can create merchant rules, so nobody else should be prompted to - const canCreateMerchantRule = - !!reportID && isPolicyAdmin(policy, currentUserLogin) && canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); - - const suggestion = useMemo(() => { - if (!canCreateMerchantRule) { - return undefined; - } - return Object.values(suggestions ?? {}).find((entry) => !!entry && !entry.isDismissed && entry.reportIDs.includes(reportID)); - }, [canCreateMerchantRule, reportID, suggestions]); + const isForThisExpenseView = !!reportID && !!storedSuggestion && !storedSuggestion.isDismissed && storedSuggestion.reportIDs.includes(reportID); + // Only workspace admins can create merchant rules, so nobody else should be offered one + const canCreateMerchantRule = isPolicyAdmin(policy, currentUserLogin) && canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); + const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(suggestion?.transactionID)}`); diff --git a/src/languages/en.ts b/src/languages/en.ts index ff97ca80783d..fde1e60fb38c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8003,8 +8003,6 @@ const translations = { subtitle: 'Set merchant rules so expenses arrive correctly coded and require less cleanup.', addRule: 'Add merchant rule', findRule: 'Find merchant rule', - createRuleFromExpenseAction: 'Create a rule', - createRuleFromExpensePrompt: 'to apply your changes to all expenses that match your criteria.', addRuleTitle: 'Add rule', editRuleTitle: 'Edit rule', importRulesTitle: 'Import merchant rules', @@ -10645,6 +10643,7 @@ const translations = { gpsTooltip: "GPS tracking in progress! When you're done, stop tracking below.", hasFilterNegation: 'Search for expenses without receipts using -has:receipt.', mileageRateAutoUpdated: 'We updated the rate based on your travel date.', + merchantRuleSuggestion: 'Create a rule to apply your changes to all expenses that match your criteria.', }, discardChangesConfirmation: { title: 'Discard changes?', diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts index 8d8430b12c02..514cb45e7986 100644 --- a/src/libs/MerchantRuleSuggestionUtils.ts +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -1,7 +1,3 @@ -import Parser from '@libs/Parser'; -import {resolveCurrentTaxCode} from '@libs/PolicyUtils'; -import {getCategory, getDescription, getMerchant, getTag, isMerchantMissing} from '@libs/TransactionUtils'; - import CONST from '@src/CONST'; import type {MerchantRuleForm} from '@src/types/form'; import type {Policy, Transaction} from '@src/types/onyx'; @@ -9,6 +5,10 @@ import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSugg import type {OnyxEntry} from 'react-native-onyx'; +import Parser from './Parser'; +import {resolveCurrentTaxCode} from './PolicyUtils'; +import {getCategory, getDescription, getMerchant, getTag, isMerchantMissing} from './TransactionUtils'; + /** * Builds the merchant-rule draft that pre-seeds the rule creation flow when an admin turns an expense edit into a * rule. Returns undefined when the expense has no merchant to match on, since a rule cannot be saved without one. diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index 2395498895e9..d5f51a43ecf1 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -4,28 +4,32 @@ import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSugg import Onyx from 'react-native-onyx'; /** - * Records that a merchant-rule-governed field was edited on an expense, so the expense detail view can offer to - * turn the edit into a merchant rule. The record is RAM-only, so it never outlives the session, and it is written - * for every user: whether the callout actually renders is decided by `useMerchantRuleSuggestion`, which requires - * workspace admin rights. + * Records that a merchant-rule-governed field was edited on an expense, so the expense detail view can offer to turn + * the edit into a merchant rule. Only the most recent edit is kept: the product training context shows one tooltip at + * a time, and the offer is always about the edit the user just made. + * + * The record is RAM-only, so it never outlives the session. This deliberately does not use `dismissProductTraining` + * like the other product training elements: that NVP dismisses an element account-wide and permanently, while this + * callout is scoped to one expense and is meant to surface again on a later edit. + * + * Written for every user; whether the tooltip renders is decided by `useMerchantRuleSuggestion` and the tooltip's + * `shouldShow`, which require workspace admin rights. */ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: MerchantRuleSuggestionField, reportIDs: Array) { if (!transactionID) { return; } - Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_MERCHANT_RULE_SUGGESTION}${transactionID}`, { + Onyx.set(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), field, - // A fresh edit re-opens the callout even if an earlier edit on this expense was dismissed - isDismissed: false, }); } -/** Dismisses the "Create a rule" callout for a single expense. */ -function dismissMerchantRuleSuggestion(transactionID: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_MERCHANT_RULE_SUGGESTION}${transactionID}`, {isDismissed: true}); +/** Dismisses the "Create a rule" tooltip for the expense currently offering it. */ +function dismissMerchantRuleSuggestion() { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isDismissed: true}); } export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion}; diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index cea7b76e16a1..c234cde8963d 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -2,7 +2,7 @@ import AnonymousReportFooter from '@components/AnonymousReportFooter'; import ArchivedReportFooter from '@components/ArchivedReportFooter'; import Banner from '@components/Banner'; import BlockedReportFooter from '@components/BlockedReportFooter'; -import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; +import MerchantRuleSuggestionTooltip from '@components/MerchantRuleSuggestionTooltip'; import OfflineIndicator from '@components/OfflineIndicator'; import SwipeableView from '@components/SwipeableView'; @@ -107,23 +107,27 @@ function ReportFooter() { ); + // On narrow layouts the expense detail view anchors this tooltip under its own header instead, per design + const composerWithMerchantRuleTooltip = + shouldUseNarrowLayout || isComposerFullSize ? ( + composer + ) : ( + + {composer} + + ); return ( - {/* On narrow layouts the expense detail view renders this callout under its own header instead, per design */} - {!shouldUseNarrowLayout && !isComposerFullSize && ( - - )} {shouldShowEnableNotificationsBanner ? ( <> - {composer} + {composerWithMerchantRuleTooltip} ) : ( - composer + composerWithMerchantRuleTooltip )} ); diff --git a/src/setup/index.ts b/src/setup/index.ts index 02e1326a9228..8f7ab3ebe574 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -77,7 +77,7 @@ export default function () { ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE, - ONYXKEYS.COLLECTION.RAM_ONLY_MERCHANT_RULE_SUGGESTION, + ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ONYXKEYS.RAM_ONLY_MERGE_HR_LINK_TOKEN, ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD, diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts index 0967dcf8691a..4b2ff1c205fa 100644 --- a/src/types/onyx/DismissedProductTraining.ts +++ b/src/types/onyx/DismissedProductTraining.ts @@ -11,6 +11,7 @@ const { MILEAGE_RATE_AUTO_UPDATED, REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP, REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP, + MERCHANT_RULE_SUGGESTION, } = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; /** @@ -100,6 +101,12 @@ type DismissedProductTraining = { * while Receipt is waived, we store the timestamp here. */ [REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP]: DismissedProductTrainingElement; + + /** + * The "Create a rule" callout on an expense is dismissed per expense for the session rather than account-wide, + * so nothing is stored here. The key exists only because the product training registry is typed by this object. + */ + [MERCHANT_RULE_SUGGESTION]?: never; }; export default DismissedProductTraining; diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index b08a1345ab9c..013de17e0e44 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -6,22 +6,22 @@ import type {ValueOf} from 'type-fest'; type MerchantRuleSuggestionField = ValueOf; /** - * Session-scoped record of a merchant-rule-governed field the user just edited on an expense. + * Session-scoped record of the merchant-rule-governed field the user most recently edited on an expense. * - * Drives the "Create a rule" callout on the expense detail view. Kept RAM-only so the callout can + * Drives the "Create a rule" product training tooltip on the expense detail view. Kept RAM-only so the tooltip can * surface again on the same expense in a future session, per the product spec. */ type MerchantRuleSuggestion = { /** The expense that was edited */ transactionID: string; - /** Reports whose expense detail view can surface the callout: the transaction thread and its parent expense report */ + /** Reports whose expense detail view can surface the tooltip: the transaction thread and its parent expense report */ reportIDs: string[]; - /** The most recently edited field, used to pre-seed the rule */ + /** The edited field, used to pre-seed the rule */ field: MerchantRuleSuggestionField; - /** Whether the user dismissed the callout for this expense */ + /** Whether the user dismissed the tooltip for this expense */ isDismissed?: boolean; }; From 9029ae61d487de589282ce84fa35e4d36ffb8c05 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 28 Aug 2026 04:37:30 +0530 Subject: [PATCH 03/51] Render the merchant rule prompt as a product training tooltip Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionTooltip.tsx | 88 ++++++++++++++++--- .../ProductTrainingContext/TOOLTIPS.ts | 3 +- .../ProductTrainingContext/index.tsx | 11 ++- .../ReportActionItem/MoneyRequestView.tsx | 6 +- .../BaseGenericTooltip/index.native.tsx | 9 +- .../Tooltip/BaseGenericTooltip/index.tsx | 9 +- .../Tooltip/BaseGenericTooltip/types.ts | 1 + src/components/Tooltip/GenericTooltip.tsx | 2 + src/components/Tooltip/types.ts | 3 + src/hooks/useMerchantRuleSuggestion.ts | 17 ++-- src/languages/en.ts | 3 +- src/pages/inbox/report/ReportFooter.tsx | 9 +- 12 files changed, 131 insertions(+), 30 deletions(-) diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx index 758bbc3421e5..a83485813c6f 100644 --- a/src/components/MerchantRuleSuggestionTooltip.tsx +++ b/src/components/MerchantRuleSuggestionTooltip.tsx @@ -1,4 +1,7 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {setDraftMerchantRule} from '@libs/actions/User'; @@ -10,17 +13,24 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import React from 'react'; +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import Icon from './Icon'; +import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import {useProductTrainingContext} from './ProductTrainingContext'; +import Text from './Text'; import EducationalTooltip from './Tooltip/EducationalTooltip'; type MerchantRuleSuggestionTooltipProps = React.PropsWithChildren<{ - /** The report hosting the expense detail view: a transaction thread or its parent expense report */ + /** The report hosting the expense detail view: a transaction thread, its expense report, or the chat it lives in */ reportID: string | undefined; /** The workspace the expense belongs to */ policyID: string | undefined; + + /** The expense being displayed, when the host already knows it */ + transactionID?: string; }>; /** @@ -28,13 +38,13 @@ type MerchantRuleSuggestionTooltipProps = React.PropsWithChildren<{ * to turn the expense edit they just made into a merchant rule. Renders the children untouched when there is no * qualifying edit to act on. */ -function MerchantRuleSuggestionTooltip({reportID, policyID, children}: MerchantRuleSuggestionTooltipProps) { +function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, children}: MerchantRuleSuggestionTooltipProps) { const styles = useThemeStyles(); - const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); - const {renderProductTrainingTooltip, shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( - CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION, - !!suggestion, - ); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Lightbulb', 'Close']); + const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID, transactionID); + const {shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION, !!suggestion); const createRule = () => { if (!suggestion || !policyID) { @@ -50,18 +60,74 @@ function MerchantRuleSuggestionTooltip({reportID, policyID, children}: MerchantR Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID, undefined, Navigation.getActiveRoute())); }; + // The shared product training content renders its copy as a single block of HTML, but this callout needs the + // "Create a rule" phrase in link colour, so the content is composed here instead. + const renderTooltipContent = useCallback( + () => ( + + + + + {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} + {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} + + + hideProductTrainingTooltip()} + > + + + + ), + [ + hideProductTrainingTooltip, + icons.Close, + icons.Lightbulb, + styles.alignItemsCenter, + styles.flexRow, + styles.flexShrink1, + styles.gap3, + styles.link, + styles.ph2, + styles.productTrainingTooltipText, + styles.pv2, + theme.icon, + theme.tooltipHighlightText, + translate, + ], + ); + return ( - {children} + {/* The tooltip measures its anchor by cloning this child with an `onLayout` prop, so it has to be a plain + View: the composer and the receipt view don't forward that prop, which would leave it unmeasured. */} + {children} ); } diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts index 378a4497e6ba..3adc4e95d5fa 100644 --- a/src/components/ProductTrainingContext/TOOLTIPS.ts +++ b/src/components/ProductTrainingContext/TOOLTIPS.ts @@ -85,7 +85,8 @@ const TOOLTIPS: Record = { shouldShow: () => true, }, [MERCHANT_RULE_SUGGESTION]: { - content: 'productTrainingTooltip.merchantRuleSuggestion', + // Unused: MerchantRuleSuggestionTooltip renders its own content so the "Create a rule" phrase can be a link. + content: 'workspace.rules.merchantRules.createRuleFromExpensePrompt', // Unlike the other tooltips this one is dismissed per expense for the session instead of account-wide, so // that a later edit on another expense (or on the same one in a new session) can offer the rule again. onHideTooltip: () => dismissMerchantRuleSuggestion(), diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx index 8df0821dfc87..9857325532b5 100644 --- a/src/components/ProductTrainingContext/index.tsx +++ b/src/components/ProductTrainingContext/index.tsx @@ -146,8 +146,14 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { } // Mileage rate tooltip is exempt from the general "hide when modal visible" rule so it can show on the confirmation surface. - // Hide it when a popover or bottom sheet opens (e.g. inline date picker) since those set modal.isPopover. - if (tooltipName === CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED && isModalVisible && modal?.isPopover) { + // The merchant rule tooltip is exempt for the same reason: the expense detail view it anchors to is usually the RHP, + // which sets the modal flag. Both hide when a popover or bottom sheet opens (e.g. inline date picker), + // since those set modal.isPopover. + if ( + (tooltipName === CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED || tooltipName === CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION) && + isModalVisible && + modal?.isPopover + ) { return false; } @@ -157,6 +163,7 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.GPS_TOOLTIP && tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.HAS_FILTER_NEGATION && tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED && + tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION && isModalVisible ) { return false; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 2c03a18a8418..6f9d4c9221c5 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1168,8 +1168,9 @@ function MoneyRequestView({ const isInWideRHP = wideRHPRouteKeys.includes(route.key); // A "Create a rule" offer only makes sense on a live expense the admin just edited, not in a review or merge flow. - // On wide layouts the report footer anchors the tooltip above the composer instead. - const shouldOfferMerchantRule = shouldUseNarrowLayout && !readonly && !isFromReviewDuplicates && !isFromMergeTransaction; + // The tooltip anchors to the receipt only where the receipt renders inline above the fields; on wide layouts and + // in a wide RHP (where the receipt moves to its own panel) the report footer anchors it above the composer instead. + const shouldOfferMerchantRule = shouldUseNarrowLayout && !isInWideRHP && !readonly && !isFromReviewDuplicates && !isFromMergeTransaction; // If the view is readonly, we don't need the transactionThread dependency if ((!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID) { @@ -1185,6 +1186,7 @@ function MoneyRequestView({ // report footer anchors it above the composer instead, per design. {}, shouldTeleportPortalToModalLayer = false, isEducationTooltip = false, @@ -142,9 +143,11 @@ function BaseGenericTooltip({ const wrapperChildren = ( <> {content} - - - + {!shouldHidePointer && ( + + + + )} ); diff --git a/src/components/Tooltip/BaseGenericTooltip/index.tsx b/src/components/Tooltip/BaseGenericTooltip/index.tsx index aa76aa9f3fcd..96f866414cb6 100644 --- a/src/components/Tooltip/BaseGenericTooltip/index.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.tsx @@ -43,6 +43,7 @@ function BaseGenericTooltip({ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }, shouldUseOverlay = false, + shouldHidePointer = false, onHideTooltip = () => {}, isEducationTooltip = false, onTooltipPress, @@ -165,9 +166,11 @@ function BaseGenericTooltip({ const wrapperChildren = ( <> {content} - - - + {!shouldHidePointer && ( + + + + )} ); diff --git a/src/components/Tooltip/BaseGenericTooltip/types.ts b/src/components/Tooltip/BaseGenericTooltip/types.ts index c36c9fe7c133..e34619d03128 100644 --- a/src/components/Tooltip/BaseGenericTooltip/types.ts +++ b/src/components/Tooltip/BaseGenericTooltip/types.ts @@ -50,6 +50,7 @@ type BaseGenericTooltipProps = { | 'wrapperStyle' | 'anchorAlignment' | 'shouldUseOverlay' + | 'shouldHidePointer' | 'onTooltipPress' | 'computeHorizontalShiftForNative' >; diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx index 45c97176c4ea..bc2c41f0f3ee 100644 --- a/src/components/Tooltip/GenericTooltip.tsx +++ b/src/components/Tooltip/GenericTooltip.tsx @@ -42,6 +42,7 @@ function GenericTooltip({ }, shouldForceAnimate = false, shouldUseOverlay: shouldUseOverlayProp = false, + shouldHidePointer = false, shouldTeleportPortalToModalLayer, shouldRender = true, isEducationTooltip = false, @@ -193,6 +194,7 @@ function GenericTooltip({ wrapperStyle={wrapperStyle} anchorAlignment={anchorAlignment} shouldUseOverlay={shouldUseOverlay} + shouldHidePointer={shouldHidePointer} shouldTeleportPortalToModalLayer={shouldTeleportPortalToModalLayer} onHideTooltip={onPressOverlay} onTooltipPress={onTooltipPress} diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index 5f3998ece92a..18761712a750 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -45,6 +45,9 @@ type SharedTooltipProps = { /** Should render a fullscreen transparent overlay */ shouldUseOverlay?: boolean; + /** Whether to hide the pointer that connects the tooltip to its anchor, leaving the tooltip as a detached card */ + shouldHidePointer?: boolean; + /** Whether the tooltip should teleport to the modal layer */ shouldTeleportPortalToModalLayer?: boolean; diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index 432c0bf320d2..b56dfec0252a 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -10,6 +10,7 @@ import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; import usePolicyFeatureWriteAccess from './usePolicyFeatureWriteAccess'; +import useReportTransactions from './useReportTransactions'; type MerchantRuleSuggestionResult = { /** The edit that can be turned into a merchant rule, or undefined when no tooltip should render */ @@ -29,25 +30,31 @@ type MerchantRuleSuggestionResult = { * Admin rights are also checked by the tooltip's own `shouldShow` in TOOLTIPS.ts, but that check is workspace-agnostic * (it asks whether the user administers any workspace), so the policy-specific checks live here. * - * @param reportID - the report hosting the expense detail view (a transaction thread or its parent expense report) + * @param reportID - the report hosting the expense detail view (a transaction thread, its expense report, or the chat the expense lives in) * @param policyID - the workspace the expense belongs to + * @param transactionID - the expense being displayed, when the caller already knows it */ -function useMerchantRuleSuggestion(reportID: string | undefined, policyID: string | undefined): MerchantRuleSuggestionResult { +function useMerchantRuleSuggestion(reportID: string | undefined, policyID: string | undefined, transactionID?: string): MerchantRuleSuggestionResult { const {isBetaEnabled} = usePermissions(); const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(storedSuggestion?.transactionID)}`); const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); - const isForThisExpenseView = !!reportID && !!storedSuggestion && !storedSuggestion.isDismissed && storedSuggestion.reportIDs.includes(reportID); + // A screen hosts this expense when it renders the expense itself (the caller passes the transaction), when it is + // the expense's own transaction thread, or when it is a report listing the expense. Asking the report which + // transactions it holds is authoritative, unlike the reports that happened to be in scope during the edit. + const reportTransactions = useReportTransactions(reportID); + const isHostingReport = + !!reportID && (reportID === storedSuggestion?.reportIDs.at(0) || reportTransactions.some((reportTransaction) => reportTransaction.transactionID === storedSuggestion?.transactionID)); + const isForThisExpenseView = !!storedSuggestion && !storedSuggestion.isDismissed && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); // Only workspace admins can create merchant rules, so nobody else should be offered one const canCreateMerchantRule = isPolicyAdmin(policy, currentUserLogin) && canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(suggestion?.transactionID)}`); - // A merchant rule matches on merchant, so an expense without one (e.g. a receipt still scanning) can't seed a rule if (!suggestion || !transaction || isMerchantMissing(transaction)) { return {suggestion: undefined, transaction: undefined, policy: undefined}; diff --git a/src/languages/en.ts b/src/languages/en.ts index 185ed0318651..8c3f2a6733de 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8130,6 +8130,8 @@ const translations = { subtitle: 'Set merchant rules so expenses arrive correctly coded and require less cleanup.', addRule: 'Add merchant rule', findRule: 'Find merchant rule', + createRuleFromExpenseAction: 'Create a rule', + createRuleFromExpensePrompt: 'to apply your changes to all expenses that match your criteria.', addRuleTitle: 'Add rule', editRuleTitle: 'Edit rule', importRulesTitle: 'Import merchant rules', @@ -10803,7 +10805,6 @@ const translations = { gpsTooltip: "GPS tracking in progress! When you're done, stop tracking below.", hasFilterNegation: 'Search for expenses without receipts using -has:receipt.', mileageRateAutoUpdated: 'We updated the rate based on your travel date.', - merchantRuleSuggestion: 'Create a rule to apply your changes to all expenses that match your criteria.', }, discardChangesConfirmation: { title: 'Discard changes?', diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index c234cde8963d..abebf9f5257d 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -5,6 +5,7 @@ import BlockedReportFooter from '@components/BlockedReportFooter'; import MerchantRuleSuggestionTooltip from '@components/MerchantRuleSuggestionTooltip'; import OfflineIndicator from '@components/OfflineIndicator'; import SwipeableView from '@components/SwipeableView'; +import {useWideRHPState} from '@components/WideRHPContextProvider'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; @@ -66,6 +67,10 @@ function ReportFooter() { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb']); + // A wide RHP reports a narrow layout but lays the expense out like a wide screen: the receipt moves to its own + // panel and the composer sits beside it, so the "Create a rule" tooltip anchors here rather than on the receipt. + const {wideRHPRouteKeys} = useWideRHPState(); + const isInWideRHP = wideRHPRouteKeys.includes(route.key); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); @@ -107,9 +112,9 @@ function ReportFooter() { ); - // On narrow layouts the expense detail view anchors this tooltip under its own header instead, per design + // On narrow layouts the expense detail view anchors this tooltip above its receipt instead, per design const composerWithMerchantRuleTooltip = - shouldUseNarrowLayout || isComposerFullSize ? ( + (shouldUseNarrowLayout && !isInWideRHP) || isComposerFullSize ? ( composer ) : ( Date: Fri, 28 Aug 2026 04:46:11 +0530 Subject: [PATCH 04/51] Fix the merchant rule tooltip's highlight color and scroll behavior Signed-off-by: krishna2323 --- src/components/MerchantRuleSuggestionTooltip.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx index a83485813c6f..53c672dd8a12 100644 --- a/src/components/MerchantRuleSuggestionTooltip.tsx +++ b/src/components/MerchantRuleSuggestionTooltip.tsx @@ -61,7 +61,7 @@ function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, child }; // The shared product training content renders its copy as a single block of HTML, but this callout needs the - // "Create a rule" phrase in link colour, so the content is composed here instead. + // "Create a rule" phrase highlighted, so the content is composed here instead. const renderTooltipContent = useCallback( () => ( + {/* The tooltip background is dark in both themes, so the highlighted phrase uses the palette's tooltip + highlight rather than `styles.link`, whose light-theme blue reads poorly against it. */} - {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} + {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} @@ -104,7 +106,6 @@ function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, child styles.flexRow, styles.flexShrink1, styles.gap3, - styles.link, styles.ph2, styles.productTrainingTooltipText, styles.pv2, @@ -119,11 +120,13 @@ function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, child shouldRender={shouldShowProductTrainingTooltip} renderTooltipContent={renderTooltipContent} wrapperStyle={styles.productTrainingTooltipWrapper} - anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} shiftVertical={variables.mileageRateTooltipShiftVertical} onTooltipPress={createRule} shouldHidePointer - shouldHideOnScroll > {/* The tooltip measures its anchor by cloning this child with an `onLayout` prop, so it has to be a plain View: the composer and the receipt view don't forward that prop, which would leave it unmeasured. */} From 840858a449e1425abbb0bba69d2cc2dd7f8bc45e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 28 Aug 2026 04:56:24 +0530 Subject: [PATCH 05/51] Align the merchant rule tooltip with the product training conventions Signed-off-by: krishna2323 --- .../BaseHTMLEngineProvider.tsx | 6 + .../MerchantRuleSuggestionTooltip.tsx | 123 +++++++----------- .../ProductTrainingContext/TOOLTIPS.ts | 3 +- .../ReportActionItem/MoneyRequestView.tsx | 40 +++--- src/languages/en.ts | 3 +- src/styles/index.ts | 4 + 6 files changed, 80 insertions(+), 99 deletions(-) diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 452a8a61cbd9..05733659cb3a 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -154,6 +154,12 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim mixedUAStyles: {whiteSpace: 'pre', ...styles.productTrainingTooltipText}, contentModel: HTMLContentModel.block, }), + // Highlights a phrase inside a product training tooltip, whose background is dark in both themes + 'tooltip-highlight': HTMLElementModel.fromCustomModel({ + tagName: 'tooltip-highlight', + mixedUAStyles: {...styles.productTrainingTooltipHighlightText}, + contentModel: HTMLContentModel.textual, + }), success: HTMLElementModel.fromCustomModel({ tagName: 'success', mixedUAStyles: {...styles.textSuccess}, diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx index 53c672dd8a12..36d88cd27ad3 100644 --- a/src/components/MerchantRuleSuggestionTooltip.tsx +++ b/src/components/MerchantRuleSuggestionTooltip.tsx @@ -1,7 +1,5 @@ -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; -import useTheme from '@hooks/useTheme'; +import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {setDraftMerchantRule} from '@libs/actions/User'; @@ -11,18 +9,18 @@ import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import React, {useCallback} from 'react'; +import type {ReactElement} from 'react'; + +import React from 'react'; import {View} from 'react-native'; -import Icon from './Icon'; -import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import {useProductTrainingContext} from './ProductTrainingContext'; -import Text from './Text'; import EducationalTooltip from './Tooltip/EducationalTooltip'; -type MerchantRuleSuggestionTooltipProps = React.PropsWithChildren<{ +type MerchantRuleSuggestionTooltipProps = { /** The report hosting the expense detail view: a transaction thread, its expense report, or the chat it lives in */ reportID: string | undefined; @@ -31,20 +29,18 @@ type MerchantRuleSuggestionTooltipProps = React.PropsWithChildren<{ /** The expense being displayed, when the host already knows it */ transactionID?: string; -}>; -/** - * Anchors the "Create a rule" product training tooltip to the wrapped element, offering a workspace admin the chance - * to turn the expense edit they just made into a merchant rule. Renders the children untouched when there is no - * qualifying edit to act on. - */ -function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, children}: MerchantRuleSuggestionTooltipProps) { + /** The element the tooltip anchors to */ + children: ReactElement; +}; + +function MerchantRuleSuggestionTooltipInner({reportID, policyID, transactionID, children}: MerchantRuleSuggestionTooltipProps) { const styles = useThemeStyles(); - const theme = useTheme(); - const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Lightbulb', 'Close']); const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID, transactionID); - const {shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION, !!suggestion); + const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION, + !!suggestion, + ); const createRule = () => { if (!suggestion || !policyID) { @@ -60,70 +56,12 @@ function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, child Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID, undefined, Navigation.getActiveRoute())); }; - // The shared product training content renders its copy as a single block of HTML, but this callout needs the - // "Create a rule" phrase highlighted, so the content is composed here instead. - const renderTooltipContent = useCallback( - () => ( - - - {/* The tooltip background is dark in both themes, so the highlighted phrase uses the palette's tooltip - highlight rather than `styles.link`, whose light-theme blue reads poorly against it. */} - - - {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} - {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} - - - hideProductTrainingTooltip()} - > - - - - ), - [ - hideProductTrainingTooltip, - icons.Close, - icons.Lightbulb, - styles.alignItemsCenter, - styles.flexRow, - styles.flexShrink1, - styles.gap3, - styles.ph2, - styles.productTrainingTooltipText, - styles.pv2, - theme.icon, - theme.tooltipHighlightText, - translate, - ], - ); - return ( + {children} + + ); +} + MerchantRuleSuggestionTooltip.displayName = 'MerchantRuleSuggestionTooltip'; export default MerchantRuleSuggestionTooltip; diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts index 3adc4e95d5fa..378a4497e6ba 100644 --- a/src/components/ProductTrainingContext/TOOLTIPS.ts +++ b/src/components/ProductTrainingContext/TOOLTIPS.ts @@ -85,8 +85,7 @@ const TOOLTIPS: Record = { shouldShow: () => true, }, [MERCHANT_RULE_SUGGESTION]: { - // Unused: MerchantRuleSuggestionTooltip renders its own content so the "Create a rule" phrase can be a link. - content: 'workspace.rules.merchantRules.createRuleFromExpensePrompt', + content: 'productTrainingTooltip.merchantRuleSuggestion', // Unlike the other tooltips this one is dismissed per expense for the session instead of account-wide, so // that a later edit on another expense (or on the same one in a new session) can offer the rule again. onHideTooltip: () => dismissMerchantRuleSuggestion(), diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 6f9d4c9221c5..81758a52e932 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -1177,26 +1177,34 @@ function MoneyRequestView({ return ; } + const receipt = ( + + ); + + // The "Create a rule" tooltip anchors above the receipt on narrow layouts; on wide layouts the report footer + // anchors it above the composer instead, per design. + const receiptView = shouldOfferMerchantRule ? ( + + {receipt} + + ) : ( + receipt + ); + return ( {shouldShowAnimatedBackground && } <> - {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( - // The "Create a rule" tooltip anchors above the receipt on narrow layouts; on wide layouts the - // report footer anchors it above the composer instead, per design. - - - - )} + {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && receiptView} {isCustomUnitOutOfPolicy && isPerDiemRequest && ( GPS tracking in progress! When you're done, stop tracking below.", hasFilterNegation: 'Search for expenses without receipts using -has:receipt.', mileageRateAutoUpdated: 'We updated the rate based on your travel date.', + merchantRuleSuggestion: 'Create a rule to apply your changes to all expenses that match your criteria.', }, discardChangesConfirmation: { title: 'Discard changes?', diff --git a/src/styles/index.ts b/src/styles/index.ts index 1c8b43f07724..85de92dac805 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4408,6 +4408,10 @@ const staticStyles = (theme: ThemeColors) => flexShrink: 1, }, + productTrainingTooltipHighlightText: { + color: theme.tooltipHighlightText, + }, + quickReactionsContainer: { gap: 12, flexDirection: 'row', From e22352d5a16066cf53f2cb767d7a6c4b6f901f21 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 28 Aug 2026 04:58:52 +0530 Subject: [PATCH 06/51] Use a link color and bold weight for the tooltip's highlighted phrase Signed-off-by: krishna2323 --- src/styles/index.ts | 3 ++- src/styles/theme/themes/dark.ts | 2 ++ src/styles/theme/themes/light.ts | 2 ++ src/styles/theme/types.ts | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 85de92dac805..190411079c10 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4409,7 +4409,8 @@ const staticStyles = (theme: ThemeColors) => }, productTrainingTooltipHighlightText: { - color: theme.tooltipHighlightText, + color: theme.tooltipLinkText, + fontWeight: FontUtils.fontWeight.bold, }, quickReactionsContainer: { diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index f04c08581918..54c6ba3b5d82 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -103,6 +103,8 @@ const darkTheme = { ourMentionBG: colors.green600, tooltipHighlightBG: colors.green100, tooltipHighlightText: colors.green400, + // The tooltip background is dark in both themes, so this stays the same in both, like the highlight above + tooltipLinkText: colors.blue300, tooltipSupportingText: colors.productLight800, tooltipPrimaryText: colors.productLight900, trialBannerBackgroundColor: colors.green700, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index f9685e8898b5..2cb6e56211e9 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -103,6 +103,8 @@ const lightTheme = { ourMentionBG: colors.green100, tooltipHighlightBG: colors.green700, tooltipHighlightText: colors.green400, + // The tooltip background is dark in both themes, so this stays the same in both, like the highlight above + tooltipLinkText: colors.blue300, tooltipSupportingText: colors.productDark800, tooltipPrimaryText: colors.productDark900, trialBannerBackgroundColor: colors.green100, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index d1a4f83d901a..bdbcbe6d6a33 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -110,6 +110,7 @@ type ThemeColors = { ourMentionBG: Color; tooltipHighlightBG: Color; tooltipHighlightText: Color; + tooltipLinkText: Color; tooltipSupportingText: Color; tooltipPrimaryText: Color; trialBannerBackgroundColor: Color; From 23f8c248248297b2460793f6a20f53c68364e669 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 28 Aug 2026 20:24:47 +0530 Subject: [PATCH 07/51] Retire the merchant rule offer once it has been seen Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionTooltip.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx index 36d88cd27ad3..70903a51b7cf 100644 --- a/src/components/MerchantRuleSuggestionTooltip.tsx +++ b/src/components/MerchantRuleSuggestionTooltip.tsx @@ -2,6 +2,7 @@ import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {dismissMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; import {setDraftMerchantRule} from '@libs/actions/User'; import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -14,7 +15,7 @@ import ROUTES from '@src/ROUTES'; import type {ReactElement} from 'react'; -import React from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import {useProductTrainingContext} from './ProductTrainingContext'; @@ -42,6 +43,26 @@ function MerchantRuleSuggestionTooltipInner({reportID, policyID, transactionID, !!suggestion, ); + const hasBeenShownRef = useRef(false); + useEffect(() => { + if (!shouldShowProductTrainingTooltip) { + return; + } + hasBeenShownRef.current = true; + }, [shouldShowProductTrainingTooltip]); + + // The offer is about the edit the user just made, so it is consumed once they have seen it and moved on. Without + // this it would live in the RAM-only key untouched until dismissed, and re-appear on every return to the expense. + useEffect( + () => () => { + if (!hasBeenShownRef.current) { + return; + } + dismissMerchantRuleSuggestion(); + }, + [], + ); + const createRule = () => { if (!suggestion || !policyID) { return; From b166e9ea8d3b13878f6e6aff1713f9269f952111 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 28 Aug 2026 20:39:22 +0530 Subject: [PATCH 08/51] Drop the deprecated backTo param from the merchant rule flow Signed-off-by: krishna2323 --- src/ROUTES.ts | 3 +-- src/components/MerchantRuleSuggestionTooltip.tsx | 6 ++++-- src/libs/ExportOnyxState/common.ts | 1 + src/libs/Navigation/types.ts | 1 - src/libs/actions/MerchantRuleSuggestion.ts | 13 ++++++++++++- .../rules/MerchantRules/AddMerchantRulePage.tsx | 1 - .../rules/MerchantRules/MerchantRulePageBase.tsx | 16 +++++++++------- src/types/onyx/MerchantRuleSuggestion.ts | 6 ++++++ 8 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cddd400bd618..570e7940880e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3864,8 +3864,7 @@ const ROUTES = { }, RULES_MERCHANT_NEW: { route: 'workspaces/:policyID/rules/merchant-rules/new', - getRoute: (policyID: string, categoryName?: string, backTo?: string) => - getUrlWithBackToParam(`workspaces/${policyID}/rules/merchant-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, backTo), + getRoute: (policyID: string, categoryName?: string) => `workspaces/${policyID}/rules/merchant-rules/new${getOptionalCategoryNameQuery(categoryName)}` as const, }, RULES_MERCHANT_IMPORT: { route: 'workspaces/:policyID/rules/merchant-rules/import', diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx index 70903a51b7cf..f6a05961c5d2 100644 --- a/src/components/MerchantRuleSuggestionTooltip.tsx +++ b/src/components/MerchantRuleSuggestionTooltip.tsx @@ -2,7 +2,7 @@ import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {dismissMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; +import {dismissMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; import {setDraftMerchantRule} from '@libs/actions/User'; import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -73,8 +73,10 @@ function MerchantRuleSuggestionTooltipInner({reportID, policyID, transactionID, } // Seed the draft the rule editor reads, then open the same flow used from workspace settings setDraftMerchantRule(draft); + // Tells that flow it was entered from an expense, so saving returns here rather than to the Rules page + setIsCreatingMerchantRule(true); hideProductTrainingTooltip(); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID, undefined, Navigation.getActiveRoute())); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); }; return ( diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 9722be1c9bf3..2ff07477f7e7 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -317,6 +317,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.RAM_ONLY_IS_LOADING_DEPOSIT_ACCOUNT_SETUP, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, ONYXKEYS.RAM_ONLY_IS_SIDEBAR_LOADED, + ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, ONYXKEYS.RAM_ONLY_MOBILE_SELECTION_MODE, ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE, ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 923f682cf7a3..b4e28a3766e0 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1637,7 +1637,6 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { policyID: string; categoryName?: string; - backTo?: Routes; }; [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { policyID: string; diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index d5f51a43ecf1..c71667d7b112 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -32,4 +32,15 @@ function dismissMerchantRuleSuggestion() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isDismissed: true}); } -export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion}; +/** + * Records that the user took the offer, so the rule page knows to return to the expense once the rule is saved. + * + * The `backTo` URL param is deprecated (see the `How to remove backTo from URL` section in NAVIGATION.md), so the + * origin travels through this record instead. It is session-scoped like the rest of the record, which means a page + * refresh mid-flow falls back to the workspace Rules page — acceptable, since a refresh discards the offer anyway. + */ +function setIsCreatingMerchantRule(isCreatingRule: boolean) { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isCreatingRule}); +} + +export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, setIsCreatingMerchantRule}; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 4aadd8247b37..74693dcbfb3c 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -14,7 +14,6 @@ function AddMerchantRulePage({route}: AddMerchantRulePageProps) { diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 461bc72b2a96..5d4bd9cc6783 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -22,6 +22,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import usePressLoading from '@hooks/usePressLoading'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {deletePolicyCodingRule, setPolicyCodingRule} from '@libs/actions/Policy/Rules'; import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; @@ -43,7 +44,6 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {MerchantRuleForm} from '@src/types/form'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; @@ -63,8 +63,6 @@ type MerchantRulePageBaseProps = { ruleID?: string; /** Pre-scopes the category default when creating a rule (e.g. from the category details RHP). */ initialCategoryName?: string; - /** Where to return after saving. Set when the rule is created outside workspace settings, e.g. from an expense. */ - backTo?: Route; titleKey: TranslationPaths; testID: string; }; @@ -114,7 +112,7 @@ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) return translate('workspace.rules.merchantRules.confirmError'); }; -function MerchantRulePageBase({policyID, ruleID, initialCategoryName, backTo, titleKey, testID}: MerchantRulePageBaseProps) { +function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, testID}: MerchantRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policy = usePolicy(policyID); @@ -129,6 +127,8 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, backTo, ti const getItemIcon = (icon: IconAsset) => (isRulesRevampEnabled ? icon : undefined); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); + const [merchantRuleSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + const isCreatedFromExpense = !isEditing && !!merchantRuleSuggestion?.isCreatingRule; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [policyTagsFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const policyTags = useMemo(() => getTagLists(policyTagsFromOnyx) ?? getEmptyArray>(), [policyTagsFromOnyx]); @@ -294,9 +294,11 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, backTo, ti return; } setPolicyCodingRule(policyID, form, policy, ruleID, shouldUpdateMatchingTransactions); - if (backTo) { - // The rule was created from outside workspace settings (e.g. an expense), so return there instead of the Rules page - Navigation.goBack(backTo); + if (isCreatedFromExpense) { + // The rule was created from an expense rather than workspace settings, so pop back to it instead of + // landing on the Rules page. Clear the marker first so a later rule created from settings is unaffected. + setIsCreatingMerchantRule(false); + Navigation.goBack(); } else if (!isEditing && isRulesRevampEnabled) { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.EXPENSE_DEFAULTS); Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 013de17e0e44..7e91b1daa784 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -23,6 +23,12 @@ type MerchantRuleSuggestion = { /** Whether the user dismissed the tooltip for this expense */ isDismissed?: boolean; + + /** + * Whether the user took the offer and is now in the rule creation flow. The rule page reads this to return to the + * expense after saving instead of to the workspace Rules page, which is where creating a rule from settings ends. + */ + isCreatingRule?: boolean; }; export default MerchantRuleSuggestion; From 37972829a481e014c3663d0cb423141eb49378a3 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 28 Aug 2026 21:03:02 +0530 Subject: [PATCH 09/51] Scope the merchant rule callout to one expense at a time Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionTooltip.tsx | 2 +- src/hooks/useMerchantRuleSuggestion.ts | 15 ++++---- src/libs/actions/MerchantRuleSuggestion.ts | 34 ++++++++++++++++--- src/types/onyx/MerchantRuleSuggestion.ts | 7 ++-- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx index f6a05961c5d2..2b307308cdcc 100644 --- a/src/components/MerchantRuleSuggestionTooltip.tsx +++ b/src/components/MerchantRuleSuggestionTooltip.tsx @@ -108,7 +108,7 @@ function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, child // Nothing is stored for most of a session, so skip the inner component (and its heavy hooks, which subscribe to the // policy, its categories, the transaction and the report's transactions) until an edit is waiting to be offered. - if (!storedSuggestion || storedSuggestion.isDismissed) { + if (!storedSuggestion?.transactionID || storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID)) { return children; } diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index b56dfec0252a..f808553e3862 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -44,13 +44,16 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(storedSuggestion?.transactionID)}`); const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); - // A screen hosts this expense when it renders the expense itself (the caller passes the transaction), when it is - // the expense's own transaction thread, or when it is a report listing the expense. Asking the report which - // transactions it holds is authoritative, unlike the reports that happened to be in scope during the edit. + // The callout belongs on the expense detail view, so a screen hosts it when it renders the expense itself (the + // caller passes the transaction), when it is the expense's own transaction thread, or when it is a report holding + // only that expense — which renders the expense detail view rather than a list. A report listing several expenses + // shows no expense detail, so it is not a host. Asking the report which transactions it holds is authoritative, + // unlike the reports that happened to be in scope during the edit. const reportTransactions = useReportTransactions(reportID); - const isHostingReport = - !!reportID && (reportID === storedSuggestion?.reportIDs.at(0) || reportTransactions.some((reportTransaction) => reportTransaction.transactionID === storedSuggestion?.transactionID)); - const isForThisExpenseView = !!storedSuggestion && !storedSuggestion.isDismissed && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); + const isOneExpenseReportForSuggestion = reportTransactions.length === 1 && reportTransactions.at(0)?.transactionID === storedSuggestion?.transactionID; + const isHostingReport = !!reportID && (reportID === storedSuggestion?.reportIDs.at(0) || isOneExpenseReportForSuggestion); + const isDismissed = !!storedSuggestion?.transactionID && !!storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID); + const isForThisExpenseView = !!storedSuggestion && !isDismissed && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); // Only workspace admins can create merchant rules, so nobody else should be offered one const canCreateMerchantRule = isPolicyAdmin(policy, currentUserLogin) && canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index c71667d7b112..dff77cc3d8f2 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -1,8 +1,20 @@ import ONYXKEYS from '@src/ONYXKEYS'; +import type {MerchantRuleSuggestion} from '@src/types/onyx'; import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; import Onyx from 'react-native-onyx'; +// The product training registry calls `onHideTooltip` with no arguments, so the dismissal below has to resolve the +// expense being offered on its own. This is action-only state, never read during render, so `Onyx.connectWithoutView` +// is appropriate. Components read the same key with `useOnyx`. +let merchantRuleSuggestion: MerchantRuleSuggestion | undefined; +Onyx.connectWithoutView({ + key: ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, + callback: (value) => { + merchantRuleSuggestion = value; + }, +}); + /** * Records that a merchant-rule-governed field was edited on an expense, so the expense detail view can offer to turn * the edit into a merchant rule. Only the most recent edit is kept: the product training context shows one tooltip at @@ -10,7 +22,8 @@ import Onyx from 'react-native-onyx'; * * The record is RAM-only, so it never outlives the session. This deliberately does not use `dismissProductTraining` * like the other product training elements: that NVP dismisses an element account-wide and permanently, while this - * callout is scoped to one expense and is meant to surface again on a later edit. + * callout is dismissed per expense and is meant to surface again on another expense, or on the same one in a later + * session. * * Written for every user; whether the tooltip renders is decided by `useMerchantRuleSuggestion` and the tooltip's * `shouldShow`, which require workspace admin rights. @@ -20,16 +33,29 @@ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: M return; } - Onyx.set(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { + // Merged rather than set so the session's dismissals survive: an expense the user has already dismissed must stay + // dismissed even after they edit it again. `isCreatingRule` belongs to the offer being replaced, so it is cleared. + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), field, + isCreatingRule: null, }); } -/** Dismisses the "Create a rule" tooltip for the expense currently offering it. */ +/** + * Dismisses the "Create a rule" callout for the expense currently offering it, for the rest of the session. Editing a + * different expense still surfaces the callout there, and a new session offers this expense again. + */ function dismissMerchantRuleSuggestion() { - Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isDismissed: true}); + const transactionID = merchantRuleSuggestion?.transactionID; + if (!transactionID) { + return; + } + + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { + dismissedTransactionIDs: [...new Set([...(merchantRuleSuggestion?.dismissedTransactionIDs ?? []), transactionID])], + }); } /** diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 7e91b1daa784..45861eefa3dc 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -21,8 +21,11 @@ type MerchantRuleSuggestion = { /** The edited field, used to pre-seed the rule */ field: MerchantRuleSuggestionField; - /** Whether the user dismissed the tooltip for this expense */ - isDismissed?: boolean; + /** + * Expenses whose callout the user dismissed this session. Kept alongside the current offer rather than replaced + * with it, so dismissing one expense survives a later edit on any expense, including that same one. + */ + dismissedTransactionIDs?: string[]; /** * Whether the user took the offer and is now in the rule creation flow. The rule page reads this to return to the From ab0ddbe1c044e5f7a441f1cfa33bce0e46b4f073 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 28 Aug 2026 22:56:36 +0530 Subject: [PATCH 10/51] Persist merchant rule callout dismissals per expense Signed-off-by: krishna2323 --- src/ONYXKEYS.ts | 5 +++ .../MerchantRuleSuggestionTooltip.tsx | 12 ++--- src/hooks/useMerchantRuleSuggestion.ts | 7 ++- src/libs/ExportOnyxState/common.ts | 1 + src/libs/actions/MerchantRuleSuggestion.ts | 45 +++++++++++++------ src/types/onyx/MerchantRuleSuggestion.ts | 7 +-- 6 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index ae3cc89bf3af..4b96cb88e00f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -243,6 +243,10 @@ const ONYXKEYS = { */ NVP_DISMISSED_ASAP_SUBMIT_EXPLANATION: 'nvp_dismissedASAPSubmitExplanation', + /** Expenses whose "Create a rule" callout the user dismissed, keyed by transactionID. Dismissal is per expense + * and permanent, so the callout stays gone on that expense across sessions and devices. */ + NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS: 'nvp_dismissedMerchantRuleSuggestions', + /** Whether the user dismissed the empty report confirmation dialog */ NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED: 'nvp_emptyReportsConfirmationDismissed', @@ -1644,6 +1648,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean; [ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION]: boolean; [ONYXKEYS.NVP_DISMISSED_ASAP_SUBMIT_EXPLANATION]: boolean; + [ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS]: Record; [ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED]: boolean; [ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: OnyxTypes.LastPaymentMethod; [ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT]: string; diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx index 2b307308cdcc..080ea458ee4a 100644 --- a/src/components/MerchantRuleSuggestionTooltip.tsx +++ b/src/components/MerchantRuleSuggestionTooltip.tsx @@ -2,7 +2,7 @@ import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {dismissMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; +import {retireMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; import {setDraftMerchantRule} from '@libs/actions/User'; import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -51,14 +51,15 @@ function MerchantRuleSuggestionTooltipInner({reportID, policyID, transactionID, hasBeenShownRef.current = true; }, [shouldShowProductTrainingTooltip]); - // The offer is about the edit the user just made, so it is consumed once they have seen it and moved on. Without - // this it would live in the RAM-only key untouched until dismissed, and re-appear on every return to the expense. + // The offer is about the edit the user just made, so it ends once they have seen it and moved on. Retiring rather + // than dismissing keeps this out of the NVP: leaving the view should not silence the expense for good, only stop + // the callout from re-appearing on every return to it. useEffect( () => () => { if (!hasBeenShownRef.current) { return; } - dismissMerchantRuleSuggestion(); + retireMerchantRuleSuggestion(); }, [], ); @@ -105,10 +106,11 @@ MerchantRuleSuggestionTooltipInner.displayName = 'MerchantRuleSuggestionTooltipI */ function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, children}: MerchantRuleSuggestionTooltipProps) { const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + const [dismissedSuggestions] = useOnyx(ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS); // Nothing is stored for most of a session, so skip the inner component (and its heavy hooks, which subscribe to the // policy, its categories, the transaction and the report's transactions) until an edit is waiting to be offered. - if (!storedSuggestion?.transactionID || storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID)) { + if (!storedSuggestion?.transactionID || storedSuggestion.isRetired || dismissedSuggestions?.[storedSuggestion.transactionID]) { return children; } diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index f808553e3862..a53c2cc5087e 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -39,6 +39,7 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + const [dismissedSuggestions] = useOnyx(ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(storedSuggestion?.transactionID)}`); @@ -52,8 +53,10 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const reportTransactions = useReportTransactions(reportID); const isOneExpenseReportForSuggestion = reportTransactions.length === 1 && reportTransactions.at(0)?.transactionID === storedSuggestion?.transactionID; const isHostingReport = !!reportID && (reportID === storedSuggestion?.reportIDs.at(0) || isOneExpenseReportForSuggestion); - const isDismissed = !!storedSuggestion?.transactionID && !!storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID); - const isForThisExpenseView = !!storedSuggestion && !isDismissed && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); + // Dismissal is permanent and per expense, so it lives in an NVP rather than alongside the session-scoped offer + const isDismissed = !!storedSuggestion?.transactionID && !!dismissedSuggestions?.[storedSuggestion.transactionID]; + const isForThisExpenseView = + !!storedSuggestion && !isDismissed && !storedSuggestion.isRetired && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); // Only workspace admins can create merchant rules, so nobody else should be offered one const canCreateMerchantRule = isPolicyAdmin(policy, currentUserLogin) && canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 2ff07477f7e7..527340aa89ee 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -261,6 +261,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_CHRONOS_TIME_TRACKING, ONYXKEYS.NVP_DISMISSED_ASAP_SUBMIT_EXPLANATION, ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, + ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS, ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS, ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION, diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index dff77cc3d8f2..d97c42900197 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -4,6 +4,8 @@ import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSugg import Onyx from 'react-native-onyx'; +import {setNameValuePair} from './User'; + // The product training registry calls `onHideTooltip` with no arguments, so the dismissal below has to resolve the // expense being offered on its own. This is action-only state, never read during render, so `Onyx.connectWithoutView` // is appropriate. Components read the same key with `useOnyx`. @@ -15,15 +17,25 @@ Onyx.connectWithoutView({ }, }); +// `SetNameValuePair` replaces the whole NVP rather than merging into it, so dismissing one expense has to send the +// expenses dismissed so far alongside it. Read here for the same reason as above. +let dismissedMerchantRuleSuggestions: Record | undefined; +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS, + callback: (value) => { + dismissedMerchantRuleSuggestions = value; + }, +}); + /** * Records that a merchant-rule-governed field was edited on an expense, so the expense detail view can offer to turn * the edit into a merchant rule. Only the most recent edit is kept: the product training context shows one tooltip at * a time, and the offer is always about the edit the user just made. * - * The record is RAM-only, so it never outlives the session. This deliberately does not use `dismissProductTraining` - * like the other product training elements: that NVP dismisses an element account-wide and permanently, while this - * callout is dismissed per expense and is meant to surface again on another expense, or on the same one in a later - * session. + * The record is RAM-only, so which expense is currently offering never outlives the session. Dismissal is separate and + * does persist, in `nvp_dismissedMerchantRuleSuggestions`. This deliberately does not use `dismissProductTraining` like + * the other product training elements: that NVP dismisses an element account-wide, while this callout is dismissed one + * expense at a time and keeps appearing on the others. * * Written for every user; whether the tooltip renders is decided by `useMerchantRuleSuggestion` and the tooltip's * `shouldShow`, which require workspace admin rights. @@ -33,29 +45,36 @@ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: M return; } - // Merged rather than set so the session's dismissals survive: an expense the user has already dismissed must stay - // dismissed even after they edit it again. `isCreatingRule` belongs to the offer being replaced, so it is cleared. + // `isCreatingRule` and `isRetired` describe the offer being replaced, so they are cleared with it Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), field, isCreatingRule: null, + isRetired: null, }); } /** - * Dismisses the "Create a rule" callout for the expense currently offering it, for the rest of the session. Editing a - * different expense still surfaces the callout there, and a new session offers this expense again. + * Dismisses the "Create a rule" callout for the expense currently offering it, permanently and on every device. Other + * expenses are unaffected: editing one of those still surfaces the callout there. */ function dismissMerchantRuleSuggestion() { const transactionID = merchantRuleSuggestion?.transactionID; - if (!transactionID) { + if (!transactionID || dismissedMerchantRuleSuggestions?.[transactionID]) { return; } - Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { - dismissedTransactionIDs: [...new Set([...(merchantRuleSuggestion?.dismissedTransactionIDs ?? []), transactionID])], - }); + setNameValuePair(ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS, {...dismissedMerchantRuleSuggestions, [transactionID]: true}, {...dismissedMerchantRuleSuggestions}); +} + +/** + * Retires the current offer for the session once the user has seen it and moved on, without dismissing the expense. + * The offer is about the edit they just made, so leaving the view ends it — but the expense is not silenced, and a + * later edit on it offers again. + */ +function retireMerchantRuleSuggestion() { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); } /** @@ -69,4 +88,4 @@ function setIsCreatingMerchantRule(isCreatingRule: boolean) { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isCreatingRule}); } -export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, setIsCreatingMerchantRule}; +export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion, setIsCreatingMerchantRule}; diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 45861eefa3dc..a4603f397b5e 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -22,10 +22,11 @@ type MerchantRuleSuggestion = { field: MerchantRuleSuggestionField; /** - * Expenses whose callout the user dismissed this session. Kept alongside the current offer rather than replaced - * with it, so dismissing one expense survives a later edit on any expense, including that same one. + * Whether this offer has already been shown and left behind. Distinct from dismissing the callout, which is + * permanent and lives in the `nvp_dismissedMerchantRuleSuggestions` NVP: this only retires the current offer for + * the session, so navigating away doesn't silence the expense for good. */ - dismissedTransactionIDs?: string[]; + isRetired?: boolean; /** * Whether the user took the offer and is now in the rule creation flow. The rule page reads this to return to the From c00c76aa2cc4c6e2ebb4c6b42100f21b602f607a Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 07:04:06 +0530 Subject: [PATCH 11/51] Scope merchant rule callout dismissals to the session Signed-off-by: krishna2323 --- src/ONYXKEYS.ts | 5 --- .../MerchantRuleSuggestionTooltip.tsx | 12 +++-- src/hooks/useMerchantRuleSuggestion.ts | 7 +-- src/libs/ExportOnyxState/common.ts | 1 - src/libs/actions/MerchantRuleSuggestion.ts | 45 ++++++------------- src/types/onyx/MerchantRuleSuggestion.ts | 7 ++- 6 files changed, 23 insertions(+), 54 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 4b96cb88e00f..ae3cc89bf3af 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -243,10 +243,6 @@ const ONYXKEYS = { */ NVP_DISMISSED_ASAP_SUBMIT_EXPLANATION: 'nvp_dismissedASAPSubmitExplanation', - /** Expenses whose "Create a rule" callout the user dismissed, keyed by transactionID. Dismissal is per expense - * and permanent, so the callout stays gone on that expense across sessions and devices. */ - NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS: 'nvp_dismissedMerchantRuleSuggestions', - /** Whether the user dismissed the empty report confirmation dialog */ NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED: 'nvp_emptyReportsConfirmationDismissed', @@ -1648,7 +1644,6 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean; [ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION]: boolean; [ONYXKEYS.NVP_DISMISSED_ASAP_SUBMIT_EXPLANATION]: boolean; - [ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS]: Record; [ONYXKEYS.NVP_EMPTY_REPORTS_CONFIRMATION_DISMISSED]: boolean; [ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: OnyxTypes.LastPaymentMethod; [ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT]: string; diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx index 080ea458ee4a..2b307308cdcc 100644 --- a/src/components/MerchantRuleSuggestionTooltip.tsx +++ b/src/components/MerchantRuleSuggestionTooltip.tsx @@ -2,7 +2,7 @@ import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {retireMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; +import {dismissMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; import {setDraftMerchantRule} from '@libs/actions/User'; import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -51,15 +51,14 @@ function MerchantRuleSuggestionTooltipInner({reportID, policyID, transactionID, hasBeenShownRef.current = true; }, [shouldShowProductTrainingTooltip]); - // The offer is about the edit the user just made, so it ends once they have seen it and moved on. Retiring rather - // than dismissing keeps this out of the NVP: leaving the view should not silence the expense for good, only stop - // the callout from re-appearing on every return to it. + // The offer is about the edit the user just made, so it is consumed once they have seen it and moved on. Without + // this it would live in the RAM-only key untouched until dismissed, and re-appear on every return to the expense. useEffect( () => () => { if (!hasBeenShownRef.current) { return; } - retireMerchantRuleSuggestion(); + dismissMerchantRuleSuggestion(); }, [], ); @@ -106,11 +105,10 @@ MerchantRuleSuggestionTooltipInner.displayName = 'MerchantRuleSuggestionTooltipI */ function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, children}: MerchantRuleSuggestionTooltipProps) { const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); - const [dismissedSuggestions] = useOnyx(ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS); // Nothing is stored for most of a session, so skip the inner component (and its heavy hooks, which subscribe to the // policy, its categories, the transaction and the report's transactions) until an edit is waiting to be offered. - if (!storedSuggestion?.transactionID || storedSuggestion.isRetired || dismissedSuggestions?.[storedSuggestion.transactionID]) { + if (!storedSuggestion?.transactionID || storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID)) { return children; } diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index a53c2cc5087e..f808553e3862 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -39,7 +39,6 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); - const [dismissedSuggestions] = useOnyx(ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(storedSuggestion?.transactionID)}`); @@ -53,10 +52,8 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const reportTransactions = useReportTransactions(reportID); const isOneExpenseReportForSuggestion = reportTransactions.length === 1 && reportTransactions.at(0)?.transactionID === storedSuggestion?.transactionID; const isHostingReport = !!reportID && (reportID === storedSuggestion?.reportIDs.at(0) || isOneExpenseReportForSuggestion); - // Dismissal is permanent and per expense, so it lives in an NVP rather than alongside the session-scoped offer - const isDismissed = !!storedSuggestion?.transactionID && !!dismissedSuggestions?.[storedSuggestion.transactionID]; - const isForThisExpenseView = - !!storedSuggestion && !isDismissed && !storedSuggestion.isRetired && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); + const isDismissed = !!storedSuggestion?.transactionID && !!storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID); + const isForThisExpenseView = !!storedSuggestion && !isDismissed && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); // Only workspace admins can create merchant rules, so nobody else should be offered one const canCreateMerchantRule = isPolicyAdmin(policy, currentUserLogin) && canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 527340aa89ee..2ff07477f7e7 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -261,7 +261,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_CHRONOS_TIME_TRACKING, ONYXKEYS.NVP_DISMISSED_ASAP_SUBMIT_EXPLANATION, ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, - ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS, ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS, ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION, diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index d97c42900197..dff77cc3d8f2 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -4,8 +4,6 @@ import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSugg import Onyx from 'react-native-onyx'; -import {setNameValuePair} from './User'; - // The product training registry calls `onHideTooltip` with no arguments, so the dismissal below has to resolve the // expense being offered on its own. This is action-only state, never read during render, so `Onyx.connectWithoutView` // is appropriate. Components read the same key with `useOnyx`. @@ -17,25 +15,15 @@ Onyx.connectWithoutView({ }, }); -// `SetNameValuePair` replaces the whole NVP rather than merging into it, so dismissing one expense has to send the -// expenses dismissed so far alongside it. Read here for the same reason as above. -let dismissedMerchantRuleSuggestions: Record | undefined; -Onyx.connectWithoutView({ - key: ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS, - callback: (value) => { - dismissedMerchantRuleSuggestions = value; - }, -}); - /** * Records that a merchant-rule-governed field was edited on an expense, so the expense detail view can offer to turn * the edit into a merchant rule. Only the most recent edit is kept: the product training context shows one tooltip at * a time, and the offer is always about the edit the user just made. * - * The record is RAM-only, so which expense is currently offering never outlives the session. Dismissal is separate and - * does persist, in `nvp_dismissedMerchantRuleSuggestions`. This deliberately does not use `dismissProductTraining` like - * the other product training elements: that NVP dismisses an element account-wide, while this callout is dismissed one - * expense at a time and keeps appearing on the others. + * The record is RAM-only, so it never outlives the session. This deliberately does not use `dismissProductTraining` + * like the other product training elements: that NVP dismisses an element account-wide and permanently, while this + * callout is dismissed per expense and is meant to surface again on another expense, or on the same one in a later + * session. * * Written for every user; whether the tooltip renders is decided by `useMerchantRuleSuggestion` and the tooltip's * `shouldShow`, which require workspace admin rights. @@ -45,36 +33,29 @@ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: M return; } - // `isCreatingRule` and `isRetired` describe the offer being replaced, so they are cleared with it + // Merged rather than set so the session's dismissals survive: an expense the user has already dismissed must stay + // dismissed even after they edit it again. `isCreatingRule` belongs to the offer being replaced, so it is cleared. Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), field, isCreatingRule: null, - isRetired: null, }); } /** - * Dismisses the "Create a rule" callout for the expense currently offering it, permanently and on every device. Other - * expenses are unaffected: editing one of those still surfaces the callout there. + * Dismisses the "Create a rule" callout for the expense currently offering it, for the rest of the session. Editing a + * different expense still surfaces the callout there, and a new session offers this expense again. */ function dismissMerchantRuleSuggestion() { const transactionID = merchantRuleSuggestion?.transactionID; - if (!transactionID || dismissedMerchantRuleSuggestions?.[transactionID]) { + if (!transactionID) { return; } - setNameValuePair(ONYXKEYS.NVP_DISMISSED_MERCHANT_RULE_SUGGESTIONS, {...dismissedMerchantRuleSuggestions, [transactionID]: true}, {...dismissedMerchantRuleSuggestions}); -} - -/** - * Retires the current offer for the session once the user has seen it and moved on, without dismissing the expense. - * The offer is about the edit they just made, so leaving the view ends it — but the expense is not silenced, and a - * later edit on it offers again. - */ -function retireMerchantRuleSuggestion() { - Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { + dismissedTransactionIDs: [...new Set([...(merchantRuleSuggestion?.dismissedTransactionIDs ?? []), transactionID])], + }); } /** @@ -88,4 +69,4 @@ function setIsCreatingMerchantRule(isCreatingRule: boolean) { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isCreatingRule}); } -export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion, setIsCreatingMerchantRule}; +export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, setIsCreatingMerchantRule}; diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index a4603f397b5e..45861eefa3dc 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -22,11 +22,10 @@ type MerchantRuleSuggestion = { field: MerchantRuleSuggestionField; /** - * Whether this offer has already been shown and left behind. Distinct from dismissing the callout, which is - * permanent and lives in the `nvp_dismissedMerchantRuleSuggestions` NVP: this only retires the current offer for - * the session, so navigating away doesn't silence the expense for good. + * Expenses whose callout the user dismissed this session. Kept alongside the current offer rather than replaced + * with it, so dismissing one expense survives a later edit on any expense, including that same one. */ - isRetired?: boolean; + dismissedTransactionIDs?: string[]; /** * Whether the user took the offer and is now in the rule creation flow. The rule page reads this to return to the From dd087016d1e8f47f37df7af2a88d50d4635d1240 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 07:31:41 +0530 Subject: [PATCH 12/51] Render the merchant rule prompt as a pinned banner Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 - src/ONYXKEYS.ts | 2 +- .../BaseHTMLEngineProvider.tsx | 6 - .../MerchantRuleSuggestionBanner.tsx | 151 ++++++++++++++++++ .../MerchantRuleSuggestionTooltip.tsx | 128 --------------- .../ProductTrainingContext/TOOLTIPS.ts | 12 +- .../ProductTrainingContext/index.tsx | 11 +- .../ReportActionItem/MoneyRequestView.tsx | 40 ++--- .../BaseGenericTooltip/index.native.tsx | 9 +- .../Tooltip/BaseGenericTooltip/index.tsx | 9 +- .../Tooltip/BaseGenericTooltip/types.ts | 1 - src/components/Tooltip/GenericTooltip.tsx | 2 - src/components/Tooltip/types.ts | 3 - src/hooks/useMerchantRuleSuggestion.ts | 7 +- src/languages/en.ts | 3 +- src/libs/actions/MerchantRuleSuggestion.ts | 21 ++- src/pages/inbox/report/ReportActionsList.tsx | 11 ++ src/pages/inbox/report/ReportFooter.tsx | 28 ++-- src/styles/index.ts | 23 ++- src/types/onyx/DismissedProductTraining.ts | 7 - src/types/onyx/MerchantRuleSuggestion.ts | 6 +- 21 files changed, 233 insertions(+), 248 deletions(-) create mode 100644 src/components/MerchantRuleSuggestionBanner.tsx delete mode 100644 src/components/MerchantRuleSuggestionTooltip.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d8b32ec1c117..3e089089c351 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8422,7 +8422,6 @@ const CONST = { MILEAGE_RATE_AUTO_UPDATED: 'mileageRateAutoUpdated', REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleReceiptCouplingTooltip', REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleItemizedReceiptCouplingTooltip', - MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion', }, CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal', AGENTS_RULES_BANNER: 'agentsRulesBanner', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index ae3cc89bf3af..2b7cfcd08377 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -682,7 +682,7 @@ const ONYXKEYS = { RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER: 'hasDismissedConciergeNotificationBanner', /** Session-scoped record of the merchant-rule-governed field most recently edited on an expense. - * Drives the "Create a rule" product training tooltip. Registered as RAM-only in `setup/index.ts`. */ + * Drives the "Create a rule" callout. Registered as RAM-only in `setup/index.ts`. */ RAM_ONLY_MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion', NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails', diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 05733659cb3a..452a8a61cbd9 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -154,12 +154,6 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim mixedUAStyles: {whiteSpace: 'pre', ...styles.productTrainingTooltipText}, contentModel: HTMLContentModel.block, }), - // Highlights a phrase inside a product training tooltip, whose background is dark in both themes - 'tooltip-highlight': HTMLElementModel.fromCustomModel({ - tagName: 'tooltip-highlight', - mixedUAStyles: {...styles.productTrainingTooltipHighlightText}, - contentModel: HTMLContentModel.textual, - }), success: HTMLElementModel.fromCustomModel({ tagName: 'success', mixedUAStyles: {...styles.textSuccess}, diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx new file mode 100644 index 000000000000..a0faf554ade9 --- /dev/null +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -0,0 +1,151 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; +import useOnyx from '@hooks/useOnyx'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {dismissMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; +import {setDraftMerchantRule} from '@libs/actions/User'; +import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import variables from '@styles/variables'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +import type {StyleProp, ViewStyle} from 'react-native'; + +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; + +import Banner from './Banner'; +import Icon from './Icon'; +import Text from './Text'; +import TextLink from './TextLink'; + +type MerchantRuleSuggestionBannerProps = { + /** The report hosting the expense detail view: a transaction thread, its expense report, or the chat it lives in */ + reportID: string | undefined; + + /** The workspace the expense belongs to */ + policyID: string | undefined; + + /** The expense being displayed, when the host already knows it */ + transactionID?: string; + + /** Styles for the banner container */ + containerStyles?: StyleProp; + + /** Pins the callout over the top of the scroll area instead of laying it out inline */ + shouldOverlayScrollArea?: boolean; +}; + +function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, containerStyles, shouldOverlayScrollArea}: MerchantRuleSuggestionBannerProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); + const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID, transactionID); + + // The offer is about the edit the user just made, so it ends once they have seen it and moved on. Without this it + // would sit in the RAM-only record until dismissed, and re-appear on every return to the expense. + const hasBeenShownRef = useRef(false); + useEffect(() => { + if (!suggestion) { + return; + } + hasBeenShownRef.current = true; + }, [suggestion]); + useEffect( + () => () => { + if (!hasBeenShownRef.current) { + return; + } + dismissMerchantRuleSuggestion(); + }, + [], + ); + + if (!suggestion || !policyID) { + return null; + } + + const createRule = () => { + const draft = getMerchantRuleDraftFromTransaction(transaction, suggestion.field, policy); + if (!draft) { + return; + } + // Seed the draft the rule editor reads, then open the same flow used from workspace settings + setDraftMerchantRule(draft); + // Tells that flow it was entered from an expense, so saving returns here rather than to the Rules page + setIsCreatingMerchantRule(true); + Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const banner = ( + + + + + + + {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} + + {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} + + + } + /> + ); + + if (!shouldOverlayScrollArea) { + return banner; + } + + return {banner}; +} + +MerchantRuleSuggestionBannerContent.displayName = 'MerchantRuleSuggestionBannerContent'; + +/** + * Offers a workspace admin the chance to turn an expense edit into a merchant rule, right on the expense they just + * edited. Renders nothing unless there is a qualifying edit to act on. + */ +function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, containerStyles, shouldOverlayScrollArea}: MerchantRuleSuggestionBannerProps) { + const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + + // Nothing is stored for most of a session, so skip the inner component (and its heavy hooks, which subscribe to the + // policy, its categories, the transaction and the report's transactions) until an edit is waiting to be offered. + if (!storedSuggestion?.transactionID || storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID)) { + return null; + } + + return ( + + ); +} + +MerchantRuleSuggestionBanner.displayName = 'MerchantRuleSuggestionBanner'; + +export default MerchantRuleSuggestionBanner; diff --git a/src/components/MerchantRuleSuggestionTooltip.tsx b/src/components/MerchantRuleSuggestionTooltip.tsx deleted file mode 100644 index 2b307308cdcc..000000000000 --- a/src/components/MerchantRuleSuggestionTooltip.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {dismissMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; -import {setDraftMerchantRule} from '@libs/actions/User'; -import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; -import Navigation from '@libs/Navigation/Navigation'; - -import variables from '@styles/variables'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; - -import type {ReactElement} from 'react'; - -import React, {useEffect, useRef} from 'react'; -import {View} from 'react-native'; - -import {useProductTrainingContext} from './ProductTrainingContext'; -import EducationalTooltip from './Tooltip/EducationalTooltip'; - -type MerchantRuleSuggestionTooltipProps = { - /** The report hosting the expense detail view: a transaction thread, its expense report, or the chat it lives in */ - reportID: string | undefined; - - /** The workspace the expense belongs to */ - policyID: string | undefined; - - /** The expense being displayed, when the host already knows it */ - transactionID?: string; - - /** The element the tooltip anchors to */ - children: ReactElement; -}; - -function MerchantRuleSuggestionTooltipInner({reportID, policyID, transactionID, children}: MerchantRuleSuggestionTooltipProps) { - const styles = useThemeStyles(); - const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID, transactionID); - const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( - CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION, - !!suggestion, - ); - - const hasBeenShownRef = useRef(false); - useEffect(() => { - if (!shouldShowProductTrainingTooltip) { - return; - } - hasBeenShownRef.current = true; - }, [shouldShowProductTrainingTooltip]); - - // The offer is about the edit the user just made, so it is consumed once they have seen it and moved on. Without - // this it would live in the RAM-only key untouched until dismissed, and re-appear on every return to the expense. - useEffect( - () => () => { - if (!hasBeenShownRef.current) { - return; - } - dismissMerchantRuleSuggestion(); - }, - [], - ); - - const createRule = () => { - if (!suggestion || !policyID) { - return; - } - const draft = getMerchantRuleDraftFromTransaction(transaction, suggestion.field, policy); - if (!draft) { - return; - } - // Seed the draft the rule editor reads, then open the same flow used from workspace settings - setDraftMerchantRule(draft); - // Tells that flow it was entered from an expense, so saving returns here rather than to the Rules page - setIsCreatingMerchantRule(true); - hideProductTrainingTooltip(); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); - }; - - return ( - - {/* The tooltip measures its anchor by cloning this child with an `onLayout` prop, so it has to be a plain - View: the composer and the receipt view don't forward that prop, which would leave it unmeasured. */} - {children} - - ); -} - -MerchantRuleSuggestionTooltipInner.displayName = 'MerchantRuleSuggestionTooltipInner'; - -/** - * Anchors the "Create a rule" product training tooltip to the wrapped element, offering a workspace admin the chance - * to turn the expense edit they just made into a merchant rule. Renders the children untouched when there is no - * qualifying edit to act on. - */ -function MerchantRuleSuggestionTooltip({reportID, policyID, transactionID, children}: MerchantRuleSuggestionTooltipProps) { - const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); - - // Nothing is stored for most of a session, so skip the inner component (and its heavy hooks, which subscribe to the - // policy, its categories, the transaction and the report's transactions) until an edit is waiting to be offered. - if (!storedSuggestion?.transactionID || storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID)) { - return children; - } - - return ( - - {children} - - ); -} - -MerchantRuleSuggestionTooltip.displayName = 'MerchantRuleSuggestionTooltip'; - -export default MerchantRuleSuggestionTooltip; diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts index 378a4497e6ba..05fca9e89df3 100644 --- a/src/components/ProductTrainingContext/TOOLTIPS.ts +++ b/src/components/ProductTrainingContext/TOOLTIPS.ts @@ -1,4 +1,3 @@ -import {dismissMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; import {dismissProductTraining} from '@libs/actions/Welcome'; import CONST from '@src/CONST'; @@ -6,7 +5,7 @@ import type {TranslationPaths} from '@src/languages/types'; import type {ValueOf} from 'type-fest'; -const {CONCIERGE_LHN_GBR, OUTSTANDING_FILTER, ACCOUNT_SWITCHER, SCAN_TEST_DRIVE_CONFIRMATION, GPS_TOOLTIP, HAS_FILTER_NEGATION, MILEAGE_RATE_AUTO_UPDATED, MERCHANT_RULE_SUGGESTION} = +const {CONCIERGE_LHN_GBR, OUTSTANDING_FILTER, ACCOUNT_SWITCHER, SCAN_TEST_DRIVE_CONFIRMATION, GPS_TOOLTIP, HAS_FILTER_NEGATION, MILEAGE_RATE_AUTO_UPDATED} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; type ProductTrainingTooltipName = Exclude< @@ -84,15 +83,6 @@ const TOOLTIPS: Record = { priority: 800, shouldShow: () => true, }, - [MERCHANT_RULE_SUGGESTION]: { - content: 'productTrainingTooltip.merchantRuleSuggestion', - // Unlike the other tooltips this one is dismissed per expense for the session instead of account-wide, so - // that a later edit on another expense (or on the same one in a new session) can offer the rule again. - onHideTooltip: () => dismissMerchantRuleSuggestion(), - name: MERCHANT_RULE_SUGGESTION, - priority: 900, - shouldShow: ({isUserPolicyAdmin}) => isUserPolicyAdmin, - }, }; export default TOOLTIPS; diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx index 9857325532b5..8df0821dfc87 100644 --- a/src/components/ProductTrainingContext/index.tsx +++ b/src/components/ProductTrainingContext/index.tsx @@ -146,14 +146,8 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { } // Mileage rate tooltip is exempt from the general "hide when modal visible" rule so it can show on the confirmation surface. - // The merchant rule tooltip is exempt for the same reason: the expense detail view it anchors to is usually the RHP, - // which sets the modal flag. Both hide when a popover or bottom sheet opens (e.g. inline date picker), - // since those set modal.isPopover. - if ( - (tooltipName === CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED || tooltipName === CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION) && - isModalVisible && - modal?.isPopover - ) { + // Hide it when a popover or bottom sheet opens (e.g. inline date picker) since those set modal.isPopover. + if (tooltipName === CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED && isModalVisible && modal?.isPopover) { return false; } @@ -163,7 +157,6 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.GPS_TOOLTIP && tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.HAS_FILTER_NEGATION && tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MILEAGE_RATE_AUTO_UPDATED && - tooltipName !== CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.MERCHANT_RULE_SUGGESTION && isModalVisible ) { return false; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 81758a52e932..6d34513f9491 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -3,7 +3,6 @@ import HighlightableMenuItemWithTopDescription from '@components/HighlightableMe import Icon from '@components/Icon'; import MenuItemAction from '@components/MenuItem/presets/MenuItemAction'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import MerchantRuleSuggestionTooltip from '@components/MerchantRuleSuggestionTooltip'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails, usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider'; @@ -1159,7 +1158,7 @@ function MoneyRequestView({ // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const {isSmallScreenWidth} = useResponsiveLayout(); const {wideRHPRouteKeys} = useWideRHPState(); const route = useRoute(); // The receipt is hidden only for the MoneyRequestView rendered inside a wide RHP, where the wide RHP's left @@ -1167,44 +1166,23 @@ function MoneyRequestView({ // the RHP) must keep their inline receipt, so the check is scoped to this view's own route key. const isInWideRHP = wideRHPRouteKeys.includes(route.key); - // A "Create a rule" offer only makes sense on a live expense the admin just edited, not in a review or merge flow. - // The tooltip anchors to the receipt only where the receipt renders inline above the fields; on wide layouts and - // in a wide RHP (where the receipt moves to its own panel) the report footer anchors it above the composer instead. - const shouldOfferMerchantRule = shouldUseNarrowLayout && !isInWideRHP && !readonly && !isFromReviewDuplicates && !isFromMergeTransaction; - // If the view is readonly, we don't need the transactionThread dependency if ((!readonly && !transactionThreadReport?.reportID) || !transaction?.transactionID) { return ; } - const receipt = ( - - ); - - // The "Create a rule" tooltip anchors above the receipt on narrow layouts; on wide layouts the report footer - // anchors it above the composer instead, per design. - const receiptView = shouldOfferMerchantRule ? ( - - {receipt} - - ) : ( - receipt - ); - return ( {shouldShowAnimatedBackground && } <> - {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && receiptView} + {(!isInWideRHP || isSmallScreenWidth || isFromReviewDuplicates || isFromMergeTransaction) && ( + + )} {isCustomUnitOutOfPolicy && isPerDiemRequest && ( {}, shouldTeleportPortalToModalLayer = false, isEducationTooltip = false, @@ -143,11 +142,9 @@ function BaseGenericTooltip({ const wrapperChildren = ( <> {content} - {!shouldHidePointer && ( - - - - )} + + + ); diff --git a/src/components/Tooltip/BaseGenericTooltip/index.tsx b/src/components/Tooltip/BaseGenericTooltip/index.tsx index 96f866414cb6..aa76aa9f3fcd 100644 --- a/src/components/Tooltip/BaseGenericTooltip/index.tsx +++ b/src/components/Tooltip/BaseGenericTooltip/index.tsx @@ -43,7 +43,6 @@ function BaseGenericTooltip({ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }, shouldUseOverlay = false, - shouldHidePointer = false, onHideTooltip = () => {}, isEducationTooltip = false, onTooltipPress, @@ -166,11 +165,9 @@ function BaseGenericTooltip({ const wrapperChildren = ( <> {content} - {!shouldHidePointer && ( - - - - )} + + + ); diff --git a/src/components/Tooltip/BaseGenericTooltip/types.ts b/src/components/Tooltip/BaseGenericTooltip/types.ts index e34619d03128..c36c9fe7c133 100644 --- a/src/components/Tooltip/BaseGenericTooltip/types.ts +++ b/src/components/Tooltip/BaseGenericTooltip/types.ts @@ -50,7 +50,6 @@ type BaseGenericTooltipProps = { | 'wrapperStyle' | 'anchorAlignment' | 'shouldUseOverlay' - | 'shouldHidePointer' | 'onTooltipPress' | 'computeHorizontalShiftForNative' >; diff --git a/src/components/Tooltip/GenericTooltip.tsx b/src/components/Tooltip/GenericTooltip.tsx index bc2c41f0f3ee..45c97176c4ea 100644 --- a/src/components/Tooltip/GenericTooltip.tsx +++ b/src/components/Tooltip/GenericTooltip.tsx @@ -42,7 +42,6 @@ function GenericTooltip({ }, shouldForceAnimate = false, shouldUseOverlay: shouldUseOverlayProp = false, - shouldHidePointer = false, shouldTeleportPortalToModalLayer, shouldRender = true, isEducationTooltip = false, @@ -194,7 +193,6 @@ function GenericTooltip({ wrapperStyle={wrapperStyle} anchorAlignment={anchorAlignment} shouldUseOverlay={shouldUseOverlay} - shouldHidePointer={shouldHidePointer} shouldTeleportPortalToModalLayer={shouldTeleportPortalToModalLayer} onHideTooltip={onPressOverlay} onTooltipPress={onTooltipPress} diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index 18761712a750..5f3998ece92a 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -45,9 +45,6 @@ type SharedTooltipProps = { /** Should render a fullscreen transparent overlay */ shouldUseOverlay?: boolean; - /** Whether to hide the pointer that connects the tooltip to its anchor, leaving the tooltip as a detached card */ - shouldHidePointer?: boolean; - /** Whether the tooltip should teleport to the modal layer */ shouldTeleportPortalToModalLayer?: boolean; diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index f808553e3862..9e14eac7e1ae 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -13,7 +13,7 @@ import usePolicyFeatureWriteAccess from './usePolicyFeatureWriteAccess'; import useReportTransactions from './useReportTransactions'; type MerchantRuleSuggestionResult = { - /** The edit that can be turned into a merchant rule, or undefined when no tooltip should render */ + /** The edit that can be turned into a merchant rule, or undefined when no callout should render */ suggestion: MerchantRuleSuggestion | undefined; /** The edited expense, needed to pre-seed the rule */ @@ -24,12 +24,9 @@ type MerchantRuleSuggestionResult = { }; /** - * Resolves the "Create a rule" tooltip for an expense detail view: an admin just edited a field that merchant rules + * Resolves the "Create a rule" callout for an expense detail view: an admin just edited a field that merchant rules * can govern, and hasn't dismissed the offer for that expense. * - * Admin rights are also checked by the tooltip's own `shouldShow` in TOOLTIPS.ts, but that check is workspace-agnostic - * (it asks whether the user administers any workspace), so the policy-specific checks live here. - * * @param reportID - the report hosting the expense detail view (a transaction thread, its expense report, or the chat the expense lives in) * @param policyID - the workspace the expense belongs to * @param transactionID - the expense being displayed, when the caller already knows it diff --git a/src/languages/en.ts b/src/languages/en.ts index f87f0c314a82..8c3f2a6733de 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8130,6 +8130,8 @@ const translations = { subtitle: 'Set merchant rules so expenses arrive correctly coded and require less cleanup.', addRule: 'Add merchant rule', findRule: 'Find merchant rule', + createRuleFromExpenseAction: 'Create a rule', + createRuleFromExpensePrompt: 'to apply your changes to all expenses that match your criteria.', addRuleTitle: 'Add rule', editRuleTitle: 'Edit rule', importRulesTitle: 'Import merchant rules', @@ -10803,7 +10805,6 @@ const translations = { gpsTooltip: "GPS tracking in progress! When you're done, stop tracking below.", hasFilterNegation: 'Search for expenses without receipts using -has:receipt.', mileageRateAutoUpdated: 'We updated the rate based on your travel date.', - merchantRuleSuggestion: 'Create a rule to apply your changes to all expenses that match your criteria.', }, discardChangesConfirmation: { title: 'Discard changes?', diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index dff77cc3d8f2..d3eed6a69f95 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -4,9 +4,9 @@ import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSugg import Onyx from 'react-native-onyx'; -// The product training registry calls `onHideTooltip` with no arguments, so the dismissal below has to resolve the -// expense being offered on its own. This is action-only state, never read during render, so `Onyx.connectWithoutView` -// is appropriate. Components read the same key with `useOnyx`. +// Dismissing appends to the expenses already dismissed, so it needs the current record. Reading it here rather than +// passing it in keeps callers from having to thread the whole record through just to dismiss. This is action-only +// state, never read during render, so `Onyx.connectWithoutView` is appropriate; components use `useOnyx`. let merchantRuleSuggestion: MerchantRuleSuggestion | undefined; Onyx.connectWithoutView({ key: ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, @@ -17,16 +17,15 @@ Onyx.connectWithoutView({ /** * Records that a merchant-rule-governed field was edited on an expense, so the expense detail view can offer to turn - * the edit into a merchant rule. Only the most recent edit is kept: the product training context shows one tooltip at - * a time, and the offer is always about the edit the user just made. + * the edit into a merchant rule. Only the most recent edit is kept: one callout shows at a time, and the offer is + * always about the edit the user just made. * - * The record is RAM-only, so it never outlives the session. This deliberately does not use `dismissProductTraining` - * like the other product training elements: that NVP dismisses an element account-wide and permanently, while this - * callout is dismissed per expense and is meant to surface again on another expense, or on the same one in a later - * session. + * The record is RAM-only, so it never outlives the session, dismissals included. That is deliberate: the callout is + * dismissed one expense at a time and is meant to surface again on another expense, or on the same one in a later + * session, which an account-wide NVP dismissal could not express. * - * Written for every user; whether the tooltip renders is decided by `useMerchantRuleSuggestion` and the tooltip's - * `shouldShow`, which require workspace admin rights. + * Written for every user; whether the callout renders is decided by `useMerchantRuleSuggestion`, which requires + * workspace admin rights. */ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: MerchantRuleSuggestionField, reportIDs: Array) { if (!transactionID) { diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a89497202559..cf9574a20f93 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -1,5 +1,6 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; import InvertedFlashList from '@components/FlashList/InvertedFlashList'; +import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useEnvironment from '@hooks/useEnvironment'; @@ -442,6 +443,16 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent return ( <> + {shouldUseNarrowLayout && ( + // Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view + // does not carry it out of sight. On wide layouts the report footer renders it above the composer. + + )} ); - // On narrow layouts the expense detail view anchors this tooltip above its receipt instead, per design - const composerWithMerchantRuleTooltip = - (shouldUseNarrowLayout && !isInWideRHP) || isComposerFullSize ? ( - composer - ) : ( - - {composer} - - ); + // On narrow layouts the expense detail view renders this callout above its receipt instead, per design + const shouldShowMerchantRuleBanner = (!shouldUseNarrowLayout || isInWideRHP) && !isComposerFullSize; + const merchantRuleBanner = shouldShowMerchantRuleBanner && ( + + ); return ( + {merchantRuleBanner} {shouldShowEnableNotificationsBanner ? ( <> - {composerWithMerchantRuleTooltip} + {composer} ) : ( - composerWithMerchantRuleTooltip + composer )} ); diff --git a/src/styles/index.ts b/src/styles/index.ts index 190411079c10..66384f3d5e7e 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4408,7 +4408,28 @@ const staticStyles = (theme: ThemeColors) => flexShrink: 1, }, - productTrainingTooltipHighlightText: { + merchantRuleCalloutContainer: { + backgroundColor: theme.tooltipHighlightBG, + borderRadius: variables.componentBorderRadiusNormal, + }, + + // Pins the callout to the top of the scroll area, the way floatingMessageCounterWrapper does, so scrolling the + // expense detail view does not carry it out of sight. + merchantRuleCalloutOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 100, + }, + + merchantRuleCalloutText: { + fontSize: variables.fontSizeLabel, + color: theme.textReversed, + lineHeight: variables.lineHeightLarge, + }, + + merchantRuleCalloutAction: { color: theme.tooltipLinkText, fontWeight: FontUtils.fontWeight.bold, }, diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts index 4b2ff1c205fa..0967dcf8691a 100644 --- a/src/types/onyx/DismissedProductTraining.ts +++ b/src/types/onyx/DismissedProductTraining.ts @@ -11,7 +11,6 @@ const { MILEAGE_RATE_AUTO_UPDATED, REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP, REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP, - MERCHANT_RULE_SUGGESTION, } = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; /** @@ -101,12 +100,6 @@ type DismissedProductTraining = { * while Receipt is waived, we store the timestamp here. */ [REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP]: DismissedProductTrainingElement; - - /** - * The "Create a rule" callout on an expense is dismissed per expense for the session rather than account-wide, - * so nothing is stored here. The key exists only because the product training registry is typed by this object. - */ - [MERCHANT_RULE_SUGGESTION]?: never; }; export default DismissedProductTraining; diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 45861eefa3dc..5868afc45720 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -8,14 +8,14 @@ type MerchantRuleSuggestionField = ValueOf Date: Tue, 1 Sep 2026 07:35:07 +0530 Subject: [PATCH 13/51] Keep navigating away from re-offering, without silencing the expense Signed-off-by: krishna2323 --- src/components/MerchantRuleSuggestionBanner.tsx | 11 ++++++----- src/hooks/useMerchantRuleSuggestion.ts | 3 ++- src/libs/actions/MerchantRuleSuggestion.ts | 14 ++++++++++++-- src/types/onyx/MerchantRuleSuggestion.ts | 6 ++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index a0faf554ade9..e76232650346 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -5,7 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {dismissMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; +import {dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; import {setDraftMerchantRule} from '@libs/actions/User'; import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -49,8 +49,9 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID, transactionID); - // The offer is about the edit the user just made, so it ends once they have seen it and moved on. Without this it - // would sit in the RAM-only record until dismissed, and re-appear on every return to the expense. + // The offer is about the edit the user just made, so it ends once they have seen it and navigated away. Retiring + // rather than dismissing leaves the expense itself untouched: returning to it shows nothing, but editing it again + // offers afresh. Only the close button silences an expense for the session. const hasBeenShownRef = useRef(false); useEffect(() => { if (!suggestion) { @@ -63,7 +64,7 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, if (!hasBeenShownRef.current) { return; } - dismissMerchantRuleSuggestion(); + retireMerchantRuleSuggestion(); }, [], ); @@ -131,7 +132,7 @@ function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, contai // Nothing is stored for most of a session, so skip the inner component (and its heavy hooks, which subscribe to the // policy, its categories, the transaction and the report's transactions) until an edit is waiting to be offered. - if (!storedSuggestion?.transactionID || storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID)) { + if (!storedSuggestion?.transactionID || storedSuggestion.isRetired || storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID)) { return null; } diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index 9e14eac7e1ae..c6b78eb5987d 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -50,7 +50,8 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const isOneExpenseReportForSuggestion = reportTransactions.length === 1 && reportTransactions.at(0)?.transactionID === storedSuggestion?.transactionID; const isHostingReport = !!reportID && (reportID === storedSuggestion?.reportIDs.at(0) || isOneExpenseReportForSuggestion); const isDismissed = !!storedSuggestion?.transactionID && !!storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID); - const isForThisExpenseView = !!storedSuggestion && !isDismissed && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); + const isForThisExpenseView = + !!storedSuggestion && !isDismissed && !storedSuggestion.isRetired && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); // Only workspace admins can create merchant rules, so nobody else should be offered one const canCreateMerchantRule = isPolicyAdmin(policy, currentUserLogin) && canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index d3eed6a69f95..e166ceaec39e 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -33,12 +33,14 @@ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: M } // Merged rather than set so the session's dismissals survive: an expense the user has already dismissed must stay - // dismissed even after they edit it again. `isCreatingRule` belongs to the offer being replaced, so it is cleared. + // dismissed even after they edit it again. `isCreatingRule` and `isRetired` describe the offer being replaced, so + // they are cleared with it — a fresh edit is a fresh offer, even on an expense the user has already walked away from. Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), field, isCreatingRule: null, + isRetired: null, }); } @@ -57,6 +59,14 @@ function dismissMerchantRuleSuggestion() { }); } +/** + * Ends the current offer once the user has seen it and navigated away, without dismissing the expense. Coming back + * shows nothing, but editing the expense again offers afresh — only the close button silences an expense outright. + */ +function retireMerchantRuleSuggestion() { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); +} + /** * Records that the user took the offer, so the rule page knows to return to the expense once the rule is saved. * @@ -68,4 +78,4 @@ function setIsCreatingMerchantRule(isCreatingRule: boolean) { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isCreatingRule}); } -export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, setIsCreatingMerchantRule}; +export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion, setIsCreatingMerchantRule}; diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 5868afc45720..33de19144733 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -27,6 +27,12 @@ type MerchantRuleSuggestion = { */ dismissedTransactionIDs?: string[]; + /** + * Whether this offer has been shown and left behind. Distinct from dismissing the expense: it ends only the + * current offer, so returning to the expense shows nothing, while editing it again offers afresh. + */ + isRetired?: boolean; + /** * Whether the user took the offer and is now in the rule creation flow. The rule page reads this to return to the * expense after saving instead of to the workspace Rules page, which is where creating a rule from settings ends. From 39c61b4bd5fec517b5980dbe2fe0ada161a1e140 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 07:39:16 +0530 Subject: [PATCH 14/51] Stop the merchant rule callout rendering twice in a wide RHP Signed-off-by: krishna2323 --- src/pages/inbox/report/ReportActionsList.tsx | 26 ++++++++++++-------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index cf9574a20f93..a4eca4038fbd 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -2,6 +2,7 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@compon import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import {useWideRHPState} from '@components/WideRHPContextProvider'; import useEnvironment from '@hooks/useEnvironment'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; @@ -109,6 +110,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isProduction} = useEnvironment(); + const {wideRHPRouteKeys} = useWideRHPState(); const { report, @@ -132,6 +134,8 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent const {isOffline} = useNetwork(); const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; + // A wide RHP reports a narrow layout but lays out like a wide screen, so the report footer owns the callout there + const isInWideRHP = wideRHPRouteKeys.includes(route.key); const {sessionStartTime} = useConciergeSessionState(); const didLayout = useRef(false); @@ -443,16 +447,18 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent return ( <> - {shouldUseNarrowLayout && ( - // Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view - // does not carry it out of sight. On wide layouts the report footer renders it above the composer. - - )} + {shouldUseNarrowLayout && + !isInWideRHP && ( + // Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view + // does not carry it out of sight. On wide layouts the report footer renders it above the composer + // instead — including in a wide RHP, which reports a narrow layout but lays out like a wide screen. + + )} Date: Tue, 1 Sep 2026 07:42:50 +0530 Subject: [PATCH 15/51] Float the composer callout instead of giving it layout height Signed-off-by: krishna2323 --- src/components/MerchantRuleSuggestionBanner.tsx | 14 +++++++------- src/pages/inbox/report/ReportActionsList.tsx | 2 +- src/pages/inbox/report/ReportFooter.tsx | 5 +++-- src/styles/index.ts | 10 ++++++++++ 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index e76232650346..ec101178f26b 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -38,11 +38,11 @@ type MerchantRuleSuggestionBannerProps = { /** Styles for the banner container */ containerStyles?: StyleProp; - /** Pins the callout over the top of the scroll area instead of laying it out inline */ - shouldOverlayScrollArea?: boolean; + /** When set, floats the callout in a wrapper carrying these styles instead of laying it out inline */ + overlayStyles?: StyleProp; }; -function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, containerStyles, shouldOverlayScrollArea}: MerchantRuleSuggestionBannerProps) { +function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, containerStyles, overlayStyles}: MerchantRuleSuggestionBannerProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -114,11 +114,11 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, /> ); - if (!shouldOverlayScrollArea) { + if (!overlayStyles) { return banner; } - return {banner}; + return {banner}; } MerchantRuleSuggestionBannerContent.displayName = 'MerchantRuleSuggestionBannerContent'; @@ -127,7 +127,7 @@ MerchantRuleSuggestionBannerContent.displayName = 'MerchantRuleSuggestionBannerC * Offers a workspace admin the chance to turn an expense edit into a merchant rule, right on the expense they just * edited. Renders nothing unless there is a qualifying edit to act on. */ -function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, containerStyles, shouldOverlayScrollArea}: MerchantRuleSuggestionBannerProps) { +function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, containerStyles, overlayStyles}: MerchantRuleSuggestionBannerProps) { const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); // Nothing is stored for most of a session, so skip the inner component (and its heavy hooks, which subscribe to the @@ -142,7 +142,7 @@ function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, contai policyID={policyID} transactionID={transactionID} containerStyles={containerStyles} - shouldOverlayScrollArea={shouldOverlayScrollArea} + overlayStyles={overlayStyles} /> ); } diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a4eca4038fbd..e88f0e6d9d53 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -456,7 +456,7 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListContent reportID={reportID} policyID={report?.policyID} containerStyles={[styles.mh4, styles.mt2]} - shouldOverlayScrollArea + overlayStyles={styles.merchantRuleCalloutOverlay} /> )} ); return ( diff --git a/src/styles/index.ts b/src/styles/index.ts index 66384f3d5e7e..c6ce030bc795 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4423,6 +4423,16 @@ const staticStyles = (theme: ThemeColors) => zIndex: 100, }, + // Floats the callout directly above the composer without giving it height there, so the conversation does not + // jump when it appears. + merchantRuleCalloutComposerOverlay: { + position: 'absolute', + bottom: '100%', + left: 0, + right: 0, + zIndex: 100, + }, + merchantRuleCalloutText: { fontSize: variables.fontSizeLabel, color: theme.textReversed, From 6307b9b9c42064568fca626da3ba604fd86ffab1 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 07:46:38 +0530 Subject: [PATCH 16/51] Compose the merchant rule overlays from the positioning helpers Signed-off-by: krishna2323 --- src/styles/index.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index c6ce030bc795..47b7dfc0e549 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4416,20 +4416,20 @@ const staticStyles = (theme: ThemeColors) => // Pins the callout to the top of the scroll area, the way floatingMessageCounterWrapper does, so scrolling the // expense detail view does not carry it out of sight. merchantRuleCalloutOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, + ...positioning.pAbsolute, + ...positioning.t0, + ...positioning.l0, + ...positioning.r0, zIndex: 100, }, // Floats the callout directly above the composer without giving it height there, so the conversation does not // jump when it appears. merchantRuleCalloutComposerOverlay: { - position: 'absolute', - bottom: '100%', - left: 0, - right: 0, + ...positioning.pAbsolute, + ...positioning.bFull, + ...positioning.l0, + ...positioning.r0, zIndex: 100, }, From ccba86236199057efed8146339fddf041363d585 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 07:52:11 +0530 Subject: [PATCH 17/51] Retire the merchant rule callout when the offer is taken Signed-off-by: krishna2323 --- src/components/MerchantRuleSuggestionBanner.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index ec101178f26b..ace650aa800b 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -82,6 +82,9 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, setDraftMerchantRule(draft); // Tells that flow it was entered from an expense, so saving returns here rather than to the Rules page setIsCreatingMerchantRule(true); + // The offer has been taken, so it is over: coming back from the rule flow — saved or abandoned — must not find + // the callout still asking. Editing the expense again is what starts a fresh offer. + retireMerchantRuleSuggestion(); Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); }; @@ -100,7 +103,7 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, height={variables.iconSizeNormal} /> - + Date: Tue, 1 Sep 2026 08:01:09 +0530 Subject: [PATCH 18/51] Simplify the merchant rule callout comments and tighten two rule violations Signed-off-by: krishna2323 --- src/CONST/index.ts | 2 +- src/ONYXKEYS.ts | 3 +- .../MerchantRuleSuggestionBanner.tsx | 13 +++---- src/hooks/useMerchantRuleSuggestion.ts | 18 ++++----- src/libs/MerchantRuleSuggestionUtils.ts | 10 ++--- src/libs/actions/MerchantRuleSuggestion.ts | 39 +++++++++---------- src/pages/inbox/report/ReportActionsList.tsx | 5 +-- src/pages/inbox/report/ReportFooter.tsx | 6 +-- .../MerchantRules/MerchantRulePageBase.tsx | 13 ++++--- src/styles/index.ts | 8 ++-- src/types/onyx/MerchantRuleSuggestion.ts | 12 +++--- 11 files changed, 62 insertions(+), 67 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3965102190d2..b04d704e5edc 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -514,7 +514,7 @@ const CONST = { MERCHANT_NAME_MAX_BYTES: 255, - /** Expense fields a merchant rule can govern, and whose edit surfaces the "Create a rule" callout on the expense */ + /** Expense fields a merchant rule can govern, whose edit shows the "Create a rule" callout on the expense */ MERCHANT_RULE_SUGGESTION_FIELDS: { CATEGORY: 'category', TAG: 'tag', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 6aeb2b15cd94..1a15505791b7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -684,8 +684,7 @@ const ONYXKEYS = { /** Session-scoped flag: user dismissed the "enable notifications" banner in the Concierge chat */ RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER: 'hasDismissedConciergeNotificationBanner', - /** Session-scoped record of the merchant-rule-governed field most recently edited on an expense. - * Drives the "Create a rule" callout. Registered as RAM-only in `setup/index.ts`. */ + /** Session-scoped record of the most recent expense edit that can become a merchant rule, which drives the "Create a rule" callout */ RAM_ONLY_MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion', NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails', diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index ace650aa800b..8f254c3f7442 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -49,9 +49,8 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID, transactionID); - // The offer is about the edit the user just made, so it ends once they have seen it and navigated away. Retiring - // rather than dismissing leaves the expense itself untouched: returning to it shows nothing, but editing it again - // offers afresh. Only the close button silences an expense for the session. + // The offer ends once the user has seen it and navigated away. Retiring leaves the expense itself untouched, so + // editing it again offers afresh. Only the close button silences an expense for the session. const hasBeenShownRef = useRef(false); useEffect(() => { if (!suggestion) { @@ -82,8 +81,8 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, setDraftMerchantRule(draft); // Tells that flow it was entered from an expense, so saving returns here rather than to the Rules page setIsCreatingMerchantRule(true); - // The offer has been taken, so it is over: coming back from the rule flow — saved or abandoned — must not find - // the callout still asking. Editing the expense again is what starts a fresh offer. + // The offer has been taken, so coming back from the rule flow, saved or abandoned, must not find it still + // asking. Editing the expense again starts a fresh offer. retireMerchantRuleSuggestion(); Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); }; @@ -133,8 +132,8 @@ MerchantRuleSuggestionBannerContent.displayName = 'MerchantRuleSuggestionBannerC function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, containerStyles, overlayStyles}: MerchantRuleSuggestionBannerProps) { const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); - // Nothing is stored for most of a session, so skip the inner component (and its heavy hooks, which subscribe to the - // policy, its categories, the transaction and the report's transactions) until an edit is waiting to be offered. + // Nothing is stored for most of a session, so skip the inner component and its many Onyx subscriptions until + // there is an edit to offer. if (!storedSuggestion?.transactionID || storedSuggestion.isRetired || storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID)) { return null; } diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index c6b78eb5987d..dd81127b1ee0 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -24,11 +24,10 @@ type MerchantRuleSuggestionResult = { }; /** - * Resolves the "Create a rule" callout for an expense detail view: an admin just edited a field that merchant rules - * can govern, and hasn't dismissed the offer for that expense. + * Resolves the "Create a rule" callout for an expense detail view: an admin just edited a field merchant rules can + * govern, and hasn't dismissed the offer for that expense. * - * @param reportID - the report hosting the expense detail view (a transaction thread, its expense report, or the chat the expense lives in) - * @param policyID - the workspace the expense belongs to + * @param reportID - the report hosting the expense detail view, either a transaction thread, its expense report, or the chat the expense lives in * @param transactionID - the expense being displayed, when the caller already knows it */ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: string | undefined, transactionID?: string): MerchantRuleSuggestionResult { @@ -41,11 +40,10 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(storedSuggestion?.transactionID)}`); const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); - // The callout belongs on the expense detail view, so a screen hosts it when it renders the expense itself (the - // caller passes the transaction), when it is the expense's own transaction thread, or when it is a report holding - // only that expense — which renders the expense detail view rather than a list. A report listing several expenses - // shows no expense detail, so it is not a host. Asking the report which transactions it holds is authoritative, - // unlike the reports that happened to be in scope during the edit. + // A screen hosts the callout when it shows the expense itself: the caller passed the transaction, or it is the + // expense's own transaction thread, or it is a report holding only that expense. A report listing several + // expenses shows no expense detail. Asking the report what it holds beats trusting the reports that happened to + // be in scope during the edit. const reportTransactions = useReportTransactions(reportID); const isOneExpenseReportForSuggestion = reportTransactions.length === 1 && reportTransactions.at(0)?.transactionID === storedSuggestion?.transactionID; const isHostingReport = !!reportID && (reportID === storedSuggestion?.reportIDs.at(0) || isOneExpenseReportForSuggestion); @@ -56,7 +54,7 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const canCreateMerchantRule = isPolicyAdmin(policy, currentUserLogin) && canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; - // A merchant rule matches on merchant, so an expense without one (e.g. a receipt still scanning) can't seed a rule + // A merchant rule matches on merchant, so an expense without one, like a receipt still scanning, can't seed a rule if (!suggestion || !transaction || isMerchantMissing(transaction)) { return {suggestion: undefined, transaction: undefined, policy: undefined}; } diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts index 514cb45e7986..af3c38626ff1 100644 --- a/src/libs/MerchantRuleSuggestionUtils.ts +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -10,8 +10,8 @@ import {resolveCurrentTaxCode} from './PolicyUtils'; import {getCategory, getDescription, getMerchant, getTag, isMerchantMissing} from './TransactionUtils'; /** - * Builds the merchant-rule draft that pre-seeds the rule creation flow when an admin turns an expense edit into a - * rule. Returns undefined when the expense has no merchant to match on, since a rule cannot be saved without one. + * Builds the draft that pre-seeds the rule creation flow when an admin turns an expense edit into a rule. Returns + * undefined when the expense has no merchant to match on, since a rule cannot be saved without one. */ function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry, field: MerchantRuleSuggestionField, policy: OnyxEntry): Partial | undefined { if (!transaction || isMerchantMissing(transaction)) { @@ -37,8 +37,8 @@ function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry break; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX: { - // The rule form stores the key of the tax in `policy.taxRates.taxes`, which is the same value a - // transaction stores as its taxCode — but the transaction can carry a code that has since been renamed. + // The rule form stores the key of the tax in `policy.taxRates.taxes`, which is what a transaction stores + // as its taxCode. A transaction can still carry a code that has since been renamed, so resolve it first. const taxCode = transaction.taxCode ? resolveCurrentTaxCode(policy, transaction.taxCode) : undefined; if (taxCode && policy?.taxRates?.taxes?.[taxCode]) { draft.tax = taxCode; @@ -71,6 +71,6 @@ function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry } export { - // eslint-disable-next-line import/prefer-default-export -- keeps the file consistent as more merchant-rule suggestion helpers are added + // eslint-disable-next-line import/prefer-default-export -- Utils modules export their helpers by name getMerchantRuleDraftFromTransaction, }; diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index e166ceaec39e..ec387c3ad3c8 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -4,9 +4,8 @@ import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSugg import Onyx from 'react-native-onyx'; -// Dismissing appends to the expenses already dismissed, so it needs the current record. Reading it here rather than -// passing it in keeps callers from having to thread the whole record through just to dismiss. This is action-only -// state, never read during render, so `Onyx.connectWithoutView` is appropriate; components use `useOnyx`. +// Dismissing appends to the expenses already dismissed, so it needs the current record. This is action-only state, +// never read during render, so `Onyx.connectWithoutView` is appropriate. Components use `useOnyx`. let merchantRuleSuggestion: MerchantRuleSuggestion | undefined; Onyx.connectWithoutView({ key: ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, @@ -16,25 +15,23 @@ Onyx.connectWithoutView({ }); /** - * Records that a merchant-rule-governed field was edited on an expense, so the expense detail view can offer to turn - * the edit into a merchant rule. Only the most recent edit is kept: one callout shows at a time, and the offer is - * always about the edit the user just made. + * Records an edit to a field merchant rules can govern, so the expense detail view can offer to turn it into a rule. + * Only the most recent edit is kept, since one callout shows at a time. * - * The record is RAM-only, so it never outlives the session, dismissals included. That is deliberate: the callout is - * dismissed one expense at a time and is meant to surface again on another expense, or on the same one in a later - * session, which an account-wide NVP dismissal could not express. + * The record is RAM-only, so nothing outlives the session, dismissals included. That is deliberate: the callout is + * dismissed one expense at a time and should appear again on that expense in a later session, which an account-wide + * NVP could not express. * - * Written for every user; whether the callout renders is decided by `useMerchantRuleSuggestion`, which requires - * workspace admin rights. + * Written for every user. `useMerchantRuleSuggestion` decides whether the callout renders, which needs admin rights. */ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: MerchantRuleSuggestionField, reportIDs: Array) { if (!transactionID) { return; } - // Merged rather than set so the session's dismissals survive: an expense the user has already dismissed must stay - // dismissed even after they edit it again. `isCreatingRule` and `isRetired` describe the offer being replaced, so - // they are cleared with it — a fresh edit is a fresh offer, even on an expense the user has already walked away from. + // Merged rather than set so this session's dismissals survive an edit to an already dismissed expense. + // `isCreatingRule` and `isRetired` describe the offer being replaced, so they are cleared with it. A fresh edit + // is a fresh offer, even on an expense the user walked away from. Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), @@ -45,8 +42,8 @@ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: M } /** - * Dismisses the "Create a rule" callout for the expense currently offering it, for the rest of the session. Editing a - * different expense still surfaces the callout there, and a new session offers this expense again. + * Dismisses the callout for the expense currently offering it, for the rest of the session. Editing a different + * expense still shows the callout there, and a new session offers this expense again. */ function dismissMerchantRuleSuggestion() { const transactionID = merchantRuleSuggestion?.transactionID; @@ -60,19 +57,19 @@ function dismissMerchantRuleSuggestion() { } /** - * Ends the current offer once the user has seen it and navigated away, without dismissing the expense. Coming back - * shows nothing, but editing the expense again offers afresh — only the close button silences an expense outright. + * Ends the current offer once the user has seen it, without dismissing the expense. Coming back shows nothing, but + * editing the expense again offers afresh. Only the close button silences an expense outright. */ function retireMerchantRuleSuggestion() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); } /** - * Records that the user took the offer, so the rule page knows to return to the expense once the rule is saved. + * Records that the user took the offer, so the rule page returns to the expense once the rule is saved. * * The `backTo` URL param is deprecated (see the `How to remove backTo from URL` section in NAVIGATION.md), so the - * origin travels through this record instead. It is session-scoped like the rest of the record, which means a page - * refresh mid-flow falls back to the workspace Rules page — acceptable, since a refresh discards the offer anyway. + * origin travels through this record instead. A page refresh mid-flow then falls back to the workspace Rules page, + * which is fine because a refresh discards the offer anyway. */ function setIsCreatingMerchantRule(isCreatingRule: boolean) { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isCreatingRule}); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 839d1054fb66..a2dc8f07a664 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -453,9 +453,8 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct <> {shouldUseNarrowLayout && !isInWideRHP && ( - // Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view - // does not carry it out of sight. On wide layouts the report footer renders it above the composer - // instead — including in a wide RHP, which reports a narrow layout but lays out like a wide screen. + // Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail + // view does not carry it out of sight. Wide layouts render it above the composer instead. ); - // On narrow layouts the expense detail view renders this callout above its receipt instead, per design + // Narrow layouts pin the callout over the top of the report list instead const shouldShowMerchantRuleBanner = (!shouldUseNarrowLayout || isInWideRHP) && !isComposerFullSize; const merchantRuleBanner = shouldShowMerchantRuleBanner && ( ) => suggestion?.isCreatingRule; + function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, testID}: MerchantRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -127,8 +130,8 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, const getItemIcon = (icon: IconAsset) => (isRulesRevampEnabled ? icon : undefined); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); - const [merchantRuleSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); - const isCreatedFromExpense = !isEditing && !!merchantRuleSuggestion?.isCreatingRule; + const [isCreatingRuleFromExpense] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {selector: isCreatingRuleSelector}); + const isCreatedFromExpense = !isEditing && !!isCreatingRuleFromExpense; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [policyTagsFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const policyTags = useMemo(() => getTagLists(policyTagsFromOnyx) ?? getEmptyArray>(), [policyTagsFromOnyx]); @@ -295,8 +298,8 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, } setPolicyCodingRule(policyID, form, policy, ruleID, shouldUpdateMatchingTransactions); if (isCreatedFromExpense) { - // The rule was created from an expense rather than workspace settings, so pop back to it instead of - // landing on the Rules page. Clear the marker first so a later rule created from settings is unaffected. + // Created from an expense rather than workspace settings, so go back to the expense, not the Rules page. + // Clear the marker so a later rule created from settings is unaffected. setIsCreatingMerchantRule(false); Navigation.goBack(); } else if (!isEditing && isRulesRevampEnabled) { diff --git a/src/styles/index.ts b/src/styles/index.ts index c4d32744f6f3..1b0df32a5fe5 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4413,8 +4413,8 @@ const staticStyles = (theme: ThemeColors) => borderRadius: variables.componentBorderRadiusNormal, }, - // Pins the callout to the top of the scroll area, the way floatingMessageCounterWrapper does, so scrolling the - // expense detail view does not carry it out of sight. + // Pins the callout to the top of the scroll area, like floatingMessageCounterWrapper, so scrolling cannot + // carry it out of sight. merchantRuleCalloutOverlay: { ...positioning.pAbsolute, ...positioning.t0, @@ -4423,8 +4423,8 @@ const staticStyles = (theme: ThemeColors) => zIndex: 100, }, - // Floats the callout directly above the composer without giving it height there, so the conversation does not - // jump when it appears. + // Floats the callout above the composer without taking height there, so the conversation does not jump when + // it appears. merchantRuleCalloutComposerOverlay: { ...positioning.pAbsolute, ...positioning.bFull, diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 33de19144733..00fa7cc3e289 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -22,20 +22,20 @@ type MerchantRuleSuggestion = { field: MerchantRuleSuggestionField; /** - * Expenses whose callout the user dismissed this session. Kept alongside the current offer rather than replaced - * with it, so dismissing one expense survives a later edit on any expense, including that same one. + * Expenses the user dismissed this session. Kept alongside the current offer rather than replaced by it, so a + * dismissal survives a later edit on any expense, including that same one. */ dismissedTransactionIDs?: string[]; /** - * Whether this offer has been shown and left behind. Distinct from dismissing the expense: it ends only the - * current offer, so returning to the expense shows nothing, while editing it again offers afresh. + * Whether the offer has been seen and left behind. Unlike dismissing, it ends only the current offer, so + * returning to the expense shows nothing while editing it again offers afresh. */ isRetired?: boolean; /** - * Whether the user took the offer and is now in the rule creation flow. The rule page reads this to return to the - * expense after saving instead of to the workspace Rules page, which is where creating a rule from settings ends. + * Whether the user took the offer. The rule page reads this to return to the expense after saving, rather than + * to the workspace Rules page where creating a rule from settings ends. */ isCreatingRule?: boolean; }; From 00e0a1b4e279b5d4b354728b8ccdb592a7d7c06c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 08:09:45 +0530 Subject: [PATCH 19/51] Slide the merchant rule callout in and out Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 78 ++++++++++--------- src/pages/inbox/report/ReportFooter.tsx | 1 + 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index 8f254c3f7442..79c21edd1f21 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -19,6 +19,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; +import Animated, {FadeInDown, FadeInUp, FadeOutDown, FadeOutUp} from 'react-native-reanimated'; import Banner from './Banner'; import Icon from './Icon'; @@ -40,9 +41,12 @@ type MerchantRuleSuggestionBannerProps = { /** When set, floats the callout in a wrapper carrying these styles instead of laying it out inline */ overlayStyles?: StyleProp; + + /** Whether the callout is pinned to the bottom of its host, which is the edge it slides in from */ + isAnchoredToBottom?: boolean; }; -function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, containerStyles, overlayStyles}: MerchantRuleSuggestionBannerProps) { +function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -87,40 +91,43 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); }; - const banner = ( - - - - - - - {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} - - {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} - - - } - /> + // The callout appears and disappears in place, so it slides out of the edge it is pinned to rather than popping. + // FloatingMessageCounter springs a parked view instead, which this cannot do because it unmounts when there is + // nothing to offer. + return ( + + + + + + + + {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} + + {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} + + + } + /> + ); - - if (!overlayStyles) { - return banner; - } - - return {banner}; } MerchantRuleSuggestionBannerContent.displayName = 'MerchantRuleSuggestionBannerContent'; @@ -129,7 +136,7 @@ MerchantRuleSuggestionBannerContent.displayName = 'MerchantRuleSuggestionBannerC * Offers a workspace admin the chance to turn an expense edit into a merchant rule, right on the expense they just * edited. Renders nothing unless there is a qualifying edit to act on. */ -function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, containerStyles, overlayStyles}: MerchantRuleSuggestionBannerProps) { +function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); // Nothing is stored for most of a session, so skip the inner component and its many Onyx subscriptions until @@ -145,6 +152,7 @@ function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, contai transactionID={transactionID} containerStyles={containerStyles} overlayStyles={overlayStyles} + isAnchoredToBottom={isAnchoredToBottom} /> ); } diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 009a43fcd64a..fbf645262360 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -120,6 +120,7 @@ function ReportFooter() { policyID={report.policyID} containerStyles={[styles.mh4, styles.mb3]} overlayStyles={styles.merchantRuleCalloutComposerOverlay} + isAnchoredToBottom /> ); return ( From cf4ec20ef737ab080568d2bdbe98dd42b6da3749 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 08:23:02 +0530 Subject: [PATCH 20/51] Stop the merchant rule callout breaking mid-word Signed-off-by: krishna2323 --- src/styles/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles/index.ts b/src/styles/index.ts index 1b0df32a5fe5..902d239b38b5 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4437,6 +4437,7 @@ const staticStyles = (theme: ThemeColors) => fontSize: variables.fontSizeLabel, color: theme.textReversed, lineHeight: variables.lineHeightLarge, + ...wordBreak.breakWord, }, merchantRuleCalloutAction: { From efd5d8791f3a40079d466fac90be4f75d0d59faf Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 08:28:47 +0530 Subject: [PATCH 21/51] Drop the deprecated Onyx.connectWithoutView from the merchant rule actions Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 4 +++- src/libs/actions/MerchantRuleSuggestion.ts | 21 ++++--------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index 79c21edd1f21..a135bf625a3c 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -76,6 +76,8 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, return null; } + const dismiss = () => dismissMerchantRuleSuggestion(suggestion); + const createRule = () => { const draft = getMerchantRuleDraftFromTransaction(transaction, suggestion.field, policy); if (!draft) { @@ -103,7 +105,7 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index ec387c3ad3c8..a3d74dc809d8 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -4,16 +4,6 @@ import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSugg import Onyx from 'react-native-onyx'; -// Dismissing appends to the expenses already dismissed, so it needs the current record. This is action-only state, -// never read during render, so `Onyx.connectWithoutView` is appropriate. Components use `useOnyx`. -let merchantRuleSuggestion: MerchantRuleSuggestion | undefined; -Onyx.connectWithoutView({ - key: ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, - callback: (value) => { - merchantRuleSuggestion = value; - }, -}); - /** * Records an edit to a field merchant rules can govern, so the expense detail view can offer to turn it into a rule. * Only the most recent edit is kept, since one callout shows at a time. @@ -44,15 +34,12 @@ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: M /** * Dismisses the callout for the expense currently offering it, for the rest of the session. Editing a different * expense still shows the callout there, and a new session offers this expense again. + * + * @param suggestion - the offer being dismissed, whose already dismissed expenses this one is appended to */ -function dismissMerchantRuleSuggestion() { - const transactionID = merchantRuleSuggestion?.transactionID; - if (!transactionID) { - return; - } - +function dismissMerchantRuleSuggestion(suggestion: MerchantRuleSuggestion) { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { - dismissedTransactionIDs: [...new Set([...(merchantRuleSuggestion?.dismissedTransactionIDs ?? []), transactionID])], + dismissedTransactionIDs: [...new Set([...(suggestion.dismissedTransactionIDs ?? []), suggestion.transactionID])], }); } From 5d7a035940ee7af75f19cf61bf606044ff846eb5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 08:33:03 +0530 Subject: [PATCH 22/51] Guard the wide RHP lookup against a missing route Signed-off-by: krishna2323 --- src/pages/inbox/report/ReportActionsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index a2dc8f07a664..36c154176738 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -138,7 +138,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; // A wide RHP reports a narrow layout but lays out like a wide screen, so the report footer owns the callout there - const isInWideRHP = wideRHPRouteKeys.includes(route.key); + const isInWideRHP = !!route?.key && wideRHPRouteKeys.includes(route.key); const {sessionStartTime} = useConciergeSessionState(); const didLayout = useRef(false); From 97d84f6efb163b6c9d25efb880b4251601eb56e6 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 22:21:42 +0530 Subject: [PATCH 23/51] update translations. Signed-off-by: krishna2323 --- src/languages/de.ts | 2 ++ src/languages/el.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 2 ++ src/languages/it.ts | 2 ++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 2 ++ src/languages/pl.ts | 2 ++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 2 ++ 10 files changed, 20 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 8fcd1ab96b51..109a83e1cd6a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7988,6 +7988,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und thenApplyFollowingDefaults: 'Wenden Sie dann die folgenden Standardwerte an:', vendorUnavailable: 'Anbieter nicht verfügbar', supplierUnavailable: 'Lieferant nicht verfügbar', + createRuleFromExpenseAction: 'Regel erstellen', + createRuleFromExpensePrompt: 'um Ihre Änderungen auf alle Ausgaben anzuwenden, die Ihren Kriterien entsprechen.', }, categoryRules: { title: 'Kategorienregeln', diff --git a/src/languages/el.ts b/src/languages/el.ts index a050f8f34d1d..aa6cb5d0b07d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8299,6 +8299,8 @@ ${reportName}`, importColumnUpdatedDescription: 'Ενημερωμένη περιγραφή', vendorUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', supplierUnavailable: 'Ο προμηθευτής δεν είναι διαθέσιμος', + createRuleFromExpenseAction: 'Δημιουργία κανόνα', + createRuleFromExpensePrompt: 'για να εφαρμόσετε τις αλλαγές σας σε όλες τις δαπάνες που ταιριάζουν με τα κριτήριά σας.', }, newRule: { title: 'Νέος κανόνας', diff --git a/src/languages/es.ts b/src/languages/es.ts index c60b01d00021..cd270a210765 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7965,6 +7965,8 @@ ${reportName}`, expenseDefaultsSubtitle: 'Actualizar campos sin que quien los envía haga nada', ifAnyExpenseMatches: 'Si algún gasto coincide con:', thenApplyFollowingDefaults: 'Luego aplica las siguientes opciones predeterminadas:', + createRuleFromExpenseAction: 'Crear una norma', + createRuleFromExpensePrompt: 'para aplicar tus cambios a todos los gastos que coincidan con tus criterios.', }, categoryRules: { title: 'Reglas de categoría', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b8fa636fff9a..0bb987ab2358 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8009,6 +8009,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip thenApplyFollowingDefaults: 'Appliquez ensuite les valeurs par défaut suivantes :', vendorUnavailable: 'Fournisseur indisponible', supplierUnavailable: 'Fournisseur indisponible', + createRuleFromExpenseAction: 'Créer une règle', + createRuleFromExpensePrompt: 'pour appliquer vos modifications à toutes les dépenses qui correspondent à vos critères.', }, categoryRules: { title: 'Règles de catégorie', diff --git a/src/languages/it.ts b/src/languages/it.ts index 702d9f807b00..e8943338375d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7947,6 +7947,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo thenApplyFollowingDefaults: 'Quindi applica le seguenti impostazioni predefinite:', vendorUnavailable: 'Fornitore non disponibile', supplierUnavailable: 'Fornitore non disponibile', + createRuleFromExpenseAction: 'Crea una regola', + createRuleFromExpensePrompt: 'per applicare le modifiche a tutte le spese che corrispondono ai tuoi criteri.', }, categoryRules: { title: 'Regole di categoria', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 261b607b9f35..9752cc5970c2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7841,6 +7841,8 @@ ${reportName}`, thenApplyFollowingDefaults: '次に、以下のデフォルトを適用します。', vendorUnavailable: 'ベンダーを利用できません', supplierUnavailable: 'サプライヤーを利用できません', + createRuleFromExpenseAction: 'ルールを作成', + createRuleFromExpensePrompt: '条件に一致するすべての経費に変更を適用します。', }, categoryRules: { title: 'カテゴリルール', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4fc45f5d51cc..2ca0f6493584 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7923,6 +7923,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar thenApplyFollowingDefaults: 'Pas vervolgens de volgende standaardinstellingen toe:', vendorUnavailable: 'Leverancier niet beschikbaar', supplierUnavailable: 'Leverancier niet beschikbaar', + createRuleFromExpenseAction: 'Maak een regel', + createRuleFromExpensePrompt: 'om je wijzigingen toe te passen op alle onkosten die aan je criteria voldoen.', }, categoryRules: { title: 'Categorisatieregels', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 962421636ae6..b31bd4bb3433 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7945,6 +7945,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i thenApplyFollowingDefaults: 'Następnie zastosuj następujące domyślne ustawienia:', vendorUnavailable: 'Dostawca niedostępny', supplierUnavailable: 'Dostawca niedostępny', + createRuleFromExpenseAction: 'Utwórz regułę', + createRuleFromExpensePrompt: 'aby zastosować zmiany do wszystkich wydatków spełniających twoje kryteria.', }, categoryRules: { title: 'Reguły kategorii', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c870e98d0f9a..c6eebc555a8a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7931,6 +7931,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e thenApplyFollowingDefaults: 'Em seguida, aplica os seguintes padrões:', vendorUnavailable: 'Fornecedor indisponível', supplierUnavailable: 'Fornecedor indisponível', + createRuleFromExpenseAction: 'Criar regra', + createRuleFromExpensePrompt: 'para aplicar suas alterações a todas as despesas que correspondem aos seus critérios.', }, categoryRules: { title: 'Regras de categoria', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index bb614baeca66..c4be48b3a936 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7645,6 +7645,8 @@ ${reportName}`, thenApplyFollowingDefaults: '然后应用以下默认设置:', vendorUnavailable: '供应商不可用', supplierUnavailable: '供应商不可用', + createRuleFromExpenseAction: '创建规则', + createRuleFromExpensePrompt: '将您的更改应用于所有符合条件的报销。', }, categoryRules: { title: '类别规则', From 99d98e10344fb2320daad4a8f35a4e023c405edf Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 22:30:47 +0530 Subject: [PATCH 24/51] Match the callout link's size and open it to everyone who can write rules Signed-off-by: krishna2323 --- src/components/MerchantRuleSuggestionBanner.tsx | 6 ++++-- src/hooks/useMerchantRuleSuggestion.ts | 13 ++++++------- src/libs/actions/MerchantRuleSuggestion.ts | 3 ++- src/styles/index.ts | 2 ++ 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index a135bf625a3c..bdd89609934f 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -56,12 +56,14 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, // The offer ends once the user has seen it and navigated away. Retiring leaves the expense itself untouched, so // editing it again offers afresh. Only the close button silences an expense for the session. const hasBeenShownRef = useRef(false); + useEffect(() => { if (!suggestion) { return; } hasBeenShownRef.current = true; }, [suggestion]); + useEffect( () => () => { if (!hasBeenShownRef.current) { @@ -135,8 +137,8 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, MerchantRuleSuggestionBannerContent.displayName = 'MerchantRuleSuggestionBannerContent'; /** - * Offers a workspace admin the chance to turn an expense edit into a merchant rule, right on the expense they just - * edited. Renders nothing unless there is a qualifying edit to act on. + * Offers the chance to turn an expense edit into a merchant rule, right on the expense that was just edited. Renders + * nothing unless there is a qualifying edit to act on. */ function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index dd81127b1ee0..4e7202ad224e 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -1,12 +1,11 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {arePolicyRulesEnabled, isPolicyAdmin} from '@libs/PolicyUtils'; +import {arePolicyRulesEnabled} from '@libs/PolicyUtils'; import {isMerchantMissing} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx'; -import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; import usePolicyFeatureWriteAccess from './usePolicyFeatureWriteAccess'; @@ -24,15 +23,14 @@ type MerchantRuleSuggestionResult = { }; /** - * Resolves the "Create a rule" callout for an expense detail view: an admin just edited a field merchant rules can - * govern, and hasn't dismissed the offer for that expense. + * Resolves the "Create a rule" callout for an expense detail view: someone who can write workspace rules just edited + * a field merchant rules can govern, and hasn't dismissed the offer for that expense. * * @param reportID - the report hosting the expense detail view, either a transaction thread, its expense report, or the chat the expense lives in * @param transactionID - the expense being displayed, when the caller already knows it */ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: string | undefined, transactionID?: string): MerchantRuleSuggestionResult { const {isBetaEnabled} = usePermissions(); - const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -50,8 +48,9 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const isDismissed = !!storedSuggestion?.transactionID && !!storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID); const isForThisExpenseView = !!storedSuggestion && !isDismissed && !storedSuggestion.isRetired && ((!!transactionID && transactionID === storedSuggestion.transactionID) || isHostingReport); - // Only workspace admins can create merchant rules, so nobody else should be offered one - const canCreateMerchantRule = isPolicyAdmin(policy, currentUserLogin) && canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); + // Offer the callout to exactly the people the rule page it opens will let in, which is write access to the Rules + // feature. That is admins today, and also editors, who can already create the same rule from workspace settings. + const canCreateMerchantRule = canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; // A merchant rule matches on merchant, so an expense without one, like a receipt still scanning, can't seed a rule diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index a3d74dc809d8..1b1353d6e29f 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -12,7 +12,8 @@ import Onyx from 'react-native-onyx'; * dismissed one expense at a time and should appear again on that expense in a later session, which an account-wide * NVP could not express. * - * Written for every user. `useMerchantRuleSuggestion` decides whether the callout renders, which needs admin rights. + * Written for every user. `useMerchantRuleSuggestion` decides whether the callout renders, which needs write access + * to the workspace's Rules feature. */ function trackMerchantRuleSuggestion(transactionID: string | undefined, field: MerchantRuleSuggestionField, reportIDs: Array) { if (!transactionID) { diff --git a/src/styles/index.ts b/src/styles/index.ts index 902d239b38b5..6d141aeb69f2 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4443,6 +4443,8 @@ const staticStyles = (theme: ThemeColors) => merchantRuleCalloutAction: { color: theme.tooltipLinkText, fontWeight: FontUtils.fontWeight.bold, + fontSize: variables.fontSizeLabel, + lineHeight: variables.lineHeightLarge, }, quickReactionsContainer: { From 98be3f1f4cdd257e4e8d45fbdf6485e58fffa14e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 23:22:34 +0530 Subject: [PATCH 25/51] Only record a merchant rule offer where a rule could exist Signed-off-by: krishna2323 --- src/libs/actions/IOU/UpdateMoneyRequest.ts | 12 +++++------ src/libs/actions/MerchantRuleSuggestion.ts | 24 +++++++++++++++++----- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index faa238974a8f..c2483ee72ceb 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -409,7 +409,7 @@ function updateMoneyRequestBillable({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, [transactionThreadReport.reportID, parentReport?.reportID]); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, [transactionThreadReport.reportID, parentReport?.reportID], policy, policyCategories); } function updateMoneyRequestReimbursable({ @@ -475,7 +475,7 @@ function updateMoneyRequestReimbursable({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, [transactionThreadReport.reportID, parentReport?.reportID]); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, [transactionThreadReport.reportID, parentReport?.reportID], policy, policyCategories); } /** Updates the merchant field of an expense */ @@ -887,7 +887,7 @@ function updateMoneyRequestTag({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, [transactionThreadReport?.reportID, parentReport?.reportID]); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, [transactionThreadReport?.reportID, parentReport?.reportID], policy, policyCategories); } /** Updates the created tax amount of an expense */ @@ -1017,7 +1017,7 @@ function updateMoneyRequestTaxRate({ }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, [transactionThreadReport?.reportID, parentReport?.reportID]); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, [transactionThreadReport?.reportID, parentReport?.reportID], policy, policyCategories); } type UpdateMoneyRequestDistanceParams = { @@ -1253,7 +1253,7 @@ function updateMoneyRequestCategory({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, [transactionThreadReport?.reportID, parentReport?.reportID]); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, [transactionThreadReport?.reportID, parentReport?.reportID], policy, policyCategories); } /** Updates the description of an expense */ @@ -1338,7 +1338,7 @@ function updateMoneyRequestDescription({ const {params, onyxData} = data; params.description = parsedComment; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, [transactionThreadReport?.reportID, parentReport?.reportID]); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, [transactionThreadReport?.reportID, parentReport?.reportID], policy, policyCategories); } /** Updates the distance rate of an expense */ diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index 1b1353d6e29f..bfbcd94e30b3 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -1,7 +1,11 @@ +import {arePolicyRulesEnabled} from '@libs/PolicyUtils'; + import ONYXKEYS from '@src/ONYXKEYS'; -import type {MerchantRuleSuggestion} from '@src/types/onyx'; +import type {MerchantRuleSuggestion, Policy, PolicyCategories} from '@src/types/onyx'; import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; +import type {OnyxEntry} from 'react-native-onyx'; + import Onyx from 'react-native-onyx'; /** @@ -12,11 +16,21 @@ import Onyx from 'react-native-onyx'; * dismissed one expense at a time and should appear again on that expense in a later session, which an account-wide * NVP could not express. * - * Written for every user. `useMerchantRuleSuggestion` decides whether the callout renders, which needs write access - * to the workspace's Rules feature. + * Written for every member of a workspace that could carry a merchant rule. `useMerchantRuleSuggestion` decides + * whether the callout renders, which additionally needs write access to the Rules feature. */ -function trackMerchantRuleSuggestion(transactionID: string | undefined, field: MerchantRuleSuggestionField, reportIDs: Array) { - if (!transactionID) { +function trackMerchantRuleSuggestion( + transactionID: string | undefined, + field: MerchantRuleSuggestionField, + reportIDs: Array, + policy: OnyxEntry, + policyCategories: OnyxEntry, +) { + // An offer only makes sense if the workspace could hold a merchant rule when the edit was made. Recording one + // regardless would leave an edit made with Rules switched off sitting here, ready to surface the moment somebody + // switched Rules on. The beta is assumed enabled because it cannot be read outside a component, which only makes + // this more permissive than the callout itself: `useMerchantRuleSuggestion` checks it for real before rendering. + if (!transactionID || !arePolicyRulesEnabled(policy, policyCategories, true)) { return; } From ff6842ebba5607a408c67126bd9d3c8f7ab33e73 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 1 Sep 2026 23:51:41 +0530 Subject: [PATCH 26/51] Open the merchant rule flow as a suffix on the expense that started it Signed-off-by: krishna2323 --- src/ROUTES.ts | 51 ++++++++++++ src/SCREENS.ts | 12 +++ .../MerchantRuleSuggestionBanner.tsx | 13 +-- .../ModalStackNavigators/index.tsx | 12 +++ src/libs/Navigation/linkingConfig/config.ts | 12 +++ src/libs/Navigation/types.ts | 49 ++++++++++++ src/libs/actions/MerchantRuleSuggestion.ts | 18 +---- .../rules/MerchantRules/AddBillablePage.tsx | 11 +-- .../rules/MerchantRules/AddCategoryPage.tsx | 10 +-- .../MerchantRules/AddDescriptionPage.tsx | 14 ++-- .../rules/MerchantRules/AddMatchTypePage.tsx | 14 +++- .../rules/MerchantRules/AddMerchantPage.tsx | 11 +-- .../MerchantRules/AddMerchantRulePage.tsx | 2 +- .../MerchantRules/AddMerchantToMatchPage.tsx | 20 +++-- .../MerchantRules/AddReimbursablePage.tsx | 14 ++-- .../rules/MerchantRules/AddTagPage.tsx | 10 +-- .../rules/MerchantRules/AddTaxPage.tsx | 10 +-- .../rules/MerchantRules/AddVendorPage.tsx | 10 +-- .../MerchantRules/MerchantRulePageBase.tsx | 53 +++++++------ .../MerchantRules/PreviewMatchesPage.tsx | 18 ++--- .../MerchantRules/useMerchantRuleRoute.ts | 79 +++++++++++++++++++ src/types/onyx/MerchantRuleSuggestion.ts | 6 -- 22 files changed, 340 insertions(+), 109 deletions(-) create mode 100644 src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 6f7477ad160e..910414b954e1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1023,6 +1023,57 @@ const DYNAMIC_ROUTES = { path: 'rules/require-fields', entryScreens: [SCREENS.WORKSPACE.DYNAMIC_CATEGORY_SETTINGS, SCREENS.SETTINGS_CATEGORIES.DYNAMIC_SETTINGS_CATEGORY_SETTINGS], }, + RULES_MERCHANT_NEW_FROM_EXPENSE: { + path: 'merchant-rule/new', + entryScreens: [SCREENS.REPORT, SCREENS.RIGHT_MODAL.SEARCH_REPORT, SCREENS.RIGHT_MODAL.EXPENSE_REPORT, SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT], + getRoute: (policyID: string) => getUrlWithParams('merchant-rule/new', {policyID}), + queryParams: ['policyID'], + }, + RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE: { + path: 'rule-merchant-to-match', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE: { + path: 'rule-match-type', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH], + }, + RULES_MERCHANT_MERCHANT_FROM_EXPENSE: { + path: 'rule-merchant', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_CATEGORY_FROM_EXPENSE: { + path: 'rule-category', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_TAG_FROM_EXPENSE: { + path: 'rule-tag/:orderWeight', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + getRoute: (orderWeight: number) => `rule-tag/${orderWeight}` as const, + }, + RULES_MERCHANT_TAX_FROM_EXPENSE: { + path: 'rule-tax', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_VENDOR_FROM_EXPENSE: { + path: 'rule-vendor', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE: { + path: 'rule-description', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE: { + path: 'rule-reimbursable', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_BILLABLE_FROM_EXPENSE: { + path: 'rule-billable', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, + RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE: { + path: 'rule-matches', + entryScreens: [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW], + }, NOTIFICATION_PREFERENCES: { // `reportID` is intentionally carried as a distinct path param (`notificationReportID`) rather than // `reportID`, so it never collides with a `reportID` inherited from the surrounding report chain's diff --git a/src/SCREENS.ts b/src/SCREENS.ts index afdf2162aaff..afad5788bc69 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -953,6 +953,18 @@ const SCREENS = { RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', RULES_MERCHANT_PREVIEW_MATCHES: 'Rules_Merchant_Preview_Matches', RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit', + DYNAMIC_RULES_MERCHANT_NEW: 'Dynamic_Rules_Merchant_New', + DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH: 'Dynamic_Rules_Merchant_Merchant_To_Match', + DYNAMIC_RULES_MERCHANT_MATCH_TYPE: 'Dynamic_Rules_Merchant_Match_Type', + DYNAMIC_RULES_MERCHANT_MERCHANT: 'Dynamic_Rules_Merchant_Merchant', + DYNAMIC_RULES_MERCHANT_CATEGORY: 'Dynamic_Rules_Merchant_Category', + DYNAMIC_RULES_MERCHANT_TAG: 'Dynamic_Rules_Merchant_Tag', + DYNAMIC_RULES_MERCHANT_TAX: 'Dynamic_Rules_Merchant_Tax', + DYNAMIC_RULES_MERCHANT_VENDOR: 'Dynamic_Rules_Merchant_Vendor', + DYNAMIC_RULES_MERCHANT_DESCRIPTION: 'Dynamic_Rules_Merchant_Description', + DYNAMIC_RULES_MERCHANT_REIMBURSABLE: 'Dynamic_Rules_Merchant_Reimbursable', + DYNAMIC_RULES_MERCHANT_BILLABLE: 'Dynamic_Rules_Merchant_Billable', + DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES: 'Dynamic_Rules_Merchant_Preview_Matches', RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants', RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit', RULES_AGENT_NEW: 'Rules_Agent_New', diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index bdd89609934f..6ea44c5c74a7 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -5,15 +5,16 @@ import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion, setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; +import {dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; import {setDraftMerchantRule} from '@libs/actions/User'; import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -85,14 +86,14 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, if (!draft) { return; } - // Seed the draft the rule editor reads, then open the same flow used from workspace settings + // Seed the draft the rule editor reads, then open the same flow used from workspace settings. Opening it as a + // suffix on the expense's own path keeps the expense underneath the modal, so the flow returns here rather + // than to the workspace Rules page. setDraftMerchantRule(draft); - // Tells that flow it was entered from an expense, so saving returns here rather than to the Rules page - setIsCreatingMerchantRule(true); // The offer has been taken, so coming back from the rule flow, saved or abandoned, must not find it still // asking. Editing the expense again starts a fresh offer. retireMerchantRuleSuggestion(); - Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.getRoute(policyID))); }; // The callout appears and disappears in place, so it slides out of the edge it is pinned to rather than popping. diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index b7dd67e9292f..288a07c00c94 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1129,6 +1129,18 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES]: () => require('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_EDIT]: () => require('../../../../pages/workspace/rules/MerchantRules/EditMerchantRulePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantToMatchPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMatchTypePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_CATEGORY]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTaxPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_VENDOR]: () => require('../../../../pages/workspace/rules/MerchantRules/AddVendorPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION]: () => require('../../../../pages/workspace/rules/MerchantRules/AddDescriptionPage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: () => require('../../../../pages/workspace/rules/MerchantRules/PreviewMatchesPage').default, [SCREENS.WORKSPACE.RULES_AGENT_NEW]: () => require('../../../../pages/workspace/rules/AgentRules/AddAgentRulePage').default, [SCREENS.WORKSPACE.RULES_AGENT_EDIT]: () => require('../../../../pages/workspace/rules/AgentRules/EditAgentRulePage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: () => require('../../../../pages/workspace/perDiem/ImportPerDiemPage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index b8c7fcb86f93..35e859435cc9 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1280,6 +1280,18 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { path: ROUTES.RULES_MERCHANT_NEW.route, }, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW]: DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH]: DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE]: DYNAMIC_ROUTES.RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT]: DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_CATEGORY]: DYNAMIC_ROUTES.RULES_MERCHANT_CATEGORY_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG]: DYNAMIC_ROUTES.RULES_MERCHANT_TAG_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX]: DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_VENDOR]: DYNAMIC_ROUTES.RULES_MERCHANT_VENDOR_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION]: DYNAMIC_ROUTES.RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: DYNAMIC_ROUTES.RULES_MERCHANT_BILLABLE_FROM_EXPENSE.path, + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: DYNAMIC_ROUTES.RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE.path, [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { path: ROUTES.RULES_MERCHANT_IMPORT.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 1f3c3107497a..ce289419c802 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1655,6 +1655,55 @@ type SettingsNavigatorParamList = { policyID: string; categoryName?: string; }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW]: { + policyID: string; + categoryName?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_CATEGORY]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG]: { + policyID: string; + orderWeight: number; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_VENDOR]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE]: { + policyID: string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES]: { + policyID: string; + ruleID?: undefined; + }; [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { policyID: string; }; diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index bfbcd94e30b3..c4b84ad9e5b3 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -35,13 +35,12 @@ function trackMerchantRuleSuggestion( } // Merged rather than set so this session's dismissals survive an edit to an already dismissed expense. - // `isCreatingRule` and `isRetired` describe the offer being replaced, so they are cleared with it. A fresh edit - // is a fresh offer, even on an expense the user walked away from. + // `isRetired` describes the offer being replaced, so it is cleared with it. A fresh edit is a fresh offer, even + // on an expense the user walked away from. Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), field, - isCreatingRule: null, isRetired: null, }); } @@ -66,15 +65,4 @@ function retireMerchantRuleSuggestion() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); } -/** - * Records that the user took the offer, so the rule page returns to the expense once the rule is saved. - * - * The `backTo` URL param is deprecated (see the `How to remove backTo from URL` section in NAVIGATION.md), so the - * origin travels through this record instead. A page refresh mid-flow then falls back to the workspace Rules page, - * which is fine because a refresh discards the offer anyway. - */ -function setIsCreatingMerchantRule(isCreatingRule: boolean) { - Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isCreatingRule}); -} - -export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion, setIsCreatingMerchantRule}; +export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion}; diff --git a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx index 14032e3805c3..ef4661a6b8c7 100644 --- a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx @@ -6,21 +6,22 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; import React from 'react'; -type AddBillablePageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddBillablePageProps = PlatformStackScreenProps; function AddBillablePage({route}: AddBillablePageProps) { const {policyID, ruleID} = route.params; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_BILLABLE_FROM_EXPENSE.path, policyID, ruleID); const goBack = () => { - const backRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - Navigation.goBack(backRoute); + Navigation.goBack(backToRoute); }; const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx index b2e3212fb85c..739bac6193b0 100644 --- a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx @@ -10,16 +10,18 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React, {useMemo} from 'react'; -type AddCategoryPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddCategoryPageProps = PlatformStackScreenProps; function AddCategoryPage({route}: AddCategoryPageProps) { const {policyID, ruleID} = route.params; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_CATEGORY_FROM_EXPENSE.path, policyID, ruleID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -35,8 +37,6 @@ function AddCategoryPage({route}: AddCategoryPageProps) { }); }, [policyCategories]); - const backToRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - const onSave = (value?: string) => { updateDraftMerchantRule({category: value}); }; diff --git a/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx b/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx index c31d6573a3f5..9ad302bb8bd8 100644 --- a/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx @@ -8,21 +8,25 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; import React from 'react'; -type AddDescriptionPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddDescriptionPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION | typeof SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION +>; function AddDescriptionPage({route}: AddDescriptionPageProps) { const {policyID, ruleID} = route.params; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE.path, policyID, ruleID); const goBack = () => { - const backRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - Navigation.goBack(backRoute); + Navigation.goBack(backToRoute); }; const onSave = (values: FormOnyxValues) => { diff --git a/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx index 5f1fd610a18e..873c602cb82b 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx @@ -15,7 +15,7 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {ValueOf} from 'type-fest'; @@ -23,7 +23,12 @@ import type {ValueOf} from 'type-fest'; import React from 'react'; import {View} from 'react-native'; -type AddMatchTypePageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddMatchTypePageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.WORKSPACE.RULES_MERCHANT_MATCH_TYPE | typeof SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE +>; type MatchTypeItem = ListItem & { value: ValueOf; @@ -31,16 +36,17 @@ type MatchTypeItem = ListItem & { function AddMatchTypePage({route}: AddMatchTypePageProps) { const {policyID, ruleID} = route.params; + // This page sits above the merchant-to-match page, so dropping its own suffix returns there. + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE.path, policyID, ruleID); const {translate} = useLocalize(); const styles = useThemeStyles(); - const isEditing = ruleID !== ROUTES.NEW; const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const selectedValue = form?.matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; const goBack = () => { - Navigation.goBack(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, isEditing ? ruleID : undefined)); + Navigation.goBack(backToRoute); }; const items: MatchTypeItem[] = [ diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx index 530d3b5d13a7..e1e9b179aa05 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx @@ -8,21 +8,22 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; import React from 'react'; -type AddMerchantPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddMerchantPageProps = PlatformStackScreenProps; function AddMerchantPage({route}: AddMerchantPageProps) { const {policyID, ruleID} = route.params; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_FROM_EXPENSE.path, policyID, ruleID); const goBack = () => { - const backRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - Navigation.goBack(backRoute); + Navigation.goBack(backToRoute); }; const onSave = (values: FormOnyxValues) => { diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx index 74693dcbfb3c..ee403ac20163 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -7,7 +7,7 @@ import React from 'react'; import MerchantRulePageBase from './MerchantRulePageBase'; -type AddMerchantRulePageProps = PlatformStackScreenProps; +type AddMerchantRulePageProps = PlatformStackScreenProps; function AddMerchantRulePage({route}: AddMerchantRulePageProps) { return ( diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx index 4f7cb21d795d..daea6d4c19c2 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx @@ -19,20 +19,25 @@ import {isRequiredFulfilled, isValidInputLength} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; import React from 'react'; import {View} from 'react-native'; -type AddMerchantToMatchPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddMerchantToMatchPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH | typeof SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH +>; function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { const {policyID, ruleID} = route.params; + const {isEditing, backToRoute, getRuleRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE.path, policyID, ruleID); const {translate} = useLocalize(); const styles = useThemeStyles(); - const isEditing = ruleID !== ROUTES.NEW; const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const {inputCallbackRef} = useAutoFocusInput(); @@ -48,8 +53,7 @@ function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { }; const goBack = () => { - const backRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - Navigation.goBack(backRoute); + Navigation.goBack(backToRoute); }; const onSave = (values: FormOnyxValues) => { @@ -119,7 +123,11 @@ function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { description={translate('workspace.rules.merchantRules.matchType')} title={getMatchTypeLabel()} shouldShowRightIcon - onPress={() => Navigation.navigate(ROUTES.RULES_MERCHANT_MATCH_TYPE.getRoute(policyID, isEditing ? ruleID : undefined))} + onPress={() => + Navigation.navigate( + getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_MATCH_TYPE.getRoute(policyID, isEditing ? ruleID : undefined)), + ) + } /> diff --git a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx index 1afc0b803964..2c3a13466c3b 100644 --- a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx @@ -6,21 +6,25 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; import React from 'react'; -type AddReimbursablePageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddReimbursablePageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE | typeof SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE +>; function AddReimbursablePage({route}: AddReimbursablePageProps) { const {policyID, ruleID} = route.params; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE.path, policyID, ruleID); const goBack = () => { - const backRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - Navigation.goBack(backRoute); + Navigation.goBack(backToRoute); }; const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { diff --git a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx index 4df89f40c1ca..dc62242c1b63 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx @@ -11,7 +11,7 @@ import {trimTag} from '@libs/TagUtils'; import {getTagArrayFromName} from '@libs/TransactionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PolicyTagLists} from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; @@ -20,11 +20,13 @@ import type {ValueOf} from 'type-fest'; import React, {useMemo} from 'react'; -type AddTagPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddTagPageProps = PlatformStackScreenProps; function AddTagPage({route}: AddTagPageProps) { const {policyID, ruleID, orderWeight} = route.params; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_TAG_FROM_EXPENSE.path, policyID, ruleID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const [policyTags = getEmptyArray>()] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {selector: getTagLists}); @@ -48,8 +50,6 @@ function AddTagPage({route}: AddTagPageProps) { const selectedTagItem = tagItems.find(({value}) => value === formTag); - const backToRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - const onSave = (value?: string) => { const newTags = [...formTags]; if (hasDependentTags) { diff --git a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx index d91cc7f13d90..743d43883191 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx @@ -9,16 +9,18 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; -type AddTaxPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddTaxPageProps = PlatformStackScreenProps; function AddTaxPage({route}: AddTaxPageProps) { const {policyID, ruleID} = route.params; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, policyID, ruleID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -33,8 +35,6 @@ function AddTaxPage({route}: AddTaxPageProps) { const selectedTaxItem = form?.tax ? taxItems.find(({value}) => value === form.tax) : undefined; - const backToRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - const onSave = (value?: string) => { updateDraftMerchantRule({tax: value}); }; diff --git a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx index 6b9f8758b797..eac8dcfd5801 100644 --- a/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddVendorPage.tsx @@ -17,13 +17,15 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Policy} from '@src/types/onyx'; import React from 'react'; -type AddVendorPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddVendorPageProps = PlatformStackScreenProps; type VendorSelectionItem = {name: string; value: string}; @@ -42,7 +44,7 @@ function getSelectedVendorItem(policy: Policy | undefined, vendorID: string | un function AddVendorPage({route}: AddVendorPageProps) { const {policyID, ruleID} = route.params; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_VENDOR_FROM_EXPENSE.path, policyID, ruleID); const {translate} = useLocalize(); const policy = usePolicy(policyID); @@ -64,8 +66,6 @@ function AddVendorPage({route}: AddVendorPageProps) { const vendorItems = getVendorSelectionItems(policy); - const backToRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - const saveVendor = (value?: string) => { updateDraftMerchantRule({vendorID: value}); }; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 828d4e330899..b855793c2b93 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -22,7 +22,6 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import usePressLoading from '@hooks/usePressLoading'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setIsCreatingMerchantRule} from '@libs/actions/MerchantRuleSuggestion'; import {openPolicyCategoriesPage} from '@libs/actions/Policy/Category'; import {deletePolicyCodingRule, setPolicyCodingRule} from '@libs/actions/Policy/Rules'; import {openPolicyTagsPage} from '@libs/actions/Policy/Tag'; @@ -44,21 +43,22 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {MerchantRuleForm} from '@src/types/form'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; -import type {MerchantRuleSuggestion, PolicyTagLists} from '@src/types/onyx'; +import type {PolicyTagLists} from '@src/types/onyx'; import type {CodingRule} from '@src/types/onyx/Policy'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + type MerchantRulePageBaseProps = { policyID: string; ruleID?: string; @@ -113,8 +113,6 @@ const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) return translate('workspace.rules.merchantRules.confirmError'); }; -const isCreatingRuleSelector = (suggestion: OnyxEntry) => suggestion?.isCreatingRule; - function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, testID}: MerchantRulePageBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -123,6 +121,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, const [isDeleting, setIsDeleting] = useState(false); const {isLoading, startWithLoading} = usePressLoading(); const isEditing = !!ruleID; + const {isCreatedFromExpense, backToRoute, getRuleRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.path, policyID, ruleID); const isInLandscapeMode = useIsInLandscapeMode(); const {isBetaEnabled} = usePermissions(); const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); @@ -130,8 +129,6 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, const getItemIcon = (icon: IconAsset) => (isRulesRevampEnabled ? icon : undefined); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); - const [isCreatingRuleFromExpense] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {selector: isCreatingRuleSelector}); - const isCreatedFromExpense = !isEditing && !!isCreatingRuleFromExpense; const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [policyTagsFromOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); const policyTags = useMemo(() => getTagLists(policyTagsFromOnyx) ?? getEmptyArray>(), [policyTagsFromOnyx]); @@ -298,10 +295,9 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, } setPolicyCodingRule(policyID, form, policy, ruleID, shouldUpdateMatchingTransactions); if (isCreatedFromExpense) { - // Created from an expense rather than workspace settings, so go back to the expense, not the Rules page. - // Clear the marker so a later rule created from settings is unaffected. - setIsCreatingMerchantRule(false); - Navigation.goBack(); + // Started from the "Create a rule" callout, so this page is a suffix on the expense's own path. Dropping + // that suffix returns to the expense instead of the workspace Rules page. + Navigation.goBack(backToRoute); } else if (!isEditing && isRulesRevampEnabled) { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.EXPENSE_DEFAULTS); Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); @@ -375,7 +371,10 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, description: translate('common.merchant'), required: true, title: form?.merchantToMatch, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, ruleID)), + onPress: () => + Navigation.navigate( + getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, ruleID)), + ), icon: getItemIcon(icons.Basket), }, ], @@ -387,7 +386,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, key: 'merchant', description: translate('common.merchant'), title: form?.merchant, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID, ruleID)), + onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_MERCHANT_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID, ruleID))), icon: getItemIcon(icons.Basket), }, hasCategories() @@ -395,7 +394,8 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, key: 'category', description: translate('common.category'), title: categoryDisplayName, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID, ruleID)), + onPress: () => + Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_CATEGORY_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID, ruleID))), icon: getItemIcon(icons.Folder), } : undefined, @@ -408,7 +408,13 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, key: `tag-${name}-${orderWeight}`, description: name, title: formTag ? getCleanedTagName(formTag) : undefined, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID, ruleID, orderWeight)), + onPress: () => + Navigation.navigate( + getRuleRoute( + DYNAMIC_ROUTES.RULES_MERCHANT_TAG_FROM_EXPENSE.getRoute(orderWeight), + ROUTES.RULES_MERCHANT_TAG.getRoute(policyID, ruleID, orderWeight), + ), + ), icon: getItemIcon(icons.Tag), }; }) @@ -418,7 +424,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, key: 'tax', description: translate('common.tax'), title: taxDisplayName(), - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID, ruleID)), + onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_TAX.getRoute(policyID, ruleID))), icon: getItemIcon(icons.InvoiceGeneric), } : undefined, @@ -427,7 +433,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, key: 'vendorID', description: vendorFieldLabel, title: vendorDisplayName, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_VENDOR.getRoute(policyID, ruleID)), + onPress: () => Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_VENDOR_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_VENDOR.getRoute(policyID, ruleID))), icon: getItemIcon(icons.Basket), } : undefined, @@ -435,7 +441,8 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, key: 'description', description: translate('common.description'), title: form?.comment ? Parser.replace(form.comment) : undefined, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_DESCRIPTION.getRoute(policyID, ruleID)), + onPress: () => + Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_DESCRIPTION_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_DESCRIPTION.getRoute(policyID, ruleID))), shouldRenderAsHTML: true, icon: getItemIcon(icons.Pencil), }, @@ -443,7 +450,8 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, key: 'reimbursable', description: translate('common.reimbursable'), title: getBooleanTitle(form?.reimbursable, translate), - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID, ruleID)), + onPress: () => + Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_REIMBURSABLE_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID, ruleID))), icon: getItemIcon(icons.Paycheck), }, isBillableEnabled @@ -451,7 +459,8 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, key: 'billable', description: translate('common.billable'), title: getBooleanTitle(form?.billable, translate), - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_BILLABLE.getRoute(policyID, ruleID)), + onPress: () => + Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_BILLABLE_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_BILLABLE.getRoute(policyID, ruleID))), icon: getItemIcon(icons.Paycheck), } : undefined, @@ -465,7 +474,7 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, return; } - Navigation.navigate(ROUTES.RULES_MERCHANT_PREVIEW_MATCHES.getRoute(policyID, ruleID)); + Navigation.navigate(getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_PREVIEW_MATCHES.getRoute(policyID, ruleID))); }; if (ruleID && !existingRule && !isDeleting) { diff --git a/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx b/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx index d8d4441ad83a..baa084b305df 100644 --- a/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx @@ -22,7 +22,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Transaction} from '@src/types/onyx'; import type {CodingRuleFilter} from '@src/types/onyx/Policy'; @@ -33,12 +33,17 @@ import {FlashList} from '@shopify/flash-list'; import React, {useEffect} from 'react'; import {View} from 'react-native'; -type PreviewMatchesPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type PreviewMatchesPageProps = PlatformStackScreenProps< + SettingsNavigatorParamList, + typeof SCREENS.WORKSPACE.RULES_MERCHANT_PREVIEW_MATCHES | typeof SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES +>; function PreviewMatchesPage({route}: PreviewMatchesPageProps) { const ruleID = route.params.ruleID; const policyID = route.params.policyID; - const isEditing = ruleID !== ROUTES.NEW; + const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_PREVIEW_MATCHES_FROM_EXPENSE.path, policyID, ruleID); const theme = useTheme(); const styles = useThemeStyles(); @@ -85,12 +90,7 @@ function PreviewMatchesPage({route}: PreviewMatchesPageProps) { ); const goBack = () => { - if (isEditing) { - Navigation.goBack(ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID)); - return; - } - - Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + Navigation.goBack(backToRoute); }; return ( diff --git a/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts new file mode 100644 index 000000000000..cc7e49997dcf --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts @@ -0,0 +1,79 @@ +import useDynamicBackPath from '@hooks/useDynamicBackPath'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; + +import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import {useRoute} from '@react-navigation/native'; + +// The merchant rule screens that hang off an expense. Membership is checked directly rather than through +// `isDynamicRouteScreen` so this stays a plain string lookup, and so only this flow's screens can match. +const SCREENS_FROM_EXPENSE = new Set([ + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_CATEGORY, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_VENDOR, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE, + SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES, +]); + +type MerchantRuleRoute = { + /** Whether these pages were entered from the "Create a rule" callout on an expense rather than workspace settings */ + isCreatedFromExpense: boolean; + + /** Whether an existing rule is being edited. The callout only ever creates, so it is never editing. */ + isEditing: boolean; + + /** Where this page's back button and its save handler should return to */ + backToRoute: Route; + + /** + * Builds a route to another page of this flow, keeping it in whichever stack the flow was entered from. + * + * @param dynamicSuffixWithParams - the target's dynamic path, with its params filled in + * @param staticRoute - the target's workspace settings route + */ + getRuleRoute: (dynamicSuffixWithParams: string, staticRoute: Route) => Route; +}; + +/** + * Routing for the merchant rule pages, which are reachable both from workspace settings and from the "Create a rule" + * callout on an expense. + * + * The callout enters through dynamic routes, whose paths are suffixes appended to the expense the user came from, so + * the expense stays underneath the modal. Those pages therefore cannot hardcode the workspace paths: going back means + * dropping their own suffix, and going on means appending the next one. + * + * @param dynamicSuffix - this page's own dynamic route path, dropped from the URL to go back + * @param policyID - the workspace the rule belongs to + * @param ruleID - the rule being edited, absent when creating one + */ +function useMerchantRuleRoute(dynamicSuffix: DynamicRouteSuffix, policyID: string, ruleID?: string): MerchantRuleRoute { + const route = useRoute(); + const isCreatedFromExpense = SCREENS_FROM_EXPENSE.has(route.name); + // Only a rule reached through workspace settings can be an edit. `ruleID` is absent on the dynamic routes, and + // `undefined !== 'new'` would otherwise read as editing. + const isEditing = !isCreatedFromExpense && !!ruleID && ruleID !== ROUTES.NEW; + const dynamicBackToRoute = useDynamicBackPath(dynamicSuffix); + const staticBackToRoute = isEditing && ruleID ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const getRuleRoute = (dynamicSuffixWithParams: string, staticRoute: Route) => (isCreatedFromExpense ? createDynamicRoute(dynamicSuffixWithParams) : staticRoute); + + return { + isCreatedFromExpense, + isEditing, + backToRoute: isCreatedFromExpense ? dynamicBackToRoute : staticBackToRoute, + getRuleRoute, + }; +} + +export default useMerchantRuleRoute; +export type {MerchantRuleRoute}; diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 00fa7cc3e289..3d6b03016a86 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -32,12 +32,6 @@ type MerchantRuleSuggestion = { * returning to the expense shows nothing while editing it again offers afresh. */ isRetired?: boolean; - - /** - * Whether the user took the offer. The rule page reads this to return to the expense after saving, rather than - * to the workspace Rules page where creating a rule from settings ends. - */ - isCreatingRule?: boolean; }; export default MerchantRuleSuggestion; From 5341423301766cbdb5366ee3ce5d62de6e7a36b0 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 2 Sep 2026 00:08:33 +0530 Subject: [PATCH 27/51] fix knip check. Signed-off-by: krishna2323 --- src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts index cc7e49997dcf..7d17d9058f64 100644 --- a/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts +++ b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts @@ -76,4 +76,3 @@ function useMerchantRuleRoute(dynamicSuffix: DynamicRouteSuffix, policyID: strin } export default useMerchantRuleRoute; -export type {MerchantRuleRoute}; From 99208d3b26f378d9618e9a1269cc2e8d4626dff5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 2 Sep 2026 01:47:45 +0530 Subject: [PATCH 28/51] Use the reversed link color and the label typography on the callout Signed-off-by: krishna2323 --- src/styles/index.ts | 12 ++++++------ src/styles/theme/themes/dark.ts | 2 -- src/styles/theme/themes/light.ts | 2 -- src/styles/theme/types.ts | 1 - 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 2f304e614c8d..4df263712bde 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4439,17 +4439,17 @@ const staticStyles = (theme: ThemeColors) => }, merchantRuleCalloutText: { - fontSize: variables.fontSizeLabel, + ...textVariants.label, color: theme.textReversed, - lineHeight: variables.lineHeightLarge, + // Banner sets breakAll on its container, which would split this sentence mid-word ...wordBreak.breakWord, }, + // The callout sits on a reversed surface, dark in the light theme and light in the dark one, which is why the + // text and the link both use the reversed colors rather than the ordinary ones. merchantRuleCalloutAction: { - color: theme.tooltipLinkText, - fontWeight: FontUtils.fontWeight.bold, - fontSize: variables.fontSizeLabel, - lineHeight: variables.lineHeightLarge, + ...textVariants.labelStrong, + color: theme.linkReversed, }, quickReactionsContainer: { diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 54c6ba3b5d82..f04c08581918 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -103,8 +103,6 @@ const darkTheme = { ourMentionBG: colors.green600, tooltipHighlightBG: colors.green100, tooltipHighlightText: colors.green400, - // The tooltip background is dark in both themes, so this stays the same in both, like the highlight above - tooltipLinkText: colors.blue300, tooltipSupportingText: colors.productLight800, tooltipPrimaryText: colors.productLight900, trialBannerBackgroundColor: colors.green700, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 2cb6e56211e9..f9685e8898b5 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -103,8 +103,6 @@ const lightTheme = { ourMentionBG: colors.green100, tooltipHighlightBG: colors.green700, tooltipHighlightText: colors.green400, - // The tooltip background is dark in both themes, so this stays the same in both, like the highlight above - tooltipLinkText: colors.blue300, tooltipSupportingText: colors.productDark800, tooltipPrimaryText: colors.productDark900, trialBannerBackgroundColor: colors.green100, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index bdbcbe6d6a33..d1a4f83d901a 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -110,7 +110,6 @@ type ThemeColors = { ourMentionBG: Color; tooltipHighlightBG: Color; tooltipHighlightText: Color; - tooltipLinkText: Color; tooltipSupportingText: Color; tooltipPrimaryText: Color; trialBannerBackgroundColor: Color; From 2cb057044483a0bac1b48bc5890dd2625dfba443 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 2 Sep 2026 01:54:23 +0530 Subject: [PATCH 29/51] Record every field edited on an expense, not just the last one Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 10 +-- src/hooks/useMerchantRuleSuggestion.ts | 20 ++++-- src/libs/MerchantRuleSuggestionUtils.ts | 64 ++++++++----------- src/libs/actions/MerchantRuleSuggestion.ts | 21 ++++-- src/types/onyx/MerchantRuleSuggestion.ts | 17 +++-- 5 files changed, 74 insertions(+), 58 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index 6ea44c5c74a7..db7fd5722d9a 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -5,7 +5,7 @@ import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; +import {clearMerchantRuleSuggestionFields, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; import {setDraftMerchantRule} from '@libs/actions/User'; import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -52,7 +52,7 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, const theme = useTheme(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); - const {suggestion, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID, transactionID); + const {suggestion, fields, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID, transactionID); // The offer ends once the user has seen it and navigated away. Retiring leaves the expense itself untouched, so // editing it again offers afresh. Only the close button silences an expense for the session. @@ -82,7 +82,7 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, const dismiss = () => dismissMerchantRuleSuggestion(suggestion); const createRule = () => { - const draft = getMerchantRuleDraftFromTransaction(transaction, suggestion.field, policy); + const draft = getMerchantRuleDraftFromTransaction(transaction, fields, policy); if (!draft) { return; } @@ -91,7 +91,9 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, // than to the workspace Rules page. setDraftMerchantRule(draft); // The offer has been taken, so coming back from the rule flow, saved or abandoned, must not find it still - // asking. Editing the expense again starts a fresh offer. + // asking, and the recording that fed it ends here. Editing the expense again starts a fresh offer, gathering + // only what changes from now on rather than repeating fields already carried into this rule. + clearMerchantRuleSuggestionFields(suggestion.transactionID); retireMerchantRuleSuggestion(); Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.getRoute(policyID))); }; diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index 4e7202ad224e..afcd1cd96a51 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -5,6 +5,7 @@ import {isMerchantMissing} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx'; +import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; import useOnyx from './useOnyx'; import usePermissions from './usePermissions'; @@ -12,9 +13,12 @@ import usePolicyFeatureWriteAccess from './usePolicyFeatureWriteAccess'; import useReportTransactions from './useReportTransactions'; type MerchantRuleSuggestionResult = { - /** The edit that can be turned into a merchant rule, or undefined when no callout should render */ + /** The edits that can be turned into a merchant rule, or undefined when no callout should render */ suggestion: MerchantRuleSuggestion | undefined; + /** Every field edited on that expense so far, which the rule is pre-seeded from */ + fields: MerchantRuleSuggestionField[]; + /** The edited expense, needed to pre-seed the rule */ transaction: Transaction | undefined; @@ -53,12 +57,18 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const canCreateMerchantRule = canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; - // A merchant rule matches on merchant, so an expense without one, like a receipt still scanning, can't seed a rule - if (!suggestion || !transaction || isMerchantMissing(transaction)) { - return {suggestion: undefined, transaction: undefined, policy: undefined}; + // Filtered from the canonical list rather than read off the record's own keys, so the fields arrive in a fixed + // order and nothing but a known field can reach the draft. + const editedFields = suggestion ? suggestion.editedFields?.[suggestion.transactionID] : undefined; + const fields = Object.values(CONST.MERCHANT_RULE_SUGGESTION_FIELDS).filter((field) => !!editedFields?.[field]); + + // A merchant rule matches on merchant, so an expense without one, like a receipt still scanning, can't seed a rule. + // An offer with nothing recorded cannot seed one either, which is how an expense reads once its fields are cleared. + if (!suggestion || !transaction || isMerchantMissing(transaction) || fields.length === 0) { + return {suggestion: undefined, fields: [], transaction: undefined, policy: undefined}; } - return {suggestion, transaction, policy}; + return {suggestion, fields, transaction, policy}; } export default useMerchantRuleSuggestion; diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts index af3c38626ff1..d800301938a6 100644 --- a/src/libs/MerchantRuleSuggestionUtils.ts +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -9,62 +9,54 @@ import Parser from './Parser'; import {resolveCurrentTaxCode} from './PolicyUtils'; import {getCategory, getDescription, getMerchant, getTag, isMerchantMissing} from './TransactionUtils'; -/** - * Builds the draft that pre-seeds the rule creation flow when an admin turns an expense edit into a rule. Returns - * undefined when the expense has no merchant to match on, since a rule cannot be saved without one. - */ -function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry, field: MerchantRuleSuggestionField, policy: OnyxEntry): Partial | undefined { - if (!transaction || isMerchantMissing(transaction)) { - return undefined; - } - - const draft: Partial = {merchantToMatch: getMerchant(transaction)}; - +/** The rule draft for one edited field, in the shape the rule form expects. */ +function getDraftForField(field: MerchantRuleSuggestionField, transaction: Transaction, policy: OnyxEntry): Partial { switch (field) { case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY: { const category = getCategory(transaction); - if (category) { - draft.category = category; - } - break; + return category ? {category} : {}; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG: { // Multi-level tags stay in the transaction's colon-joined form, which is what the rule form expects too const tag = getTag(transaction); - if (tag) { - draft.tag = tag; - } - break; + return tag ? {tag} : {}; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX: { // The rule form stores the key of the tax in `policy.taxRates.taxes`, which is what a transaction stores // as its taxCode. A transaction can still carry a code that has since been renamed, so resolve it first. const taxCode = transaction.taxCode ? resolveCurrentTaxCode(policy, transaction.taxCode) : undefined; - if (taxCode && policy?.taxRates?.taxes?.[taxCode]) { - draft.tax = taxCode; - } - break; + return taxCode && policy?.taxRates?.taxes?.[taxCode] ? {tax: taxCode} : {}; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION: { // An expense description is stored as HTML, while the rule form edits markdown const description = getDescription(transaction); - if (description) { - draft.comment = Parser.htmlToMarkdown(description); - } - break; + return description ? {comment: Parser.htmlToMarkdown(description)} : {}; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE: - if (transaction.billable !== undefined) { - draft.billable = transaction.billable; - } - break; + return transaction.billable !== undefined ? {billable: transaction.billable} : {}; case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE: - if (transaction.reimbursable !== undefined) { - draft.reimbursable = transaction.reimbursable; - } - break; + return transaction.reimbursable !== undefined ? {reimbursable: transaction.reimbursable} : {}; default: - break; + return {}; + } +} + +/** + * Builds the draft that pre-seeds the rule creation flow when an admin turns their expense edits into a rule. Every + * field they changed since they started editing is carried over, so a rule created after changing category, tag and + * tax arrives with all three filled in. Returns undefined when the expense has no merchant to match on, since a rule + * cannot be saved without one. + */ +function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry, fields: MerchantRuleSuggestionField[], policy: OnyxEntry): Partial | undefined { + if (!transaction || isMerchantMissing(transaction)) { + return undefined; + } + + const draft: Partial = {merchantToMatch: getMerchant(transaction)}; + + // Each field contributes a different property, so the order they were edited in does not matter. + for (const field of fields) { + Object.assign(draft, getDraftForField(field, transaction, policy)); } return draft; diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index c4b84ad9e5b3..64aac102c2f3 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -10,7 +10,8 @@ import Onyx from 'react-native-onyx'; /** * Records an edit to a field merchant rules can govern, so the expense detail view can offer to turn it into a rule. - * Only the most recent edit is kept, since one callout shows at a time. + * Edits accumulate per expense: the user is recorded from their first edit until they take the offer, so a rule + * created after changing category, tag and tax carries all three. Only the most recently edited expense offers. * * The record is RAM-only, so nothing outlives the session, dismissals included. That is deliberate: the callout is * dismissed one expense at a time and should appear again on that expense in a later session, which an account-wide @@ -34,13 +35,13 @@ function trackMerchantRuleSuggestion( return; } - // Merged rather than set so this session's dismissals survive an edit to an already dismissed expense. - // `isRetired` describes the offer being replaced, so it is cleared with it. A fresh edit is a fresh offer, even - // on an expense the user walked away from. + // Merged rather than set so this session's dismissals survive an edit to an already dismissed expense, and so + // `editedFields` gathers this expense's fields rather than replacing them. `isRetired` describes the offer being + // replaced, so it is cleared with it. A fresh edit is a fresh offer, even on an expense the user walked away from. Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), - field, + editedFields: {[transactionID]: {[field]: true}}, isRetired: null, }); } @@ -57,6 +58,14 @@ function dismissMerchantRuleSuggestion(suggestion: MerchantRuleSuggestion) { }); } +/** + * Forgets what was edited on an expense, so the next rule created from it starts from that point rather than carrying + * fields the user has already turned into a rule. Called when the offer is taken, which ends the recording. + */ +function clearMerchantRuleSuggestionFields(transactionID: string) { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {editedFields: {[transactionID]: null}}); +} + /** * Ends the current offer once the user has seen it, without dismissing the expense. Coming back shows nothing, but * editing the expense again offers afresh. Only the close button silences an expense outright. @@ -65,4 +74,4 @@ function retireMerchantRuleSuggestion() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); } -export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion}; +export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion, clearMerchantRuleSuggestionFields}; diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 3d6b03016a86..bad4bf53b00f 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -6,20 +6,23 @@ import type {ValueOf} from 'type-fest'; type MerchantRuleSuggestionField = ValueOf; /** - * Session-scoped record of the merchant-rule-governed field the user most recently edited on an expense. + * The expense edits that can be turned into a merchant rule, which drive the "Create a rule" callout. * - * Drives the "Create a rule" callout on the expense detail view. Kept RAM-only so the callout can surface again on - * the same expense in a future session, per the product spec. + * Kept RAM-only so the callout can appear again on the same expense in a future session. */ type MerchantRuleSuggestion = { - /** The expense that was edited */ + /** The expense most recently edited, which is the one currently offering a rule */ transactionID: string; - /** Reports whose expense detail view can surface the callout: the transaction thread and its parent expense report */ + /** The transaction thread and its parent expense report, whose expense detail views can show the callout */ reportIDs: string[]; - /** The edited field, used to pre-seed the rule */ - field: MerchantRuleSuggestionField; + /** + * Every field edited so far, keyed by expense. The callout offers all of an expense's fields at once, because the + * user is recorded from their first edit until they take the offer. Keying by expense is what lets `Onyx.merge` + * accumulate them without reading the record back, and keeps one expense's edits out of another's rule. + */ + editedFields: Record>>; /** * Expenses the user dismissed this session. Kept alongside the current offer rather than replaced by it, so a From 51ff41301de9a6a40aef834bb486cf04ee27f9e0 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 02:32:38 +0530 Subject: [PATCH 30/51] Address review: fix the match type back button and tighten the callout Signed-off-by: krishna2323 --- src/CONST/index.ts | 10 ++++- .../MerchantRuleSuggestionBanner.tsx | 37 ++++++++++++------- src/hooks/useMerchantRuleSuggestion.ts | 20 +++++----- src/libs/MerchantRuleSuggestionUtils.ts | 30 ++++++++++----- src/libs/actions/IOU/UpdateMoneyRequest.ts | 12 +++--- src/libs/actions/MerchantRuleSuggestion.ts | 25 ++++++------- src/pages/inbox/report/ReportActionsList.tsx | 23 ++++-------- src/pages/inbox/report/ReportFooter.tsx | 11 ++---- .../rules/MerchantRules/AddMatchTypePage.tsx | 12 ++++-- .../MerchantRules/useMerchantRuleRoute.ts | 32 +++++----------- src/types/onyx/MerchantRuleSuggestion.ts | 13 +++++-- 11 files changed, 118 insertions(+), 107 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e32014041bc7..58dc3d374303 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -514,11 +514,17 @@ const CONST = { MERCHANT_NAME_MAX_BYTES: 255, - /** Expense fields a merchant rule can govern, whose edit shows the "Create a rule" callout on the expense */ + /** + * Expense fields a merchant rule can govern, whose edit shows the "Create a rule" callout on the expense. + * + * These are the same strings EDIT_REQUEST_FIELD uses, since they name the very edits that trigger the callout. + * They cannot be derived from it with Pick, because it is declared further down this same object literal, so + * `MerchantRuleSuggestionField` intersects the two instead and fails the build if they ever drift apart. + */ MERCHANT_RULE_SUGGESTION_FIELDS: { CATEGORY: 'category', TAG: 'tag', - TAX: 'tax', + TAX: 'taxRate', DESCRIPTION: 'description', BILLABLE: 'billable', REIMBURSABLE: 'reimbursable', diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index db7fd5722d9a..e00f4c71f8a7 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -2,12 +2,13 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMerchantRuleSuggestion from '@hooks/useMerchantRuleSuggestion'; import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearMerchantRuleSuggestionFields, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; import {setDraftMerchantRule} from '@libs/actions/User'; -import {getMerchantRuleDraftFromTransaction} from '@libs/MerchantRuleSuggestionUtils'; +import {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -18,6 +19,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {StyleProp, ViewStyle} from 'react-native'; +import {useRoute} from '@react-navigation/native'; import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import Animated, {FadeInDown, FadeInUp, FadeOutDown, FadeOutUp} from 'react-native-reanimated'; @@ -26,6 +28,7 @@ import Banner from './Banner'; import Icon from './Icon'; import Text from './Text'; import TextLink from './TextLink'; +import {useWideRHPState} from './WideRHPContextProvider'; type MerchantRuleSuggestionBannerProps = { /** The report hosting the expense detail view: a transaction thread, its expense report, or the chat it lives in */ @@ -34,25 +37,25 @@ type MerchantRuleSuggestionBannerProps = { /** The workspace the expense belongs to */ policyID: string | undefined; - /** The expense being displayed, when the host already knows it */ - transactionID?: string; - /** Styles for the banner container */ containerStyles?: StyleProp; /** When set, floats the callout in a wrapper carrying these styles instead of laying it out inline */ overlayStyles?: StyleProp; - /** Whether the callout is pinned to the bottom of its host, which is the edge it slides in from */ + /** + * Whether this mount point is the one above the composer. It decides both the edge the callout slides in from and + * which layouts it belongs in, since the composer takes the wide layouts and the report list takes the narrow ones. + */ isAnchoredToBottom?: boolean; }; -function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { +function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); - const {suggestion, fields, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID, transactionID); + const {suggestion, fields, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); // The offer ends once the user has seen it and navigated away. Retiring leaves the expense itself untouched, so // editing it again offers afresh. Only the close button silences an expense for the session. @@ -137,18 +140,27 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, transactionID, ); } -MerchantRuleSuggestionBannerContent.displayName = 'MerchantRuleSuggestionBannerContent'; - /** * Offers the chance to turn an expense edit into a merchant rule, right on the expense that was just edited. Renders * nothing unless there is a qualifying edit to act on. */ -function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { +function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + // A wide RHP reports a narrow layout but lays the expense out like a wide screen, so it belongs to the composer + // mount alongside the genuinely wide layouts. + const route = useRoute(); + const {wideRHPRouteKeys} = useWideRHPState(); + const isInWideRHP = !!route?.key && wideRHPRouteKeys.includes(route.key); + + // Both mount points are always rendered, and this picks the one that suits the layout. Deciding here rather than + // at each mount keeps the two halves of the condition from drifting apart, and keeps the navigation-state + // subscription out of the report actions list, which re-renders far more than this does. + const isMountForThisLayout = isAnchoredToBottom ? !shouldUseNarrowLayout || isInWideRHP : shouldUseNarrowLayout && !isInWideRHP; // Nothing is stored for most of a session, so skip the inner component and its many Onyx subscriptions until // there is an edit to offer. - if (!storedSuggestion?.transactionID || storedSuggestion.isRetired || storedSuggestion.dismissedTransactionIDs?.includes(storedSuggestion.transactionID)) { + if (!isMountForThisLayout || !isMerchantRuleSuggestionLive(storedSuggestion)) { return null; } @@ -156,7 +168,6 @@ function MerchantRuleSuggestionBanner({reportID, policyID, transactionID, contai ): boolean { + if (!suggestion?.transactionID || suggestion.isRetired) { + return false; + } + return !suggestion.dismissedTransactionIDs?.includes(suggestion.transactionID); +} /** The rule draft for one edited field, in the shape the rule form expects. */ function getDraftForField(field: MerchantRuleSuggestionField, transaction: Transaction, policy: OnyxEntry): Partial { @@ -24,7 +36,8 @@ function getDraftForField(field: MerchantRuleSuggestionField, transaction: Trans case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX: { // The rule form stores the key of the tax in `policy.taxRates.taxes`, which is what a transaction stores // as its taxCode. A transaction can still carry a code that has since been renamed, so resolve it first. - const taxCode = transaction.taxCode ? resolveCurrentTaxCode(policy, transaction.taxCode) : undefined; + const storedTaxCode = getTaxCode(transaction); + const taxCode = storedTaxCode ? resolveCurrentTaxCode(policy, storedTaxCode) : undefined; return taxCode && policy?.taxRates?.taxes?.[taxCode] ? {tax: taxCode} : {}; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION: { @@ -32,10 +45,12 @@ function getDraftForField(field: MerchantRuleSuggestionField, transaction: Trans const description = getDescription(transaction); return description ? {comment: Parser.htmlToMarkdown(description)} : {}; } + // Read through the helpers rather than the raw fields, so an unset value seeds the rule with what the expense + // view actually displays. An unset `reimbursable` shows as reimbursable, and would otherwise seed "Don't change". case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE: - return transaction.billable !== undefined ? {billable: transaction.billable} : {}; + return {billable: getBillable(transaction)}; case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE: - return transaction.reimbursable !== undefined ? {reimbursable: transaction.reimbursable} : {}; + return {reimbursable: getReimbursable(transaction)}; default: return {}; } @@ -62,7 +77,4 @@ function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry return draft; } -export { - // eslint-disable-next-line import/prefer-default-export -- Utils modules export their helpers by name - getMerchantRuleDraftFromTransaction, -}; +export {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive}; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index c2483ee72ceb..4f9a5fa316e1 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -409,7 +409,7 @@ function updateMoneyRequestBillable({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, [transactionThreadReport.reportID, parentReport?.reportID], policy, policyCategories); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, transactionThreadReport.reportID, policy); } function updateMoneyRequestReimbursable({ @@ -475,7 +475,7 @@ function updateMoneyRequestReimbursable({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, [transactionThreadReport.reportID, parentReport?.reportID], policy, policyCategories); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, transactionThreadReport.reportID, policy); } /** Updates the merchant field of an expense */ @@ -887,7 +887,7 @@ function updateMoneyRequestTag({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, [transactionThreadReport?.reportID, parentReport?.reportID], policy, policyCategories); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy); } /** Updates the created tax amount of an expense */ @@ -1017,7 +1017,7 @@ function updateMoneyRequestTaxRate({ }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, [transactionThreadReport?.reportID, parentReport?.reportID], policy, policyCategories); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, transactionThreadReport?.reportID, policy); } type UpdateMoneyRequestDistanceParams = { @@ -1253,7 +1253,7 @@ function updateMoneyRequestCategory({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, [transactionThreadReport?.reportID, parentReport?.reportID], policy, policyCategories); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, transactionThreadReport?.reportID, policy); } /** Updates the description of an expense */ @@ -1338,7 +1338,7 @@ function updateMoneyRequestDescription({ const {params, onyxData} = data; params.description = parsedComment; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, [transactionThreadReport?.reportID, parentReport?.reportID], policy, policyCategories); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, transactionThreadReport?.reportID, policy); } /** Updates the distance rate of an expense */ diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index 64aac102c2f3..e0ab4d3a8e5b 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -1,7 +1,8 @@ -import {arePolicyRulesEnabled} from '@libs/PolicyUtils'; +// eslint-disable-next-line no-restricted-imports -- Rules is a paid-plan feature, and this mirrors the isPaidGroupPolicy check arePolicyRulesEnabled makes before anything renders +import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MerchantRuleSuggestion, Policy, PolicyCategories} from '@src/types/onyx'; +import type {MerchantRuleSuggestion, Policy} from '@src/types/onyx'; import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; import type {OnyxEntry} from 'react-native-onyx'; @@ -20,18 +21,16 @@ import Onyx from 'react-native-onyx'; * Written for every member of a workspace that could carry a merchant rule. `useMerchantRuleSuggestion` decides * whether the callout renders, which additionally needs write access to the Rules feature. */ -function trackMerchantRuleSuggestion( - transactionID: string | undefined, - field: MerchantRuleSuggestionField, - reportIDs: Array, - policy: OnyxEntry, - policyCategories: OnyxEntry, -) { +function trackMerchantRuleSuggestion(transactionID: string | undefined, field: MerchantRuleSuggestionField, reportID: string | undefined, policy: OnyxEntry) { // An offer only makes sense if the workspace could hold a merchant rule when the edit was made. Recording one // regardless would leave an edit made with Rules switched off sitting here, ready to surface the moment somebody - // switched Rules on. The beta is assumed enabled because it cannot be read outside a component, which only makes - // this more permissive than the callout itself: `useMerchantRuleSuggestion` checks it for real before rendering. - if (!transactionID || !arePolicyRulesEnabled(policy, policyCategories, true)) { + // switched Rules on. + // + // This asks the two questions that need no beta, rather than calling arePolicyRulesEnabled: the remaining branch + // there only narrows collect workspaces by the Rules revamp beta, and reading that here would mean threading it + // through six update actions and every one of their callers. Leaving it out is the permissive direction, and + // `useMerchantRuleSuggestion` still checks the beta in full before anything renders. + if (!transactionID || !reportID || !isPaidGroupPolicy(policy) || policy?.areRulesEnabled === false) { return; } @@ -40,7 +39,7 @@ function trackMerchantRuleSuggestion( // replaced, so it is cleared with it. A fresh edit is a fresh offer, even on an expense the user walked away from. Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, - reportIDs: reportIDs.filter((reportID): reportID is string => !!reportID), + reportID, editedFields: {[transactionID]: {[field]: true}}, isRetired: null, }); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 36c154176738..012e12803dc2 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -2,7 +2,6 @@ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@compon import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; -import {useWideRHPState} from '@components/WideRHPContextProvider'; import useEnvironment from '@hooks/useEnvironment'; import useLinkedMessageOfflineLoading from '@hooks/useLinkedMessageOfflineLoading'; @@ -113,7 +112,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isProduction} = useEnvironment(); - const {wideRHPRouteKeys} = useWideRHPState(); const { report, @@ -137,8 +135,6 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {isOffline} = useNetwork(); const route = useRoute>(); const reportActionIDFromRoute = route?.params?.reportActionID; - // A wide RHP reports a narrow layout but lays out like a wide screen, so the report footer owns the callout there - const isInWideRHP = !!route?.key && wideRHPRouteKeys.includes(route.key); const {sessionStartTime} = useConciergeSessionState(); const didLayout = useRef(false); @@ -451,17 +447,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return ( <> - {shouldUseNarrowLayout && - !isInWideRHP && ( - // Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail - // view does not carry it out of sight. Wide layouts render it above the composer instead. - - )} + {/* Pinned over the top of the list rather than laid out inside it, so scrolling the expense detail view + does not carry it out of sight. Renders nothing on the layouts the composer mount serves. */} + ); - // Narrow layouts pin the callout over the top of the report list instead - const shouldShowMerchantRuleBanner = (!shouldUseNarrowLayout || isInWideRHP) && !isComposerFullSize; - const merchantRuleBanner = shouldShowMerchantRuleBanner && ( + // The callout decides for itself whether this mount suits the layout. A full-size composer leaves no room + // for it either way. + const merchantRuleBanner = !isComposerFullSize && ( ([ - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_NEW, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT_TO_MATCH, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MATCH_TYPE, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_MERCHANT, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_CATEGORY, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_VENDOR, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_DESCRIPTION, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_REIMBURSABLE, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_BILLABLE, - SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_PREVIEW_MATCHES, -]); - type MerchantRuleRoute = { /** Whether these pages were entered from the "Create a rule" callout on an expense rather than workspace settings */ isCreatedFromExpense: boolean; @@ -55,22 +39,26 @@ type MerchantRuleRoute = { * @param dynamicSuffix - this page's own dynamic route path, dropped from the URL to go back * @param policyID - the workspace the rule belongs to * @param ruleID - the rule being edited, absent when creating one + * @param staticBackToRoute - where to return in the workspace settings flow, for the pages that sit deeper than the rule page itself */ -function useMerchantRuleRoute(dynamicSuffix: DynamicRouteSuffix, policyID: string, ruleID?: string): MerchantRuleRoute { +function useMerchantRuleRoute(dynamicSuffix: DynamicRouteSuffix, policyID: string, ruleID?: string, staticBackToRoute?: Route): MerchantRuleRoute { const route = useRoute(); - const isCreatedFromExpense = SCREENS_FROM_EXPENSE.has(route.name); + // Asking the linking config keeps this from drifting: a screen added to the flow is dynamic or it is not, with no + // list here to forget to update. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- useRoute is untyped here because this hook is shared by every page of the flow, and the repo's other isDynamicRouteScreen callers narrow the same way + const isCreatedFromExpense = isDynamicRouteScreen(route.name as Screen); // Only a rule reached through workspace settings can be an edit. `ruleID` is absent on the dynamic routes, and // `undefined !== 'new'` would otherwise read as editing. const isEditing = !isCreatedFromExpense && !!ruleID && ruleID !== ROUTES.NEW; const dynamicBackToRoute = useDynamicBackPath(dynamicSuffix); - const staticBackToRoute = isEditing && ruleID ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + const ruleRoute = isEditing && ruleID ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); const getRuleRoute = (dynamicSuffixWithParams: string, staticRoute: Route) => (isCreatedFromExpense ? createDynamicRoute(dynamicSuffixWithParams) : staticRoute); return { isCreatedFromExpense, isEditing, - backToRoute: isCreatedFromExpense ? dynamicBackToRoute : staticBackToRoute, + backToRoute: isCreatedFromExpense ? dynamicBackToRoute : (staticBackToRoute ?? ruleRoute), getRuleRoute, }; } diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index bad4bf53b00f..807384f8d65f 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -2,8 +2,13 @@ import type CONST from '@src/CONST'; import type {ValueOf} from 'type-fest'; -/** Expense field a merchant rule can govern */ -type MerchantRuleSuggestionField = ValueOf; +/** + * Expense field a merchant rule can govern. + * + * Intersected with the edit-request fields so the two lists cannot drift: if a value here ever stops being one + * EDIT_REQUEST_FIELD declares, it drops out of this union and every switch over it stops compiling. + */ +type MerchantRuleSuggestionField = ValueOf & ValueOf; /** * The expense edits that can be turned into a merchant rule, which drive the "Create a rule" callout. @@ -14,8 +19,8 @@ type MerchantRuleSuggestion = { /** The expense most recently edited, which is the one currently offering a rule */ transactionID: string; - /** The transaction thread and its parent expense report, whose expense detail views can show the callout */ - reportIDs: string[]; + /** The expense's own transaction thread, whose expense detail view can show the callout */ + reportID: string; /** * Every field edited so far, keyed by expense. The callout offers all of an expense's fields at once, because the From ce0991ff2fe23f466eb8281cd61af84fdf4caa1e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 02:39:28 +0530 Subject: [PATCH 31/51] Coerce the tag page's orderWeight, which dynamic routes deliver as a string Signed-off-by: krishna2323 --- src/libs/Navigation/types.ts | 2 +- src/pages/workspace/rules/MerchantRules/AddTagPage.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 874673280032..bcaa9300db2d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1688,7 +1688,7 @@ type SettingsNavigatorParamList = { }; [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAG]: { policyID: string; - orderWeight: number; + orderWeight: number | string; ruleID?: undefined; }; [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX]: { diff --git a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx index dc62242c1b63..ff9b11d55e6f 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx @@ -25,7 +25,9 @@ import useMerchantRuleRoute from './useMerchantRuleRoute'; type AddTagPageProps = PlatformStackScreenProps; function AddTagPage({route}: AddTagPageProps) { - const {policyID, ruleID, orderWeight} = route.params; + const {policyID, ruleID, orderWeight: orderWeightParam} = route.params; + // Dynamic routes hand their path params over as strings, so the tag list lookup below would miss without this. + const orderWeight = Number(orderWeightParam); const {backToRoute} = useMerchantRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_TAG_FROM_EXPENSE.path, policyID, ruleID); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); From c5210c2ed3f91670ab2278473f0b7285d506356a Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 02:50:02 +0530 Subject: [PATCH 32/51] Retire the rule suggestion when the report is left, not when the callout unmounts Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 28 +++---------- .../useRetireMerchantRuleSuggestionOnLeave.ts | 40 +++++++++++++++++++ src/pages/inbox/report/ReportActionsList.tsx | 4 ++ src/pages/inbox/report/ReportFooter.tsx | 5 +-- 4 files changed, 51 insertions(+), 26 deletions(-) create mode 100644 src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index e00f4c71f8a7..a58d2d2e7763 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -20,7 +20,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {StyleProp, ViewStyle} from 'react-native'; import {useRoute} from '@react-navigation/native'; -import React, {useEffect, useRef} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Animated, {FadeInDown, FadeInUp, FadeOutDown, FadeOutUp} from 'react-native-reanimated'; @@ -57,27 +57,6 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); const {suggestion, fields, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); - // The offer ends once the user has seen it and navigated away. Retiring leaves the expense itself untouched, so - // editing it again offers afresh. Only the close button silences an expense for the session. - const hasBeenShownRef = useRef(false); - - useEffect(() => { - if (!suggestion) { - return; - } - hasBeenShownRef.current = true; - }, [suggestion]); - - useEffect( - () => () => { - if (!hasBeenShownRef.current) { - return; - } - retireMerchantRuleSuggestion(); - }, - [], - ); - if (!suggestion || !policyID) { return null; } @@ -146,6 +125,9 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle */ function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + // A full-size composer leaves no room for the callout, and on narrow layouts it would sit over the button that + // collapses the composer again. + const [isComposerFullSize] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const {shouldUseNarrowLayout} = useResponsiveLayout(); // A wide RHP reports a narrow layout but lays the expense out like a wide screen, so it belongs to the composer // mount alongside the genuinely wide layouts. @@ -160,7 +142,7 @@ function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles, over // Nothing is stored for most of a session, so skip the inner component and its many Onyx subscriptions until // there is an edit to offer. - if (!isMountForThisLayout || !isMerchantRuleSuggestionLive(storedSuggestion)) { + if (!isMountForThisLayout || isComposerFullSize || !isMerchantRuleSuggestionLive(storedSuggestion)) { return null; } diff --git a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts new file mode 100644 index 000000000000..d4baddcaa674 --- /dev/null +++ b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts @@ -0,0 +1,40 @@ +import {retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; +import {isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import {useEffect, useRef} from 'react'; + +import useOnyx from './useOnyx'; + +/** + * Ends the "Create a rule" offer once the user has seen it on this report and left it behind. + * + * This belongs to the report rather than to the callout itself. The callout mounts and unmounts whenever the layout + * crosses the narrow breakpoint or the composer is expanded, and retiring on any of those would silence an offer the + * user is still looking at, with no way back until they edit the expense again. + */ +function useRetireMerchantRuleSuggestionOnLeave(reportID: string | undefined) { + const [suggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); + const hasBeenShownRef = useRef(false); + const isOfferedHere = !!reportID && suggestion?.reportID === reportID && isMerchantRuleSuggestionLive(suggestion); + + useEffect(() => { + if (!isOfferedHere) { + return; + } + hasBeenShownRef.current = true; + }, [isOfferedHere]); + + useEffect( + () => () => { + if (!hasBeenShownRef.current) { + return; + } + retireMerchantRuleSuggestion(); + }, + [], + ); +} + +export default useRetireMerchantRuleSuggestionOnLeave; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 012e12803dc2..3036c8feae5d 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -11,6 +11,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRetireMerchantRuleSuggestionOnLeave from '@hooks/useRetireMerchantRuleSuggestionOnLeave'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -145,6 +146,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); + // Owned here rather than by the callout, which mounts and unmounts as the layout and the composer change size. + useRetireMerchantRuleSuggestionOnLeave(reportID); + // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 3529f4f84d95..4836a3f05caf 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -107,9 +107,8 @@ function ReportFooter() { ); - // The callout decides for itself whether this mount suits the layout. A full-size composer leaves no room - // for it either way. - const merchantRuleBanner = !isComposerFullSize && ( + // The callout decides for itself whether this mount suits the layout and the composer size. + const merchantRuleBanner = ( Date: Thu, 3 Sep 2026 03:39:27 +0530 Subject: [PATCH 33/51] Derive the merchant rule fields from EDIT_REQUEST_FIELD and use the shared rules check Signed-off-by: krishna2323 --- src/CONST/index.ts | 60 +++++++++++----------- src/libs/actions/IOU/UpdateMoneyRequest.ts | 12 ++--- src/libs/actions/MerchantRuleSuggestion.ts | 22 ++++---- src/types/onyx/MerchantRuleSuggestion.ts | 9 +--- 4 files changed, 50 insertions(+), 53 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 58dc3d374303..d4d39d80c840 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -226,6 +226,27 @@ const EMAIL = { QA_GUIDE: 'qa.guide@team.expensify.com', }; +// Declared up here, rather than inline below, so the subsets of it can be built from its own members instead of +// re-declaring the same strings. Spliced in as `EDIT_REQUEST_FIELD`. +const editRequestFields = { + AMOUNT: 'amount', + CURRENCY: 'currency', + DATE: 'date', + DESCRIPTION: 'description', + MERCHANT: 'merchant', + CATEGORY: 'category', + RECEIPT: 'receipt', + DISTANCE: 'distance', + DISTANCE_RATE: 'distanceRate', + TAG: 'tag', + TAX_RATE: 'taxRate', + TAX_AMOUNT: 'taxAmount', + REIMBURSABLE: 'reimbursable', + ATTENDEES: 'attendees', + BILLABLE: 'billable', + REPORT: 'report', +} as const; + const CONST = { HEIC_SIGNATURES: [ '6674797068656963', // 'ftypheic' - Indicates standard HEIC file @@ -514,20 +535,14 @@ const CONST = { MERCHANT_NAME_MAX_BYTES: 255, - /** - * Expense fields a merchant rule can govern, whose edit shows the "Create a rule" callout on the expense. - * - * These are the same strings EDIT_REQUEST_FIELD uses, since they name the very edits that trigger the callout. - * They cannot be derived from it with Pick, because it is declared further down this same object literal, so - * `MerchantRuleSuggestionField` intersects the two instead and fails the build if they ever drift apart. - */ + /** The subset of EDIT_REQUEST_FIELD a merchant rule can govern, whose edit shows the "Create a rule" callout */ MERCHANT_RULE_SUGGESTION_FIELDS: { - CATEGORY: 'category', - TAG: 'tag', - TAX: 'taxRate', - DESCRIPTION: 'description', - BILLABLE: 'billable', - REIMBURSABLE: 'reimbursable', + CATEGORY: editRequestFields.CATEGORY, + TAG: editRequestFields.TAG, + TAX: editRequestFields.TAX_RATE, + DESCRIPTION: editRequestFields.DESCRIPTION, + BILLABLE: editRequestFields.BILLABLE, + REIMBURSABLE: editRequestFields.REIMBURSABLE, }, MASKED_PAN_PREFIX: 'XXXXXXXXXXXX', @@ -5595,24 +5610,7 @@ const CONST = { SHARE: 'share', }, }, - EDIT_REQUEST_FIELD: { - AMOUNT: 'amount', - CURRENCY: 'currency', - DATE: 'date', - DESCRIPTION: 'description', - MERCHANT: 'merchant', - CATEGORY: 'category', - RECEIPT: 'receipt', - DISTANCE: 'distance', - DISTANCE_RATE: 'distanceRate', - TAG: 'tag', - TAX_RATE: 'taxRate', - TAX_AMOUNT: 'taxAmount', - REIMBURSABLE: 'reimbursable', - ATTENDEES: 'attendees', - BILLABLE: 'billable', - REPORT: 'report', - }, + EDIT_REQUEST_FIELD: editRequestFields, FOOTER: { EXPENSE_MANAGEMENT_URL: `${USE_EXPENSIFY_URL}/expense-management`, SPEND_MANAGEMENT_URL: `${USE_EXPENSIFY_URL}/spend-management`, diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 4f9a5fa316e1..98623ff75979 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -409,7 +409,7 @@ function updateMoneyRequestBillable({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, transactionThreadReport.reportID, policy); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, transactionThreadReport.reportID, policy, policyCategories); } function updateMoneyRequestReimbursable({ @@ -475,7 +475,7 @@ function updateMoneyRequestReimbursable({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, transactionThreadReport.reportID, policy); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, transactionThreadReport.reportID, policy, policyCategories); } /** Updates the merchant field of an expense */ @@ -887,7 +887,7 @@ function updateMoneyRequestTag({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy, policyCategories); } /** Updates the created tax amount of an expense */ @@ -1017,7 +1017,7 @@ function updateMoneyRequestTaxRate({ }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, transactionThreadReport?.reportID, policy); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, transactionThreadReport?.reportID, policy, policyCategories); } type UpdateMoneyRequestDistanceParams = { @@ -1253,7 +1253,7 @@ function updateMoneyRequestCategory({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, transactionThreadReport?.reportID, policy); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, transactionThreadReport?.reportID, policy, policyCategories); } /** Updates the description of an expense */ @@ -1338,7 +1338,7 @@ function updateMoneyRequestDescription({ const {params, onyxData} = data; params.description = parsedComment; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, transactionThreadReport?.reportID, policy); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, transactionThreadReport?.reportID, policy, policyCategories); } /** Updates the distance rate of an expense */ diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index e0ab4d3a8e5b..e06bdba4c14a 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -1,8 +1,7 @@ -// eslint-disable-next-line no-restricted-imports -- Rules is a paid-plan feature, and this mirrors the isPaidGroupPolicy check arePolicyRulesEnabled makes before anything renders -import {isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {arePolicyRulesEnabled} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MerchantRuleSuggestion, Policy} from '@src/types/onyx'; +import type {MerchantRuleSuggestion, Policy, PolicyCategories} from '@src/types/onyx'; import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; import type {OnyxEntry} from 'react-native-onyx'; @@ -21,16 +20,21 @@ import Onyx from 'react-native-onyx'; * Written for every member of a workspace that could carry a merchant rule. `useMerchantRuleSuggestion` decides * whether the callout renders, which additionally needs write access to the Rules feature. */ -function trackMerchantRuleSuggestion(transactionID: string | undefined, field: MerchantRuleSuggestionField, reportID: string | undefined, policy: OnyxEntry) { +function trackMerchantRuleSuggestion( + transactionID: string | undefined, + field: MerchantRuleSuggestionField, + reportID: string | undefined, + policy: OnyxEntry, + policyCategories: OnyxEntry, +) { // An offer only makes sense if the workspace could hold a merchant rule when the edit was made. Recording one // regardless would leave an edit made with Rules switched off sitting here, ready to surface the moment somebody // switched Rules on. // - // This asks the two questions that need no beta, rather than calling arePolicyRulesEnabled: the remaining branch - // there only narrows collect workspaces by the Rules revamp beta, and reading that here would mean threading it - // through six update actions and every one of their callers. Leaving it out is the permissive direction, and - // `useMerchantRuleSuggestion` still checks the beta in full before anything renders. - if (!transactionID || !reportID || !isPaidGroupPolicy(policy) || policy?.areRulesEnabled === false) { + // The Rules revamp beta is assumed on. It is the permissive direction, it only narrows collect workspaces, and it + // is due to be removed, so threading it here would mean six action signatures and every one of their callers for + // plumbing with a short life. `useMerchantRuleSuggestion` checks the beta in full before anything renders. + if (!transactionID || !reportID || !arePolicyRulesEnabled(policy, policyCategories, true)) { return; } diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 807384f8d65f..58b5249e396a 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -2,13 +2,8 @@ import type CONST from '@src/CONST'; import type {ValueOf} from 'type-fest'; -/** - * Expense field a merchant rule can govern. - * - * Intersected with the edit-request fields so the two lists cannot drift: if a value here ever stops being one - * EDIT_REQUEST_FIELD declares, it drops out of this union and every switch over it stops compiling. - */ -type MerchantRuleSuggestionField = ValueOf & ValueOf; +/** Expense field a merchant rule can govern, which is always one of the edit-request fields it is built from */ +type MerchantRuleSuggestionField = ValueOf; /** * The expense edits that can be turned into a merchant rule, which drive the "Create a rule" callout. From 9c7a62e2373b8e2beb290fd969881afff56d1370 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 03:55:26 +0530 Subject: [PATCH 34/51] Shorten and simplify the comments added in this PR Signed-off-by: krishna2323 --- src/CONST/index.ts | 4 +- src/ONYXKEYS.ts | 2 +- .../MerchantRuleSuggestionBanner.tsx | 31 ++++++------- src/hooks/useMerchantRuleSuggestion.ts | 25 +++++------ .../useRetireMerchantRuleSuggestionOnLeave.ts | 7 ++- src/libs/MerchantRuleSuggestionUtils.ts | 23 ++++------ src/libs/actions/MerchantRuleSuggestion.ts | 44 ++++++------------- src/pages/inbox/report/ReportActionsList.tsx | 2 +- .../rules/MerchantRules/AddMatchTypePage.tsx | 4 +- .../MerchantRules/MerchantRulePageBase.tsx | 4 +- .../MerchantRules/useMerchantRuleRoute.ts | 30 ++++++------- src/styles/index.ts | 10 ++--- src/types/onyx/MerchantRuleSuggestion.ts | 25 ++++------- 13 files changed, 85 insertions(+), 126 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d4d39d80c840..ac3d7ce74d32 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -226,8 +226,8 @@ const EMAIL = { QA_GUIDE: 'qa.guide@team.expensify.com', }; -// Declared up here, rather than inline below, so the subsets of it can be built from its own members instead of -// re-declaring the same strings. Spliced in as `EDIT_REQUEST_FIELD`. +// Declared above CONST so subsets can be built from its members instead of repeating the strings. Spliced in below +// as `EDIT_REQUEST_FIELD`. const editRequestFields = { AMOUNT: 'amount', CURRENCY: 'currency', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index ab665d2fe7bd..a7090c362522 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -684,7 +684,7 @@ const ONYXKEYS = { /** Session-scoped flag: user dismissed the "enable notifications" banner in the Concierge chat */ RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER: 'hasDismissedConciergeNotificationBanner', - /** Session-scoped record of the most recent expense edit that can become a merchant rule, which drives the "Create a rule" callout */ + /** Session-scoped record of the latest expense edit that could become a merchant rule, driving the "Create a rule" callout */ RAM_ONLY_MERCHANT_RULE_SUGGESTION: 'merchantRuleSuggestion', NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails', diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index a58d2d2e7763..f1383bf81b82 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -44,8 +44,8 @@ type MerchantRuleSuggestionBannerProps = { overlayStyles?: StyleProp; /** - * Whether this mount point is the one above the composer. It decides both the edge the callout slides in from and - * which layouts it belongs in, since the composer takes the wide layouts and the report list takes the narrow ones. + * Whether this is the mount above the composer. Sets the edge the callout slides in from, and which layouts it + * serves: the composer takes the wide ones, the report list the narrow ones. */ isAnchoredToBottom?: boolean; }; @@ -68,21 +68,18 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle if (!draft) { return; } - // Seed the draft the rule editor reads, then open the same flow used from workspace settings. Opening it as a - // suffix on the expense's own path keeps the expense underneath the modal, so the flow returns here rather - // than to the workspace Rules page. + // Opened as a suffix on the expense's own path, so the expense stays under the modal and the flow returns + // here rather than to the workspace Rules page. setDraftMerchantRule(draft); - // The offer has been taken, so coming back from the rule flow, saved or abandoned, must not find it still - // asking, and the recording that fed it ends here. Editing the expense again starts a fresh offer, gathering - // only what changes from now on rather than repeating fields already carried into this rule. + // The offer was taken, so it must not still be asking on the way back, and the recording ends here. Editing + // the expense again starts fresh instead of repeating fields already in this rule. clearMerchantRuleSuggestionFields(suggestion.transactionID); retireMerchantRuleSuggestion(); Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.getRoute(policyID))); }; - // The callout appears and disappears in place, so it slides out of the edge it is pinned to rather than popping. - // FloatingMessageCounter springs a parked view instead, which this cannot do because it unmounts when there is - // nothing to offer. + // Slides out of the edge it is pinned to rather than popping. FloatingMessageCounter springs a parked view + // instead, which this cannot do because it unmounts when there is nothing to offer. return ( !!editedFields?.[field]); - // A merchant rule matches on merchant, so an expense without one, like a receipt still scanning, can't seed a rule. - // An offer with nothing recorded cannot seed one either, which is how an expense reads once its fields are cleared. + // A rule matches on merchant, so an expense without one (a receipt still scanning) can't seed one. Nor can an + // offer with nothing recorded, which is how an expense reads once its fields are cleared. if (!suggestion || !transaction || isMerchantMissing(transaction) || fields.length === 0) { return {suggestion: undefined, fields: [], transaction: undefined, policy: undefined}; } diff --git a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts index d4baddcaa674..037022d6fce1 100644 --- a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts +++ b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts @@ -8,11 +8,10 @@ import {useEffect, useRef} from 'react'; import useOnyx from './useOnyx'; /** - * Ends the "Create a rule" offer once the user has seen it on this report and left it behind. + * Ends the "Create a rule" offer once the user has seen it on this report and left. * - * This belongs to the report rather than to the callout itself. The callout mounts and unmounts whenever the layout - * crosses the narrow breakpoint or the composer is expanded, and retiring on any of those would silence an offer the - * user is still looking at, with no way back until they edit the expense again. + * Owned by the report, not the callout: the callout unmounts whenever the layout crosses the narrow breakpoint or the + * composer expands, and retiring on those would silence an offer the user is still looking at. */ function useRetireMerchantRuleSuggestionOnLeave(reportID: string | undefined) { const [suggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts index 92ef65b6ab2d..6ea62619caff 100644 --- a/src/libs/MerchantRuleSuggestionUtils.ts +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -9,11 +9,7 @@ import Parser from './Parser'; import {resolveCurrentTaxCode} from './PolicyUtils'; import {getBillable, getCategory, getDescription, getMerchant, getReimbursable, getTag, getTaxCode, isMerchantMissing} from './TransactionUtils'; -/** - * Whether a stored offer is still live: it names an expense, has not been left behind, and that expense has not been - * dismissed this session. Both the callout's cheap outer gate and `useMerchantRuleSuggestion` ask this, so the answer - * is defined once here rather than spelled out in each. - */ +/** Whether a stored offer still stands: it names an expense, was not left behind, and was not dismissed this session. */ function isMerchantRuleSuggestionLive(suggestion: OnyxEntry): boolean { if (!suggestion?.transactionID || suggestion.isRetired) { return false; @@ -29,13 +25,12 @@ function getDraftForField(field: MerchantRuleSuggestionField, transaction: Trans return category ? {category} : {}; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG: { - // Multi-level tags stay in the transaction's colon-joined form, which is what the rule form expects too + // Multi-level tags keep their colon-joined form, which the rule form expects too const tag = getTag(transaction); return tag ? {tag} : {}; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX: { - // The rule form stores the key of the tax in `policy.taxRates.taxes`, which is what a transaction stores - // as its taxCode. A transaction can still carry a code that has since been renamed, so resolve it first. + // A transaction stores the same tax key the rule form uses, but it may have been renamed since. const storedTaxCode = getTaxCode(transaction); const taxCode = storedTaxCode ? resolveCurrentTaxCode(policy, storedTaxCode) : undefined; return taxCode && policy?.taxRates?.taxes?.[taxCode] ? {tax: taxCode} : {}; @@ -45,8 +40,8 @@ function getDraftForField(field: MerchantRuleSuggestionField, transaction: Trans const description = getDescription(transaction); return description ? {comment: Parser.htmlToMarkdown(description)} : {}; } - // Read through the helpers rather than the raw fields, so an unset value seeds the rule with what the expense - // view actually displays. An unset `reimbursable` shows as reimbursable, and would otherwise seed "Don't change". + // Use the helpers so an unset value seeds what the expense view shows. Unset `reimbursable` displays as + // reimbursable, but the raw field would seed "Don't change". case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE: return {billable: getBillable(transaction)}; case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE: @@ -57,10 +52,8 @@ function getDraftForField(field: MerchantRuleSuggestionField, transaction: Trans } /** - * Builds the draft that pre-seeds the rule creation flow when an admin turns their expense edits into a rule. Every - * field they changed since they started editing is carried over, so a rule created after changing category, tag and - * tax arrives with all three filled in. Returns undefined when the expense has no merchant to match on, since a rule - * cannot be saved without one. + * Builds the draft that pre-seeds the rule flow, carrying every field edited so far. Returns undefined when the + * expense has no merchant, since a rule cannot match without one. */ function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry, fields: MerchantRuleSuggestionField[], policy: OnyxEntry): Partial | undefined { if (!transaction || isMerchantMissing(transaction)) { @@ -69,7 +62,7 @@ function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry const draft: Partial = {merchantToMatch: getMerchant(transaction)}; - // Each field contributes a different property, so the order they were edited in does not matter. + // Each field sets a different property, so edit order does not matter. for (const field of fields) { Object.assign(draft, getDraftForField(field, transaction, policy)); } diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index e06bdba4c14a..a097e4261c80 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -9,16 +9,11 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; /** - * Records an edit to a field merchant rules can govern, so the expense detail view can offer to turn it into a rule. - * Edits accumulate per expense: the user is recorded from their first edit until they take the offer, so a rule - * created after changing category, tag and tax carries all three. Only the most recently edited expense offers. + * Records an edit that could become a merchant rule, so the expense can offer to create one. * - * The record is RAM-only, so nothing outlives the session, dismissals included. That is deliberate: the callout is - * dismissed one expense at a time and should appear again on that expense in a later session, which an account-wide - * NVP could not express. - * - * Written for every member of a workspace that could carry a merchant rule. `useMerchantRuleSuggestion` decides - * whether the callout renders, which additionally needs write access to the Rules feature. + * Edits accumulate per expense until the offer is taken, so one rule can carry category, tag and tax together. Only + * the most recently edited expense offers. Recorded for anyone on the workspace; `useMerchantRuleSuggestion` decides + * who actually sees the callout. */ function trackMerchantRuleSuggestion( transactionID: string | undefined, @@ -27,20 +22,17 @@ function trackMerchantRuleSuggestion( policy: OnyxEntry, policyCategories: OnyxEntry, ) { - // An offer only makes sense if the workspace could hold a merchant rule when the edit was made. Recording one - // regardless would leave an edit made with Rules switched off sitting here, ready to surface the moment somebody - // switched Rules on. + // Skip workspaces that could not hold a merchant rule, otherwise an edit made with Rules off would surface the + // moment somebody turned Rules on. // - // The Rules revamp beta is assumed on. It is the permissive direction, it only narrows collect workspaces, and it - // is due to be removed, so threading it here would mean six action signatures and every one of their callers for - // plumbing with a short life. `useMerchantRuleSuggestion` checks the beta in full before anything renders. + // The Rules revamp beta is assumed on: it only narrows collect workspaces, it is being removed, and + // `useMerchantRuleSuggestion` checks it for real before rendering. if (!transactionID || !reportID || !arePolicyRulesEnabled(policy, policyCategories, true)) { return; } - // Merged rather than set so this session's dismissals survive an edit to an already dismissed expense, and so - // `editedFields` gathers this expense's fields rather than replacing them. `isRetired` describes the offer being - // replaced, so it is cleared with it. A fresh edit is a fresh offer, even on an expense the user walked away from. + // Merged rather than set, so dismissals survive and `editedFields` accumulates. `isRetired` belongs to the offer + // being replaced, so it is cleared: a new edit is a new offer. Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { transactionID, reportID, @@ -50,10 +42,8 @@ function trackMerchantRuleSuggestion( } /** - * Dismisses the callout for the expense currently offering it, for the rest of the session. Editing a different - * expense still shows the callout there, and a new session offers this expense again. - * - * @param suggestion - the offer being dismissed, whose already dismissed expenses this one is appended to + * Hides the callout for this expense for the rest of the session. Other expenses still offer, and a new session + * offers this one again. */ function dismissMerchantRuleSuggestion(suggestion: MerchantRuleSuggestion) { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { @@ -61,18 +51,12 @@ function dismissMerchantRuleSuggestion(suggestion: MerchantRuleSuggestion) { }); } -/** - * Forgets what was edited on an expense, so the next rule created from it starts from that point rather than carrying - * fields the user has already turned into a rule. Called when the offer is taken, which ends the recording. - */ +/** Forgets an expense's recorded fields so the next rule starts fresh. Called when the offer is taken. */ function clearMerchantRuleSuggestionFields(transactionID: string) { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {editedFields: {[transactionID]: null}}); } -/** - * Ends the current offer once the user has seen it, without dismissing the expense. Coming back shows nothing, but - * editing the expense again offers afresh. Only the close button silences an expense outright. - */ +/** Ends the current offer without silencing the expense. Returning shows nothing; editing it again offers afresh. */ function retireMerchantRuleSuggestion() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); } diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 3036c8feae5d..78c16f28f0a1 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -146,7 +146,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); - // Owned here rather than by the callout, which mounts and unmounts as the layout and the composer change size. + // Owned here rather than by the callout, which unmounts as the layout and composer change size. useRetireMerchantRuleSuggestionOnLeave(reportID); // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. diff --git a/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx index b41ae6fc7494..aef9c80dd2eb 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx @@ -36,8 +36,8 @@ type MatchTypeItem = ListItem & { function AddMatchTypePage({route}: AddMatchTypePageProps) { const {policyID, ruleID} = route.params; - // This page sits above the merchant-to-match page rather than the rule page, so both flows have to name it: the - // callout flow by dropping this page's own suffix, the settings flow by the route passed here. + // Sits above merchant-to-match, not the rule page, so each flow names it: the callout by dropping this suffix, + // settings by the route passed here. const {backToRoute} = useMerchantRuleRoute( DYNAMIC_ROUTES.RULES_MERCHANT_MATCH_TYPE_FROM_EXPENSE.path, policyID, diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index b855793c2b93..621584063693 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -295,8 +295,8 @@ function MerchantRulePageBase({policyID, ruleID, initialCategoryName, titleKey, } setPolicyCodingRule(policyID, form, policy, ruleID, shouldUpdateMatchingTransactions); if (isCreatedFromExpense) { - // Started from the "Create a rule" callout, so this page is a suffix on the expense's own path. Dropping - // that suffix returns to the expense instead of the workspace Rules page. + // Opened from the callout, so this page is a suffix on the expense's path. Dropping it returns to the + // expense instead of the workspace Rules page. Navigation.goBack(backToRoute); } else if (!isEditing && isRulesRevampEnabled) { Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.EXPENSE_DEFAULTS); diff --git a/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts index bda298cedb3a..844e155e7f45 100644 --- a/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts +++ b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts @@ -10,45 +10,43 @@ import type {Screen} from '@src/SCREENS'; import {useRoute} from '@react-navigation/native'; type MerchantRuleRoute = { - /** Whether these pages were entered from the "Create a rule" callout on an expense rather than workspace settings */ + /** Whether the flow was entered from the "Create a rule" callout rather than workspace settings */ isCreatedFromExpense: boolean; - /** Whether an existing rule is being edited. The callout only ever creates, so it is never editing. */ + /** Whether an existing rule is being edited. The callout only creates, so it is never editing. */ isEditing: boolean; - /** Where this page's back button and its save handler should return to */ + /** Where this page's back button and save handler return to */ backToRoute: Route; /** - * Builds a route to another page of this flow, keeping it in whichever stack the flow was entered from. + * Builds a route to another page of this flow, keeping it in the stack the flow was entered from. * - * @param dynamicSuffixWithParams - the target's dynamic path, with its params filled in + * @param dynamicSuffixWithParams - the target's dynamic path, params filled in * @param staticRoute - the target's workspace settings route */ getRuleRoute: (dynamicSuffixWithParams: string, staticRoute: Route) => Route; }; /** - * Routing for the merchant rule pages, which are reachable both from workspace settings and from the "Create a rule" - * callout on an expense. + * Routing for the merchant rule pages, reachable both from workspace settings and from the "Create a rule" callout. * - * The callout enters through dynamic routes, whose paths are suffixes appended to the expense the user came from, so - * the expense stays underneath the modal. Those pages therefore cannot hardcode the workspace paths: going back means - * dropping their own suffix, and going on means appending the next one. + * The callout enters through dynamic routes, whose paths are suffixes on the expense the user came from, so the + * expense stays under the modal. Those pages cannot hardcode the workspace paths: back drops their own suffix, and + * forward appends the next one. * - * @param dynamicSuffix - this page's own dynamic route path, dropped from the URL to go back + * @param dynamicSuffix - this page's dynamic path, dropped from the URL to go back * @param policyID - the workspace the rule belongs to * @param ruleID - the rule being edited, absent when creating one - * @param staticBackToRoute - where to return in the workspace settings flow, for the pages that sit deeper than the rule page itself + * @param staticBackToRoute - where the settings flow returns to, for pages deeper than the rule page */ function useMerchantRuleRoute(dynamicSuffix: DynamicRouteSuffix, policyID: string, ruleID?: string, staticBackToRoute?: Route): MerchantRuleRoute { const route = useRoute(); - // Asking the linking config keeps this from drifting: a screen added to the flow is dynamic or it is not, with no - // list here to forget to update. + // Asking the linking config avoids a list here that could drift as screens are added. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- useRoute is untyped here because this hook is shared by every page of the flow, and the repo's other isDynamicRouteScreen callers narrow the same way const isCreatedFromExpense = isDynamicRouteScreen(route.name as Screen); - // Only a rule reached through workspace settings can be an edit. `ruleID` is absent on the dynamic routes, and - // `undefined !== 'new'` would otherwise read as editing. + // Only workspace settings can reach an edit. `ruleID` is absent on dynamic routes, and `undefined !== 'new'` + // would otherwise read as editing. const isEditing = !isCreatedFromExpense && !!ruleID && ruleID !== ROUTES.NEW; const dynamicBackToRoute = useDynamicBackPath(dynamicSuffix); const ruleRoute = isEditing && ruleID ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); diff --git a/src/styles/index.ts b/src/styles/index.ts index b1089a6398d8..75c5ba93a11d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4423,8 +4423,7 @@ const staticStyles = (theme: ThemeColors) => borderRadius: variables.componentBorderRadiusNormal, }, - // Pins the callout to the top of the scroll area, like floatingMessageCounterWrapper, so scrolling cannot - // carry it out of sight. + // Pins the callout to the top of the scroll area, like floatingMessageCounterWrapper, so scrolling cannot hide it. merchantRuleCalloutOverlay: { ...positioning.pAbsolute, ...positioning.t0, @@ -4433,8 +4432,7 @@ const staticStyles = (theme: ThemeColors) => zIndex: 100, }, - // Floats the callout above the composer without taking height there, so the conversation does not jump when - // it appears. + // Floats above the composer without taking height, so the conversation does not jump when it appears. merchantRuleCalloutComposerOverlay: { ...positioning.pAbsolute, ...positioning.bFull, @@ -4450,8 +4448,8 @@ const staticStyles = (theme: ThemeColors) => ...wordBreak.breakWord, }, - // The callout sits on a reversed surface, dark in the light theme and light in the dark one, which is why the - // text and the link both use the reversed colors rather than the ordinary ones. + // The callout sits on a reversed surface, dark in the light theme and light in the dark one, so text and link + // use the reversed colors. merchantRuleCalloutAction: { ...textVariants.labelStrong, color: theme.linkReversed, diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 58b5249e396a..a550704ffb32 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -2,38 +2,31 @@ import type CONST from '@src/CONST'; import type {ValueOf} from 'type-fest'; -/** Expense field a merchant rule can govern, which is always one of the edit-request fields it is built from */ +/** Expense field a merchant rule can govern */ type MerchantRuleSuggestionField = ValueOf; /** - * The expense edits that can be turned into a merchant rule, which drive the "Create a rule" callout. + * The expense edits that can become a merchant rule, which drive the "Create a rule" callout. * - * Kept RAM-only so the callout can appear again on the same expense in a future session. + * RAM-only, so the callout can appear again on the same expense next session. */ type MerchantRuleSuggestion = { - /** The expense most recently edited, which is the one currently offering a rule */ + /** The most recently edited expense, the one currently offering */ transactionID: string; - /** The expense's own transaction thread, whose expense detail view can show the callout */ + /** That expense's transaction thread, where the callout can show */ reportID: string; /** - * Every field edited so far, keyed by expense. The callout offers all of an expense's fields at once, because the - * user is recorded from their first edit until they take the offer. Keying by expense is what lets `Onyx.merge` - * accumulate them without reading the record back, and keeps one expense's edits out of another's rule. + * Fields edited so far, keyed by expense. Keying by expense lets `Onyx.merge` accumulate them without a read, and + * keeps one expense's edits out of another's rule. */ editedFields: Record>>; - /** - * Expenses the user dismissed this session. Kept alongside the current offer rather than replaced by it, so a - * dismissal survives a later edit on any expense, including that same one. - */ + /** Expenses dismissed this session. Kept alongside the current offer, so a dismissal survives later edits. */ dismissedTransactionIDs?: string[]; - /** - * Whether the offer has been seen and left behind. Unlike dismissing, it ends only the current offer, so - * returning to the expense shows nothing while editing it again offers afresh. - */ + /** Whether the offer was seen and left. Unlike dismissing, editing the expense again offers afresh. */ isRetired?: boolean; }; From 0c6799aa3b6c63b894e222c61f57f0a012102f3c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 04:08:19 +0530 Subject: [PATCH 35/51] Seed a merchant rule with only the tag levels the user actually edited Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 4 +- src/hooks/useMerchantRuleSuggestion.ts | 7 ++- src/libs/MerchantRuleSuggestionUtils.ts | 54 ++++++++++++++++--- src/libs/actions/IOU/UpdateMoneyRequest.ts | 12 ++++- src/libs/actions/MerchantRuleSuggestion.ts | 3 ++ src/types/onyx/MerchantRuleSuggestion.ts | 6 +++ 6 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index f1383bf81b82..1d2aa3983e6a 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -55,7 +55,7 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle const theme = useTheme(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); - const {suggestion, fields, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); + const {suggestion, fields, editedTagLevels, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); if (!suggestion || !policyID) { return null; @@ -64,7 +64,7 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle const dismiss = () => dismissMerchantRuleSuggestion(suggestion); const createRule = () => { - const draft = getMerchantRuleDraftFromTransaction(transaction, fields, policy); + const draft = getMerchantRuleDraftFromTransaction(transaction, fields, policy, editedTagLevels); if (!draft) { return; } diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index 124c23508963..108e766cab3b 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -20,6 +20,9 @@ type MerchantRuleSuggestionResult = { /** Every field edited on that expense so far, which the rule is pre-seeded from */ fields: MerchantRuleSuggestionField[]; + /** Which levels of a multi-level tag were edited, so untouched levels stay out of the rule */ + editedTagLevels: Record | undefined; + /** The edited expense, needed to pre-seed the rule */ transaction: Transaction | undefined; @@ -62,10 +65,10 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin // A rule matches on merchant, so an expense without one (a receipt still scanning) can't seed one. Nor can an // offer with nothing recorded, which is how an expense reads once its fields are cleared. if (!suggestion || !transaction || isMerchantMissing(transaction) || fields.length === 0) { - return {suggestion: undefined, fields: [], transaction: undefined, policy: undefined}; + return {suggestion: undefined, fields: [], editedTagLevels: undefined, transaction: undefined, policy: undefined}; } - return {suggestion, fields, transaction, policy}; + return {suggestion, fields, editedTagLevels: suggestion.editedTagLevels?.[suggestion.transactionID], transaction, policy}; } export default useMerchantRuleSuggestion; diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts index 6ea62619caff..ed4968ad5997 100644 --- a/src/libs/MerchantRuleSuggestionUtils.ts +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -7,7 +7,8 @@ import type {OnyxEntry} from 'react-native-onyx'; import Parser from './Parser'; import {resolveCurrentTaxCode} from './PolicyUtils'; -import {getBillable, getCategory, getDescription, getMerchant, getReimbursable, getTag, getTaxCode, isMerchantMissing} from './TransactionUtils'; +import {trimTag} from './TagUtils'; +import {getBillable, getCategory, getDescription, getMerchant, getReimbursable, getTag, getTagArrayFromName, getTaxCode, isMerchantMissing} from './TransactionUtils'; /** Whether a stored offer still stands: it names an expense, was not left behind, and was not dismissed this session. */ function isMerchantRuleSuggestionLive(suggestion: OnyxEntry): boolean { @@ -17,17 +18,51 @@ function isMerchantRuleSuggestionLive(suggestion: OnyxEntry): Partial { +function getDraftForField( + field: MerchantRuleSuggestionField, + transaction: Transaction, + policy: OnyxEntry, + editedTagLevels: Record | undefined, +): Partial { switch (field) { case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY: { const category = getCategory(transaction); return category ? {category} : {}; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG: { - // Multi-level tags keep their colon-joined form, which the rule form expects too + // Multi-level tags keep their colon-joined form, which the rule form expects too. Levels the user did not + // edit are blanked, so a rule from one edited level leaves the rest for the matched expense to decide. + // Without recorded levels, which is any single-level tag, the whole tag is carried over. const tag = getTag(transaction); - return tag ? {tag} : {}; + if (!editedTagLevels) { + return tag ? {tag} : {}; + } + const editedTag = trimTag( + getTagArrayFromName(tag) + .map((level, index) => (editedTagLevels[index] ? level : '')) + .join(':'), + ); + return editedTag ? {tag: editedTag} : {}; } case CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX: { // A transaction stores the same tax key the rule form uses, but it may have been renamed since. @@ -55,7 +90,12 @@ function getDraftForField(field: MerchantRuleSuggestionField, transaction: Trans * Builds the draft that pre-seeds the rule flow, carrying every field edited so far. Returns undefined when the * expense has no merchant, since a rule cannot match without one. */ -function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry, fields: MerchantRuleSuggestionField[], policy: OnyxEntry): Partial | undefined { +function getMerchantRuleDraftFromTransaction( + transaction: OnyxEntry, + fields: MerchantRuleSuggestionField[], + policy: OnyxEntry, + editedTagLevels?: Record, +): Partial | undefined { if (!transaction || isMerchantMissing(transaction)) { return undefined; } @@ -64,10 +104,10 @@ function getMerchantRuleDraftFromTransaction(transaction: OnyxEntry // Each field sets a different property, so edit order does not matter. for (const field of fields) { - Object.assign(draft, getDraftForField(field, transaction, policy)); + Object.assign(draft, getDraftForField(field, transaction, policy, editedTagLevels)); } return draft; } -export {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive}; +export {getChangedTagLevels, getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive}; diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 98623ff75979..1702c3aef76f 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -5,6 +5,7 @@ import type {UpdateMoneyRequestParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +import {getChangedTagLevels} from '@libs/MerchantRuleSuggestionUtils'; import {buildOptimisticNextStep} from '@libs/NextStepUtils'; import {rand64} from '@libs/NumberUtils'; import {hasDependentTags, isGroupPolicy, isTaxTrackingEnabled} from '@libs/PolicyUtils'; @@ -28,6 +29,7 @@ import { getClearedPendingFields, getDistanceRateTaxUpdates, getMerchant, + getTag, getUpdatedTransaction, hasLocallyKnownDistance, hasSubmissionBlockingViolationInReport, @@ -887,7 +889,15 @@ function updateMoneyRequestTag({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy, policyCategories); + // `transaction` still holds the tag as it was, so comparing it with the new one says which levels the user edited. + trackMerchantRuleSuggestion( + transactionID, + CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, + transactionThreadReport?.reportID, + policy, + policyCategories, + transaction ? getChangedTagLevels(getTag(transaction), tag) : undefined, + ); } /** Updates the created tax amount of an expense */ diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index a097e4261c80..83d53add3c53 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -21,6 +21,7 @@ function trackMerchantRuleSuggestion( reportID: string | undefined, policy: OnyxEntry, policyCategories: OnyxEntry, + editedTagLevels?: number[], ) { // Skip workspaces that could not hold a merchant rule, otherwise an edit made with Rules off would surface the // moment somebody turned Rules on. @@ -37,6 +38,8 @@ function trackMerchantRuleSuggestion( transactionID, reportID, editedFields: {[transactionID]: {[field]: true}}, + // Keyed by level so editing several levels of one tag accumulates, the same way fields do. + ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, true]))}} : {}), isRetired: null, }); } diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index a550704ffb32..08e46758dea1 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -23,6 +23,12 @@ type MerchantRuleSuggestion = { */ editedFields: Record>>; + /** + * Which levels of a multi-level tag were edited, keyed by expense then by level. Only these levels seed the rule, + * so editing one level does not commit the rule to the levels the user left alone. + */ + editedTagLevels?: Record>; + /** Expenses dismissed this session. Kept alongside the current offer, so a dismissal survives later edits. */ dismissedTransactionIDs?: string[]; From 65627723bc0bd742bcdee5b86da653cc1cfcabc5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 3 Sep 2026 04:14:53 +0530 Subject: [PATCH 36/51] Take the edited tag level from the caller, and clear it once the rule is created Signed-off-by: krishna2323 --- .../ReportActionItem/MoneyRequestView.tsx | 1 + src/libs/actions/IOU/UpdateMoneyRequest.ts | 21 +++++++++++-------- src/libs/actions/MerchantRuleSuggestion.ts | 7 +++++-- .../request/step/DynamicIOURequestStepTag.tsx | 1 + 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index ba094f7ae0cd..310a9df1d7b4 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -947,6 +947,7 @@ function MoneyRequestView({ parentReport, iouReportOwnerLogin, tag: updatedTag, + tagListIndex, policy, policyTagList, policyRecentlyUsedTags: undefined, diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 1702c3aef76f..847c4d3783b7 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -824,6 +824,8 @@ type UpdateMoneyRequestTagParams = { parentReport: OnyxEntry; iouReportOwnerLogin: string | undefined; tag: string; + /** Which level of a multi-level tag was edited, so the "Create a rule" callout can seed that level alone */ + tagListIndex?: number; policy: OnyxEntry; policyTagList: OnyxEntry; policyRecentlyUsedTags: OnyxEntry; @@ -848,6 +850,7 @@ function updateMoneyRequestTag({ parentReport, iouReportOwnerLogin, tag, + tagListIndex, policy, policyTagList, policyRecentlyUsedTags, @@ -889,15 +892,15 @@ function updateMoneyRequestTag({ getCurrencySymbol, }); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); - // `transaction` still holds the tag as it was, so comparing it with the new one says which levels the user edited. - trackMerchantRuleSuggestion( - transactionID, - CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, - transactionThreadReport?.reportID, - policy, - policyCategories, - transaction ? getChangedTagLevels(getTag(transaction), tag) : undefined, - ); + // Callers that edit one level of a multi-level tag say which. The rest, like the Search table, hand over a whole + // tag, so the edited levels come from comparing it with the one `transaction` still holds. + let editedTagLevels: number[] | undefined; + if (tagListIndex !== undefined) { + editedTagLevels = [tagListIndex]; + } else if (transaction) { + editedTagLevels = getChangedTagLevels(getTag(transaction), tag); + } + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy, policyCategories, editedTagLevels); } /** Updates the created tax amount of an expense */ diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index 83d53add3c53..28fc99e667f9 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -54,9 +54,12 @@ function dismissMerchantRuleSuggestion(suggestion: MerchantRuleSuggestion) { }); } -/** Forgets an expense's recorded fields so the next rule starts fresh. Called when the offer is taken. */ +/** + * Forgets an expense's recorded fields, and the tag levels alongside them, so the next rule starts fresh. Called when + * the offer is taken. + */ function clearMerchantRuleSuggestionFields(transactionID: string) { - Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {editedFields: {[transactionID]: null}}); + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {editedFields: {[transactionID]: null}, editedTagLevels: {[transactionID]: null}}); } /** Ends the current offer without silencing the expense. Returning shows nothing; editing it again offers afresh. */ diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx index 69f51692108b..7c56d0909078 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx @@ -173,6 +173,7 @@ function DynamicIOURequestStepTag({ parentReport, iouReportOwnerLogin, tag: updatedTag, + tagListIndex, policy, policyTagList: policyTags, policyRecentlyUsedTags, From 7d84ba005e39df2f341d512e68629010f830fe5f Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 6 Sep 2026 23:41:32 +0530 Subject: [PATCH 37/51] Retire the offer when the expense page is left, and skip offers that would change nothing Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 14 ++++++++++-- src/hooks/useMerchantRuleSuggestion.ts | 13 ++++++++--- .../useRetireMerchantRuleSuggestionOnLeave.ts | 22 ++++++++++--------- src/libs/actions/MerchantRuleSuggestion.ts | 8 ++++++- src/pages/inbox/report/ReportActionsList.tsx | 2 +- src/types/onyx/MerchantRuleSuggestion.ts | 6 +++++ 6 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index 1d2aa3983e6a..59143ff32616 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -6,7 +6,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearMerchantRuleSuggestionFields, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; +import {clearMerchantRuleSuggestionFields, dismissMerchantRuleSuggestion, markMerchantRuleSuggestionSeen, retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion'; import {setDraftMerchantRule} from '@libs/actions/User'; import {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; @@ -20,7 +20,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {StyleProp, ViewStyle} from 'react-native'; import {useRoute} from '@react-navigation/native'; -import React from 'react'; +import React, {useEffect} from 'react'; import {View} from 'react-native'; import Animated, {FadeInDown, FadeInUp, FadeOutDown, FadeOutUp} from 'react-native-reanimated'; @@ -56,6 +56,16 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); const {suggestion, fields, editedTagLevels, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); + const isShowing = !!suggestion && !!policyID; + + // Recorded so leaving the report can retire the offer. The report cannot work this out for itself, because the + // one showing an expense is not always the one the edit was recorded against. + useEffect(() => { + if (!isShowing || suggestion?.wasSeen) { + return; + } + markMerchantRuleSuggestionSeen(); + }, [isShowing, suggestion?.wasSeen]); if (!suggestion || !policyID) { return null; diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index 108e766cab3b..a46caeeb2762 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -1,10 +1,11 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; +import {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; import {arePolicyRulesEnabled} from '@libs/PolicyUtils'; import {isMerchantMissing} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/MerchantRuleForm'; import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx'; import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; @@ -62,13 +63,19 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const editedFields = suggestion ? suggestion.editedFields?.[suggestion.transactionID] : undefined; const fields = Object.values(CONST.MERCHANT_RULE_SUGGESTION_FIELDS).filter((field) => !!editedFields?.[field]); + const editedTagLevels = suggestion?.editedTagLevels?.[suggestion.transactionID]; + // Built here rather than only on press, so an offer that would apply nothing never appears. Clearing a field is + // still an edit worth recording, but the rule it would make sets that field to empty, which changes nothing. + const draft = suggestion && transaction ? getMerchantRuleDraftFromTransaction(transaction, fields, policy, editedTagLevels) : undefined; + const hasUpdatesToApply = !!draft && Object.keys(draft).some((key) => key !== INPUT_IDS.MERCHANT_TO_MATCH); + // A rule matches on merchant, so an expense without one (a receipt still scanning) can't seed one. Nor can an // offer with nothing recorded, which is how an expense reads once its fields are cleared. - if (!suggestion || !transaction || isMerchantMissing(transaction) || fields.length === 0) { + if (!suggestion || !transaction || isMerchantMissing(transaction) || fields.length === 0 || !hasUpdatesToApply) { return {suggestion: undefined, fields: [], editedTagLevels: undefined, transaction: undefined, policy: undefined}; } - return {suggestion, fields, editedTagLevels: suggestion.editedTagLevels?.[suggestion.transactionID], transaction, policy}; + return {suggestion, fields, editedTagLevels, transaction, policy}; } export default useMerchantRuleSuggestion; diff --git a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts index 037022d6fce1..f5a329eb08a4 100644 --- a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts +++ b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts @@ -8,26 +8,28 @@ import {useEffect, useRef} from 'react'; import useOnyx from './useOnyx'; /** - * Ends the "Create a rule" offer once the user has seen it on this report and left. + * Ends the "Create a rule" offer once the user has seen it and left the report showing it. * * Owned by the report, not the callout: the callout unmounts whenever the layout crosses the narrow breakpoint or the * composer expands, and retiring on those would silence an offer the user is still looking at. + * + * Whether it was seen is read from the record rather than matched against this report, because the report showing an + * expense is not always the one the edit was recorded against. An expense report holding a single expense renders the + * detail view under its own reportID, so matching here would never fire and the offer would outlive the page. */ -function useRetireMerchantRuleSuggestionOnLeave(reportID: string | undefined) { +function useRetireMerchantRuleSuggestionOnLeave() { const [suggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); - const hasBeenShownRef = useRef(false); - const isOfferedHere = !!reportID && suggestion?.reportID === reportID && isMerchantRuleSuggestionLive(suggestion); + const hasBeenSeenRef = useRef(false); + // Tracks the live value rather than latching, so an offer dismissed on the way out is not retired as well. + const isSeenAndLive = !!suggestion?.wasSeen && isMerchantRuleSuggestionLive(suggestion); useEffect(() => { - if (!isOfferedHere) { - return; - } - hasBeenShownRef.current = true; - }, [isOfferedHere]); + hasBeenSeenRef.current = isSeenAndLive; + }, [isSeenAndLive]); useEffect( () => () => { - if (!hasBeenShownRef.current) { + if (!hasBeenSeenRef.current) { return; } retireMerchantRuleSuggestion(); diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index 28fc99e667f9..409f3d9a04ed 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -40,10 +40,16 @@ function trackMerchantRuleSuggestion( editedFields: {[transactionID]: {[field]: true}}, // Keyed by level so editing several levels of one tag accumulates, the same way fields do. ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, true]))}} : {}), + wasSeen: null, isRetired: null, }); } +/** Records that the callout rendered, which is what makes leaving the expense retire the offer. */ +function markMerchantRuleSuggestionSeen() { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {wasSeen: true}); +} + /** * Hides the callout for this expense for the rest of the session. Other expenses still offer, and a new session * offers this one again. @@ -67,4 +73,4 @@ function retireMerchantRuleSuggestion() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); } -export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, retireMerchantRuleSuggestion, clearMerchantRuleSuggestionFields}; +export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, markMerchantRuleSuggestionSeen, retireMerchantRuleSuggestion, clearMerchantRuleSuggestionFields}; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 78c16f28f0a1..a7726b062be0 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -147,7 +147,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); // Owned here rather than by the callout, which unmounts as the layout and composer change size. - useRetireMerchantRuleSuggestionOnLeave(reportID); + useRetireMerchantRuleSuggestionOnLeave(); // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index 08e46758dea1..c9b64099a90b 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -32,6 +32,12 @@ type MerchantRuleSuggestion = { /** Expenses dismissed this session. Kept alongside the current offer, so a dismissal survives later edits. */ dismissedTransactionIDs?: string[]; + /** + * Whether the callout actually rendered. Set by the callout itself, since the report showing an expense is not + * always the one the edit was recorded against: a report holding a single expense shows the detail view too. + */ + wasSeen?: boolean; + /** Whether the offer was seen and left. Unlike dismissing, editing the expense again offers afresh. */ isRetired?: boolean; }; From 062c134ad1a76938cf41cce036d176d6c280b747 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 6 Sep 2026 23:52:12 +0530 Subject: [PATCH 38/51] Drop the callout instantly when the composer expands, without animating out Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index 59143ff32616..fff6b0a9ba31 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -50,7 +50,12 @@ type MerchantRuleSuggestionBannerProps = { isAnchoredToBottom?: boolean; }; -function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { +type MerchantRuleSuggestionBannerContentProps = MerchantRuleSuggestionBannerProps & { + /** Whether the composer is expanded, which leaves no room for the callout */ + isComposerFullSize: boolean; +}; + +function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom, isComposerFullSize}: MerchantRuleSuggestionBannerContentProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -90,38 +95,44 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle // Slides out of the edge it is pinned to rather than popping. FloatingMessageCounter springs a parked view // instead, which this cannot do because it unmounts when there is nothing to offer. + // + // The composer check sits inside rather than around this wrapper on purpose. Expanding the composer should take + // the callout away at once, and unmounting the wrapper would instead play the exit animation over the composer + // as it grows. Emptying it leaves nothing to see while keeping the animation for an actual dismissal. return ( - - - - - - - {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} - - {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} - - - } - /> + {!isComposerFullSize && ( + + + + + + + {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} + + {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} + + + } + /> + )} ); } @@ -133,8 +144,9 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles, overlayStyles, isAnchoredToBottom}: MerchantRuleSuggestionBannerProps) { const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); // A full-size composer leaves no room for the callout, and on narrow layouts it would sit over the button that - // collapses the composer again. - const [isComposerFullSize] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); + // collapses the composer again. Handled inside the content rather than here, so the callout goes at once instead + // of animating out over the composer as it grows. + const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`); const {shouldUseNarrowLayout} = useResponsiveLayout(); // A wide RHP reports a narrow layout but looks wide, so it belongs to the composer mount. const route = useRoute(); @@ -147,7 +159,7 @@ function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles, over // Nothing is stored for most of a session, so skip the inner component and its Onyx subscriptions until there is // an edit to offer. - if (!isMountForThisLayout || isComposerFullSize || !isMerchantRuleSuggestionLive(storedSuggestion)) { + if (!isMountForThisLayout || !isMerchantRuleSuggestionLive(storedSuggestion)) { return null; } @@ -158,6 +170,7 @@ function MerchantRuleSuggestionBanner({reportID, policyID, containerStyles, over containerStyles={containerStyles} overlayStyles={overlayStyles} isAnchoredToBottom={isAnchoredToBottom} + isComposerFullSize={isComposerFullSize} /> ); } From 4281f4804c0fb4d70640c1798b5c05d15eb4698d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 7 Sep 2026 00:00:32 +0530 Subject: [PATCH 39/51] Seed the rule type from the callout so it opens the merchant editor directly Signed-off-by: krishna2323 --- src/hooks/useMerchantRuleSuggestion.ts | 2 +- src/libs/MerchantRuleSuggestionUtils.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index a46caeeb2762..5a23056c84a1 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -67,7 +67,7 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin // Built here rather than only on press, so an offer that would apply nothing never appears. Clearing a field is // still an edit worth recording, but the rule it would make sets that field to empty, which changes nothing. const draft = suggestion && transaction ? getMerchantRuleDraftFromTransaction(transaction, fields, policy, editedTagLevels) : undefined; - const hasUpdatesToApply = !!draft && Object.keys(draft).some((key) => key !== INPUT_IDS.MERCHANT_TO_MATCH); + const hasUpdatesToApply = !!draft && Object.keys(draft).some((key) => key !== INPUT_IDS.MERCHANT_TO_MATCH && key !== INPUT_IDS.RULE_TYPE); // A rule matches on merchant, so an expense without one (a receipt still scanning) can't seed one. Nor can an // offer with nothing recorded, which is how an expense reads once its fields are cleared. diff --git a/src/libs/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts index ed4968ad5997..239c11e38f80 100644 --- a/src/libs/MerchantRuleSuggestionUtils.ts +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -100,7 +100,9 @@ function getMerchantRuleDraftFromTransaction( return undefined; } - const draft: Partial = {merchantToMatch: getMerchant(transaction)}; + // The callout only ever creates a merchant rule, so it names the type itself. Without it the editor bounces to the + // type chooser, which would drop the user a step back from where the callout promised to take them. + const draft: Partial = {ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT, merchantToMatch: getMerchant(transaction)}; // Each field sets a different property, so edit order does not matter. for (const field of fields) { From 3b31b8913c7227c763924550302fa6fcfed0b352 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 7 Sep 2026 14:22:07 +0530 Subject: [PATCH 40/51] Paint the create-a-rule callout above the new-messages pill Signed-off-by: krishna2323 --- src/styles/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 78c6b2f27ef6..bdfd1172d2b0 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4432,12 +4432,14 @@ const staticStyles = (theme: ThemeColors) => }, // Pins the callout to the top of the scroll area, like floatingMessageCounterWrapper, so scrolling cannot hide it. + // Both occupy that strip, so the callout sits one layer above: it is dismissible, and the "New messages" pill + // underneath it stays reachable once the callout is gone. merchantRuleCalloutOverlay: { ...positioning.pAbsolute, ...positioning.t0, ...positioning.l0, ...positioning.r0, - zIndex: 100, + zIndex: 101, }, // Floats above the composer without taking height, so the conversation does not jump when it appears. From fd93496e565c0bbb2ca44a2282742400f1a94d5e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 8 Sep 2026 22:57:29 +0530 Subject: [PATCH 41/51] Hide the callout on Collect, where the rule page is out of reach Signed-off-by: krishna2323 --- src/hooks/useMerchantRuleSuggestion.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index 5a23056c84a1..3f4475094325 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -1,6 +1,6 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; -import {arePolicyRulesEnabled} from '@libs/PolicyUtils'; +import {arePolicyRulesEnabled, isControlPolicy} from '@libs/PolicyUtils'; import {isMerchantMissing} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -10,7 +10,6 @@ import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx' import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; import useOnyx from './useOnyx'; -import usePermissions from './usePermissions'; import usePolicyFeatureWriteAccess from './usePolicyFeatureWriteAccess'; import useReportTransactions from './useReportTransactions'; @@ -38,8 +37,6 @@ type MerchantRuleSuggestionResult = { * @param reportID - the report showing the expense: its transaction thread, or a report holding only that expense */ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: string | undefined): MerchantRuleSuggestionResult { - const {isBetaEnabled} = usePermissions(); - const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -53,9 +50,10 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin const isOneExpenseReportForSuggestion = reportTransactions.length === 1 && reportTransactions.at(0)?.transactionID === storedSuggestion?.transactionID; const isHostingReport = !!reportID && (reportID === storedSuggestion?.reportID || isOneExpenseReportForSuggestion); const isForThisExpenseView = isMerchantRuleSuggestionLive(storedSuggestion) && isHostingReport; - // Offer it to exactly who the rule page lets in: write access to Rules. Admins today, and editors, who can - // already create the same rule from workspace settings. - const canCreateMerchantRule = canWriteRules && arePolicyRulesEnabled(policy, policyCategories, isBetaEnabled(CONST.BETAS.RULES_REVAMP)); + // Offer it to exactly who the rule page lets in: write access to Rules on a Control workspace. Admins today, and + // editors, who can already create the same rule from workspace settings. The rule page is Control-only, so + // without that check a Collect workspace would be offered a callout that lands on Not Found. + const canCreateMerchantRule = canWriteRules && isControlPolicy(policy) && arePolicyRulesEnabled(policy, policyCategories); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; // Filtered from the canonical list, not the record's own keys, so the order is fixed and only known fields reach From 8050a3e45d22afd98e769799509249395fa0b100 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 8 Sep 2026 23:18:25 +0530 Subject: [PATCH 42/51] Hold the create-a-rule callout behind the rules revamp beta Signed-off-by: krishna2323 --- src/hooks/useMerchantRuleSuggestion.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts index 3f4475094325..08cf00397306 100644 --- a/src/hooks/useMerchantRuleSuggestion.ts +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -10,6 +10,7 @@ import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx' import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; import usePolicyFeatureWriteAccess from './usePolicyFeatureWriteAccess'; import useReportTransactions from './useReportTransactions'; @@ -37,6 +38,8 @@ type MerchantRuleSuggestionResult = { * @param reportID - the report showing the expense: its transaction thread, or a report holding only that expense */ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: string | undefined): MerchantRuleSuggestionResult { + const {isBetaEnabled} = usePermissions(); + const [storedSuggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); @@ -53,7 +56,10 @@ function useMerchantRuleSuggestion(reportID: string | undefined, policyID: strin // Offer it to exactly who the rule page lets in: write access to Rules on a Control workspace. Admins today, and // editors, who can already create the same rule from workspace settings. The rule page is Control-only, so // without that check a Collect workspace would be offered a callout that lands on Not Found. - const canCreateMerchantRule = canWriteRules && isControlPolicy(policy) && arePolicyRulesEnabled(policy, policyCategories); + // + // The callout ships with the rules revamp, so it waits for that beta. This is its own condition rather than the + // one `arePolicyRulesEnabled` takes, which decides something else: whether Collect can reach Rules at all. + const canCreateMerchantRule = isBetaEnabled(CONST.BETAS.RULES_REVAMP) && canWriteRules && isControlPolicy(policy) && arePolicyRulesEnabled(policy, policyCategories); const suggestion = isForThisExpenseView && canCreateMerchantRule ? storedSuggestion : undefined; // Filtered from the canonical list, not the record's own keys, so the order is fixed and only known fields reach From 530ecdd6e38382c724d71f14d0b86c589ec6d826 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 9 Sep 2026 08:15:08 +0530 Subject: [PATCH 43/51] Stop the callout flickering in, and show it only on the focused report view Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 53 ++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index fff6b0a9ba31..6f914bc1d87f 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -11,18 +11,20 @@ import {setDraftMerchantRule} from '@libs/actions/User'; import {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {StyleProp, ViewStyle} from 'react-native'; -import {useRoute} from '@react-navigation/native'; +import {useIsFocused, useRoute} from '@react-navigation/native'; import React, {useEffect} from 'react'; import {View} from 'react-native'; -import Animated, {FadeInDown, FadeInUp, FadeOutDown, FadeOutUp} from 'react-native-reanimated'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Banner from './Banner'; import Icon from './Icon'; @@ -30,6 +32,9 @@ import Text from './Text'; import TextLink from './TextLink'; import {useWideRHPState} from './WideRHPContextProvider'; +/** How far the callout travels on its way in, matching the distance reanimated's FadeInUp and FadeInDown use */ +const CALLOUT_SLIDE_DISTANCE = 25; + type MerchantRuleSuggestionBannerProps = { /** The report hosting the expense detail view: a transaction thread, its expense report, or the chat it lives in */ reportID: string | undefined; @@ -63,6 +68,31 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle const {suggestion, fields, editedTagLevels, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); const isShowing = !!suggestion && !!policyID; + // Slid in from the edge it is pinned to by a parked view, the way FloatingMessageCounter does it. A reanimated + // entering animation looked right when the callout arrived with a fresh page, but a toggle edit leaves the user + // on the page it appears in, and there the animation fought the surrounding layout and flickered. + const slideOffset = isAnchoredToBottom ? CALLOUT_SLIDE_DISTANCE : -CALLOUT_SLIDE_DISTANCE; + const translateY = useSharedValue(slideOffset); + const opacity = useSharedValue(0); + + // Held until any navigation transition finishes. Coming back from a field's edit page mounts the callout while the + // page is still sliding, and the slide would be over before the page arrived. Nothing is transitioning after a + // toggle edit, where the callout appears on the page the user is already on, so there it starts at once. + useEffect(() => { + const handle = TransitionTracker.runAfterTransitions({ + callback: () => { + translateY.set(withTiming(0, {duration: CONST.ANIMATED_TRANSITION})); + opacity.set(withTiming(1, {duration: CONST.ANIMATED_TRANSITION})); + }, + }); + return handle.cancel; + }, [translateY, opacity]); + + const slideStyle = useAnimatedStyle(() => ({ + opacity: opacity.get(), + transform: [{translateY: translateY.get()}], + })); + // Recorded so leaving the report can retire the offer. The report cannot work this out for itself, because the // one showing an expense is not always the one the edit was recorded against. useEffect(() => { @@ -93,18 +123,10 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.RULES_MERCHANT_NEW_FROM_EXPENSE.getRoute(policyID))); }; - // Slides out of the edge it is pinned to rather than popping. FloatingMessageCounter springs a parked view - // instead, which this cannot do because it unmounts when there is nothing to offer. - // // The composer check sits inside rather than around this wrapper on purpose. Expanding the composer should take - // the callout away at once, and unmounting the wrapper would instead play the exit animation over the composer - // as it grows. Emptying it leaves nothing to see while keeping the animation for an actual dismissal. + // the callout away at once, and emptying the wrapper does that without disturbing the slide. return ( - + {!isComposerFullSize && ( Date: Wed, 9 Sep 2026 08:22:34 +0530 Subject: [PATCH 44/51] Spring the callout in instead of fading it with a layout animation Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index 6f914bc1d87f..1fea4b5dd093 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -24,7 +24,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import {useIsFocused, useRoute} from '@react-navigation/native'; import React, {useEffect} from 'react'; import {View} from 'react-native'; -import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {Easing, useAnimatedStyle, useSharedValue, withSpring, withTiming} from 'react-native-reanimated'; import Banner from './Banner'; import Icon from './Icon'; @@ -32,8 +32,12 @@ import Text from './Text'; import TextLink from './TextLink'; import {useWideRHPState} from './WideRHPContextProvider'; -/** How far the callout travels on its way in, matching the distance reanimated's FadeInUp and FadeInDown use */ -const CALLOUT_SLIDE_DISTANCE = 25; +/** How far the callout travels on its way in, far enough for the spring to read as movement rather than a nudge */ +const CALLOUT_SLIDE_DISTANCE = 40; + +// The spring carries the movement, so the fade only has to stop the callout appearing before it has travelled. Linear +// because that is what reanimated's own fade keyframes run at on web. +const CALLOUT_FADE_CONFIG = {duration: CONST.ANIMATED_TRANSITION, easing: Easing.linear}; type MerchantRuleSuggestionBannerProps = { /** The report hosting the expense detail view: a transaction thread, its expense report, or the chat it lives in */ @@ -68,9 +72,9 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle const {suggestion, fields, editedTagLevels, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); const isShowing = !!suggestion && !!policyID; - // Slid in from the edge it is pinned to by a parked view, the way FloatingMessageCounter does it. A reanimated - // entering animation looked right when the callout arrived with a fresh page, but a toggle edit leaves the user - // on the page it appears in, and there the animation fought the surrounding layout and flickered. + // Sprung in from the edge it is pinned to, the way FloatingMessageCounter moves its pill. A reanimated entering + // animation looked right when the callout arrived with a fresh page, but a toggle edit leaves the user on the page + // it appears in, and there the animation fought the surrounding layout and flickered. const slideOffset = isAnchoredToBottom ? CALLOUT_SLIDE_DISTANCE : -CALLOUT_SLIDE_DISTANCE; const translateY = useSharedValue(slideOffset); const opacity = useSharedValue(0); @@ -81,8 +85,8 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle useEffect(() => { const handle = TransitionTracker.runAfterTransitions({ callback: () => { - translateY.set(withTiming(0, {duration: CONST.ANIMATED_TRANSITION})); - opacity.set(withTiming(1, {duration: CONST.ANIMATED_TRANSITION})); + translateY.set(withSpring(0)); + opacity.set(withTiming(1, CALLOUT_FADE_CONFIG)); }, }); return handle.cancel; From 4880d5a3589fb6a27d0069adf152a5de6fb742b5 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 9 Sep 2026 08:33:41 +0530 Subject: [PATCH 45/51] Keep hidden offers unseen, and stop the retire hook waking the report list Signed-off-by: krishna2323 --- src/components/MerchantRuleSuggestionBanner.tsx | 7 +++++-- src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts | 13 ++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index 1fea4b5dd093..ceb31a7dc4ac 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -99,12 +99,15 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle // Recorded so leaving the report can retire the offer. The report cannot work this out for itself, because the // one showing an expense is not always the one the edit was recorded against. + // + // A full-size composer hides the callout, and composer size is remembered per report. Marking it seen there would + // retire an offer the user never had a chance to read. useEffect(() => { - if (!isShowing || suggestion?.wasSeen) { + if (!isShowing || isComposerFullSize || suggestion?.wasSeen) { return; } markMerchantRuleSuggestionSeen(); - }, [isShowing, suggestion?.wasSeen]); + }, [isShowing, isComposerFullSize, suggestion?.wasSeen]); if (!suggestion || !policyID) { return null; diff --git a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts index f5a329eb08a4..7462ee07f97a 100644 --- a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts +++ b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts @@ -2,11 +2,16 @@ import {retireMerchantRuleSuggestion} from '@libs/actions/MerchantRuleSuggestion import {isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {MerchantRuleSuggestion} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; import {useEffect, useRef} from 'react'; import useOnyx from './useOnyx'; +const selectIsSeenAndLive = (suggestion: OnyxEntry) => !!suggestion?.wasSeen && isMerchantRuleSuggestionLive(suggestion); + /** * Ends the "Create a rule" offer once the user has seen it and left the report showing it. * @@ -18,10 +23,12 @@ import useOnyx from './useOnyx'; * detail view under its own reportID, so matching here would never fire and the offer would outlive the page. */ function useRetireMerchantRuleSuggestionOnLeave() { - const [suggestion] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION); - const hasBeenSeenRef = useRef(false); + // Selected down to the one boolean this needs. The report actions list hosting this hook re-renders often, and + // subscribing to the whole record would wake it on every write the feature makes, in every report. + // // Tracks the live value rather than latching, so an offer dismissed on the way out is not retired as well. - const isSeenAndLive = !!suggestion?.wasSeen && isMerchantRuleSuggestionLive(suggestion); + const [isSeenAndLive = false] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {selector: selectIsSeenAndLive}); + const hasBeenSeenRef = useRef(false); useEffect(() => { hasBeenSeenRef.current = isSeenAndLive; From acea7d90f6ffcfc14d139610fe3f47d422e82046 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 9 Sep 2026 08:52:02 +0530 Subject: [PATCH 46/51] Roll back offers on failed edits, skip unused back-path work, and cover the utils Signed-off-by: krishna2323 --- src/hooks/useDynamicBackPath.ts | 6 +- src/libs/actions/IOU/UpdateMoneyRequest.ts | 35 +++- src/libs/actions/MerchantRuleSuggestion.ts | 37 +++- .../MerchantRules/useMerchantRuleRoute.ts | 4 +- tests/unit/MerchantRuleSuggestionUtilsTest.ts | 197 ++++++++++++++++++ 5 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 tests/unit/MerchantRuleSuggestionUtilsTest.ts diff --git a/src/hooks/useDynamicBackPath.ts b/src/hooks/useDynamicBackPath.ts index 47eb00808e2a..048bfa212ac1 100644 --- a/src/hooks/useDynamicBackPath.ts +++ b/src/hooks/useDynamicBackPath.ts @@ -17,11 +17,13 @@ import useRootNavigationState from './useRootNavigationState'; * If the suffix doesn't match the tail of the current path, returns the path as-is. * * @param dynamicRouteSuffix - The dynamic route pattern to remove from the current URL. + * @param isEnabled - Pass false from a caller that discards the result, to skip the work behind it. Serializing the + * navigation tree and matching every suffix against it runs on each navigation event, for each mounted caller. * @returns The back path for the dynamic route. */ -function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route { +function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix, isEnabled = true): Route { const path = useRootNavigationState((state) => { - if (!state) { + if (!isEnabled || !state) { return undefined; } diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 74148d40f17f..57312388d02f 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -43,7 +43,7 @@ import { } from '@libs/TransactionUtils'; import ViolationsUtils, {syncCustomUnitRateOutOfDateRangeViolation} from '@libs/Violations/ViolationsUtils'; -import {trackMerchantRuleSuggestion} from '@userActions/MerchantRuleSuggestion'; +import {getMerchantRuleSuggestionRollback, trackMerchantRuleSuggestion} from '@userActions/MerchantRuleSuggestion'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {stringifyWaypointsForAPI} from '@userActions/Transaction'; @@ -51,6 +51,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; import type RecentlyUsedTags from '@src/types/onyx/RecentlyUsedTags'; import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; @@ -348,6 +349,23 @@ function updateMoneyRequestDate({ API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); } +/** + * Adds a tracked edit's rollback to the update carrying it, so a rejected edit takes its offer down with it. Must run + * before the write, since the failure data is read when the request is queued. + */ +function addMerchantRuleSuggestionRollback( + onyxData: OnyxData, + transactionID: string | undefined, + field: MerchantRuleSuggestionField, + editedTagLevels?: number[], +) { + const rollback = getMerchantRuleSuggestionRollback(transactionID, field, editedTagLevels); + if (!rollback) { + return; + } + onyxData.failureData = [...(onyxData.failureData ?? []), rollback]; +} + /** Updates the billable field of an expense */ function updateMoneyRequestBillable({ transactionID, @@ -411,6 +429,7 @@ function updateMoneyRequestBillable({ getCurrencyDecimals, getCurrencySymbol, }); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, transactionThreadReport.reportID, policy, policyCategories); } @@ -480,6 +499,7 @@ function updateMoneyRequestReimbursable({ getCurrencyDecimals, getCurrencySymbol, }); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, transactionThreadReport.reportID, policy, policyCategories); } @@ -901,15 +921,17 @@ function updateMoneyRequestTag({ getCurrencyDecimals, getCurrencySymbol, }); - API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); // Callers that edit one level of a multi-level tag say which. The rest, like the Search table, hand over a whole - // tag, so the edited levels come from comparing it with the one `transaction` still holds. + // tag, so the edited levels come from comparing it with the one `transaction` still holds. Worked out before the + // write, because the rollback below forgets the same levels and the write reads its failure data when queued. let editedTagLevels: number[] | undefined; if (tagListIndex !== undefined) { editedTagLevels = [tagListIndex]; } else if (transaction) { editedTagLevels = getChangedTagLevels(getTag(transaction), tag); } + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, editedTagLevels); + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy, policyCategories, editedTagLevels); } @@ -1042,6 +1064,7 @@ function updateMoneyRequestTaxRate({ getCurrencySymbol, }); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, params, onyxData); trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, transactionThreadReport?.reportID, policy, policyCategories); } @@ -1284,6 +1307,7 @@ function updateMoneyRequestCategory({ getCurrencyDecimals, getCurrencySymbol, }); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, transactionThreadReport?.reportID, policy, policyCategories); } @@ -1372,6 +1396,7 @@ function updateMoneyRequestDescription({ } const {params, onyxData} = data; params.description = parsedComment; + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, transactionThreadReport?.reportID, policy, policyCategories); } @@ -1665,7 +1690,9 @@ type UpdateMoneyRequestDataKeys = | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.NVP_RECENT_ATTENDEES | typeof ONYXKEYS.COLLECTION.SNAPSHOT - | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT; + | typeof ONYXKEYS.COLLECTION.TRANSACTION_DRAFT + // Carried on failure only, to forget an edit the server rejected + | typeof ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION; /** * @param params.violations - pass all violations including those generated on the server. Otherwise, server violations will be lost in the local optimistic calculation. diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index 409f3d9a04ed..3b176f7d38ab 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -4,7 +4,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleSuggestion, Policy, PolicyCategories} from '@src/types/onyx'; import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -45,6 +45,32 @@ function trackMerchantRuleSuggestion( }); } +/** + * The rollback for a tracked edit, to sit in an update's `failureData`. A rejected edit puts the old value back, and + * an offer left behind would seed a rule from a value the expense no longer holds. Forgetting the field is enough: + * once an expense has none left, it stops offering. + * + * @param editedTagLevels - the levels recorded alongside a tag edit, forgotten with it + */ +function getMerchantRuleSuggestionRollback( + transactionID: string | undefined, + field: MerchantRuleSuggestionField, + editedTagLevels?: number[], +): OnyxUpdate | undefined { + if (!transactionID) { + return undefined; + } + + return { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, + value: { + editedFields: {[transactionID]: {[field]: null}}, + ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, null]))}} : {}), + }, + }; +} + /** Records that the callout rendered, which is what makes leaving the expense retire the offer. */ function markMerchantRuleSuggestionSeen() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {wasSeen: true}); @@ -73,4 +99,11 @@ function retireMerchantRuleSuggestion() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); } -export {trackMerchantRuleSuggestion, dismissMerchantRuleSuggestion, markMerchantRuleSuggestionSeen, retireMerchantRuleSuggestion, clearMerchantRuleSuggestionFields}; +export { + trackMerchantRuleSuggestion, + dismissMerchantRuleSuggestion, + markMerchantRuleSuggestionSeen, + retireMerchantRuleSuggestion, + clearMerchantRuleSuggestionFields, + getMerchantRuleSuggestionRollback, +}; diff --git a/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts index 844e155e7f45..34c44c3d0c68 100644 --- a/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts +++ b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts @@ -48,7 +48,9 @@ function useMerchantRuleRoute(dynamicSuffix: DynamicRouteSuffix, policyID: strin // Only workspace settings can reach an edit. `ruleID` is absent on dynamic routes, and `undefined !== 'new'` // would otherwise read as editing. const isEditing = !isCreatedFromExpense && !!ruleID && ruleID !== ROUTES.NEW; - const dynamicBackToRoute = useDynamicBackPath(dynamicSuffix); + // Only the callout's own entry uses this, and the settings flow reaches every one of these pages too. Working the + // path out there would cost the same on each navigation event and then be thrown away. + const dynamicBackToRoute = useDynamicBackPath(dynamicSuffix, isCreatedFromExpense); const ruleRoute = isEditing && ruleID ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); const getRuleRoute = (dynamicSuffixWithParams: string, staticRoute: Route) => (isCreatedFromExpense ? createDynamicRoute(dynamicSuffixWithParams) : staticRoute); diff --git a/tests/unit/MerchantRuleSuggestionUtilsTest.ts b/tests/unit/MerchantRuleSuggestionUtilsTest.ts new file mode 100644 index 000000000000..584e80e87287 --- /dev/null +++ b/tests/unit/MerchantRuleSuggestionUtilsTest.ts @@ -0,0 +1,197 @@ +import {getChangedTagLevels, getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; + +import CONST from '@src/CONST'; +import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; + +const TRANSACTION_ID = '1234567890'; + +/** + * A minimal expense the draft builder can read. Tests override only the fields they exercise, so the seeding table is + * validated against a realistic transaction rather than a hand-picked subset. + */ +const buildTransaction = (overrides: Partial = {}) => + ({ + transactionID: TRANSACTION_ID, + merchant: 'Starbucks', + amount: -500, + currency: 'USD', + created: '2026-09-01', + reportID: '999', + comment: {}, + ...overrides, + }) as Transaction; + +/** The recorded tag levels, built rather than written inline so the level numbers stay out of an object literal. */ +const buildEditedTagLevels = (levels: number[]): Record => Object.fromEntries(levels.map((level) => [level, true])); + +/** A workspace holding one tax rate, to exercise seeding a rate that is still there against one that is not. */ +const buildPolicyWithTax = (taxCode: string): Policy => { + const policy = createRandomPolicy(0); + policy.taxRates = { + name: 'Tax', + defaultExternalID: taxCode, + defaultValue: '0%', + foreignTaxDefault: taxCode, + taxes: Object.fromEntries([[taxCode, {name: 'Tax exempt', value: '0%'}]]), + }; + return policy; +}; + +const buildSuggestion = (overrides: Partial = {}) => + ({ + transactionID: TRANSACTION_ID, + reportID: '999', + editedFields: {[TRANSACTION_ID]: {category: true}}, + ...overrides, + }) as MerchantRuleSuggestion; + +describe('isMerchantRuleSuggestionLive', () => { + it('is not live without a stored offer', () => { + expect(isMerchantRuleSuggestionLive(undefined)).toBe(false); + }); + + it('is not live without a transaction to offer for', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({transactionID: ''}))).toBe(false); + }); + + it('is live for a freshly recorded edit', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion())).toBe(true); + }); + + it('is not live once retired', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({isRetired: true}))).toBe(false); + }); + + it('stays live after being seen, since seeing it is not taking it', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({wasSeen: true}))).toBe(true); + }); + + it('is not live once this expense is dismissed', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({dismissedTransactionIDs: [TRANSACTION_ID]}))).toBe(false); + }); + + it('stays live when a different expense was dismissed', () => { + expect(isMerchantRuleSuggestionLive(buildSuggestion({dismissedTransactionIDs: ['9999']}))).toBe(true); + }); +}); + +describe('getChangedTagLevels', () => { + it('reports no change when the tag is untouched', () => { + expect(getChangedTagLevels('Sales:South America', 'Sales:South America')).toEqual([]); + }); + + it('reports only the level that changed', () => { + expect(getChangedTagLevels('Sales:South America:Project 6', 'Marketing:South America:Project 6')).toEqual([0]); + expect(getChangedTagLevels('Sales:South America:Project 6', 'Sales:Europe:Project 6')).toEqual([1]); + }); + + it('reports every level that changed', () => { + expect(getChangedTagLevels('Sales:South America', 'Marketing:Europe')).toEqual([0, 1]); + }); + + it('reports a level added to a shorter tag', () => { + expect(getChangedTagLevels('Sales', 'Sales:South America')).toEqual([1]); + }); + + it('reports a level removed from a longer tag', () => { + expect(getChangedTagLevels('Sales:South America', 'Sales')).toEqual([1]); + }); + + it('reports the whole tag when it is cleared', () => { + expect(getChangedTagLevels('Sales:South America', '')).toEqual([0, 1]); + }); + + it('reports a single-level tag being set', () => { + expect(getChangedTagLevels('', 'Sales')).toEqual([0]); + }); +}); + +describe('getMerchantRuleDraftFromTransaction', () => { + it('returns nothing when there is no expense', () => { + expect(getMerchantRuleDraftFromTransaction(undefined, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined)).toBeUndefined(); + }); + + it('returns nothing when the expense has no merchant to match on', () => { + const transaction = buildTransaction({merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT}); + expect(getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined)).toBeUndefined(); + }); + + it('names the rule type itself, so the editor opens rather than the type chooser', () => { + const draft = getMerchantRuleDraftFromTransaction(buildTransaction(), [], undefined); + expect(draft?.ruleType).toBe(CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT); + expect(draft?.merchantToMatch).toBe('Starbucks'); + }); + + it('seeds the category that was edited', () => { + const transaction = buildTransaction({category: 'Benefits'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined); + expect(draft?.category).toBe('Benefits'); + }); + + it('leaves out a field that was cleared, so the rule would change nothing', () => { + const transaction = buildTransaction({category: ''}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY], undefined); + expect(draft).not.toHaveProperty('category'); + }); + + it('carries the whole tag when no levels were recorded', () => { + const transaction = buildTransaction({tag: 'Sales:South America'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG], undefined); + expect(draft?.tag).toBe('Sales:South America'); + }); + + it('blanks the levels that were not edited', () => { + const transaction = buildTransaction({tag: 'Sales:South America:Project 6'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG], undefined, buildEditedTagLevels([1])); + expect(draft?.tag).toBe(':South America'); + }); + + it('keeps the first level alone when only it was edited', () => { + const transaction = buildTransaction({tag: 'Sales:South America'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG], undefined, buildEditedTagLevels([0])); + expect(draft?.tag).toBe('Sales'); + }); + + it('leaves out the tag when every edited level is empty', () => { + const transaction = buildTransaction({tag: ''}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG], undefined, buildEditedTagLevels([0])); + expect(draft).not.toHaveProperty('tag'); + }); + + it('seeds a tax rate the workspace still holds', () => { + const transaction = buildTransaction({taxCode: 'id_TAX_EXEMPT'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX], buildPolicyWithTax('id_TAX_EXEMPT')); + expect(draft?.tax).toBe('id_TAX_EXEMPT'); + }); + + it('leaves out a tax rate the workspace no longer holds', () => { + const transaction = buildTransaction({taxCode: 'id_GONE'}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX], buildPolicyWithTax('id_TAX_EXEMPT')); + expect(draft).not.toHaveProperty('tax'); + }); + + it('seeds an unset reimbursable as reimbursable, matching what the expense shows', () => { + const draft = getMerchantRuleDraftFromTransaction(buildTransaction(), [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE], undefined); + expect(draft?.reimbursable).toBe(true); + }); + + it('seeds reimbursable turned off', () => { + const transaction = buildTransaction({reimbursable: false}); + const draft = getMerchantRuleDraftFromTransaction(transaction, [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE], undefined); + expect(draft?.reimbursable).toBe(false); + }); + + it('carries every edited field together, so one rule holds them all', () => { + const transaction = buildTransaction({category: 'Benefits', tag: 'Sales', billable: true}); + const draft = getMerchantRuleDraftFromTransaction( + transaction, + [CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE], + undefined, + ); + expect(draft?.category).toBe('Benefits'); + expect(draft?.tag).toBe('Sales'); + expect(draft?.billable).toBe(true); + }); +}); From 135ec85fa1a10ebadad011a7da4ff364b73db812 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 9 Sep 2026 08:57:15 +0530 Subject: [PATCH 47/51] fix spell check. Signed-off-by: krishna2323 --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index 2acc4c0385af..ad6be4595542 100644 --- a/cspell.json +++ b/cspell.json @@ -1048,6 +1048,7 @@ "tosorted", "touchables", "tranid", + "travelled", "trinet", "trivago", "trustcacerts", From eebf269ccb0652ab39cce17666eaa04e8fa4c11d Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Wed, 9 Sep 2026 09:09:42 +0530 Subject: [PATCH 48/51] fix ESLint. Signed-off-by: krishna2323 --- src/libs/actions/IOU/UpdateMoneyRequest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 57312388d02f..7204751be47d 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -363,7 +363,7 @@ function addMerchantRuleSuggestionRollback( if (!rollback) { return; } - onyxData.failureData = [...(onyxData.failureData ?? []), rollback]; + onyxData.failureData?.push(rollback); } /** Updates the billable field of an expense */ From 8e1690517a3a4901d1c304875a8038a76c16073e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 11:08:00 +0530 Subject: [PATCH 49/51] Scope offer retirement to the report that showed it, and stop tracking on Collect Signed-off-by: krishna2323 --- .../MerchantRuleSuggestionBanner.tsx | 8 +++---- .../useRetireMerchantRuleSuggestionOnLeave.ts | 22 +++++++++++-------- src/libs/actions/MerchantRuleSuggestion.ts | 22 ++++++++++--------- src/pages/inbox/report/ReportActionsList.tsx | 2 +- src/types/onyx/MerchantRuleSuggestion.ts | 9 +++++--- tests/unit/MerchantRuleSuggestionUtilsTest.ts | 2 +- 6 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/components/MerchantRuleSuggestionBanner.tsx b/src/components/MerchantRuleSuggestionBanner.tsx index ceb31a7dc4ac..b39a3f930608 100644 --- a/src/components/MerchantRuleSuggestionBanner.tsx +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -97,17 +97,17 @@ function MerchantRuleSuggestionBannerContent({reportID, policyID, containerStyle transform: [{translateY: translateY.get()}], })); - // Recorded so leaving the report can retire the offer. The report cannot work this out for itself, because the + // Recorded so leaving this report can retire the offer. The report cannot work this out for itself, because the // one showing an expense is not always the one the edit was recorded against. // // A full-size composer hides the callout, and composer size is remembered per report. Marking it seen there would // retire an offer the user never had a chance to read. useEffect(() => { - if (!isShowing || isComposerFullSize || suggestion?.wasSeen) { + if (!isShowing || !reportID || isComposerFullSize || suggestion?.seenInReportID === reportID) { return; } - markMerchantRuleSuggestionSeen(); - }, [isShowing, isComposerFullSize, suggestion?.wasSeen]); + markMerchantRuleSuggestionSeen(reportID); + }, [isShowing, reportID, isComposerFullSize, suggestion?.seenInReportID]); if (!suggestion || !policyID) { return null; diff --git a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts index 7462ee07f97a..aa8d7454c841 100644 --- a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts +++ b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts @@ -10,7 +10,7 @@ import {useEffect, useRef} from 'react'; import useOnyx from './useOnyx'; -const selectIsSeenAndLive = (suggestion: OnyxEntry) => !!suggestion?.wasSeen && isMerchantRuleSuggestionLive(suggestion); +const selectLiveSeenInReportID = (suggestion: OnyxEntry) => (isMerchantRuleSuggestionLive(suggestion) ? suggestion?.seenInReportID : undefined); /** * Ends the "Create a rule" offer once the user has seen it and left the report showing it. @@ -18,21 +18,25 @@ const selectIsSeenAndLive = (suggestion: OnyxEntry) => ! * Owned by the report, not the callout: the callout unmounts whenever the layout crosses the narrow breakpoint or the * composer expands, and retiring on those would silence an offer the user is still looking at. * - * Whether it was seen is read from the record rather than matched against this report, because the report showing an - * expense is not always the one the edit was recorded against. An expense report holding a single expense renders the - * detail view under its own reportID, so matching here would never fire and the offer would outlive the page. + * @param reportID - the report this list belongs to, matched against the one the callout recorded itself in */ -function useRetireMerchantRuleSuggestionOnLeave() { - // Selected down to the one boolean this needs. The report actions list hosting this hook re-renders often, and +function useRetireMerchantRuleSuggestionOnLeave(reportID: string | undefined) { + // Selected down to the one field this needs. The report actions list hosting this hook re-renders often, and // subscribing to the whole record would wake it on every write the feature makes, in every report. // // Tracks the live value rather than latching, so an offer dismissed on the way out is not retired as well. - const [isSeenAndLive = false] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {selector: selectIsSeenAndLive}); + const [liveSeenInReportID] = useOnyx(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {selector: selectLiveSeenInReportID}); const hasBeenSeenRef = useRef(false); + // Compared against this report rather than taken as a bare flag. Several report screens stay mounted at once, in a + // split pane or behind an RHP, and every one of them runs this hook. Without the match, any of them unmounting + // would retire an offer the user is still looking at somewhere else. + // + // The callout reports which report it rendered in, because the report cannot tell: an expense report holding a + // single expense renders the detail view under its own reportID, not the transaction thread's. useEffect(() => { - hasBeenSeenRef.current = isSeenAndLive; - }, [isSeenAndLive]); + hasBeenSeenRef.current = !!reportID && liveSeenInReportID === reportID; + }, [liveSeenInReportID, reportID]); useEffect( () => () => { diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index 3b176f7d38ab..2168cf8f924a 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -1,4 +1,4 @@ -import {arePolicyRulesEnabled} from '@libs/PolicyUtils'; +import {arePolicyRulesEnabled, isControlPolicy} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleSuggestion, Policy, PolicyCategories} from '@src/types/onyx'; @@ -24,11 +24,9 @@ function trackMerchantRuleSuggestion( editedTagLevels?: number[], ) { // Skip workspaces that could not hold a merchant rule, otherwise an edit made with Rules off would surface the - // moment somebody turned Rules on. - // - // The Rules revamp beta is assumed on: it only narrows collect workspaces, it is being removed, and - // `useMerchantRuleSuggestion` checks it for real before rendering. - if (!transactionID || !reportID || !arePolicyRulesEnabled(policy, policyCategories, true)) { + // moment somebody turned Rules on. Control only, matching the rule page the callout leads to, so an edit on a + // Collect workspace does not pay for a write that could never be shown. + if (!transactionID || !reportID || !isControlPolicy(policy) || !arePolicyRulesEnabled(policy, policyCategories)) { return; } @@ -40,7 +38,7 @@ function trackMerchantRuleSuggestion( editedFields: {[transactionID]: {[field]: true}}, // Keyed by level so editing several levels of one tag accumulates, the same way fields do. ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, true]))}} : {}), - wasSeen: null, + seenInReportID: null, isRetired: null, }); } @@ -71,9 +69,13 @@ function getMerchantRuleSuggestionRollback( }; } -/** Records that the callout rendered, which is what makes leaving the expense retire the offer. */ -function markMerchantRuleSuggestionSeen() { - Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {wasSeen: true}); +/** + * Records the report the callout rendered in, which is what makes leaving that report retire the offer. + * + * @param reportID - the report hosting the expense detail view the callout appeared on + */ +function markMerchantRuleSuggestionSeen(reportID: string) { + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {seenInReportID: reportID}); } /** diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 1a8fb341b6b2..408514130258 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -144,7 +144,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); // Owned here rather than by the callout, which unmounts as the layout and composer change size. - useRetireMerchantRuleSuggestionOnLeave(); + useRetireMerchantRuleSuggestionOnLeave(reportID); // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts index c9b64099a90b..1f6ac87538cb 100644 --- a/src/types/onyx/MerchantRuleSuggestion.ts +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -33,10 +33,13 @@ type MerchantRuleSuggestion = { dismissedTransactionIDs?: string[]; /** - * Whether the callout actually rendered. Set by the callout itself, since the report showing an expense is not - * always the one the edit was recorded against: a report holding a single expense shows the detail view too. + * The report the callout actually rendered in. Set by the callout itself, since the report showing an expense is + * not always the one the edit was recorded against: a report holding a single expense shows the detail view too. + * + * Held as a report ID rather than a flag because several report screens stay mounted at once, in a split pane or + * behind an RHP. Only the one that showed the offer should retire it on the way out. */ - wasSeen?: boolean; + seenInReportID?: string; /** Whether the offer was seen and left. Unlike dismissing, editing the expense again offers afresh. */ isRetired?: boolean; diff --git a/tests/unit/MerchantRuleSuggestionUtilsTest.ts b/tests/unit/MerchantRuleSuggestionUtilsTest.ts index 584e80e87287..4c95590467f0 100644 --- a/tests/unit/MerchantRuleSuggestionUtilsTest.ts +++ b/tests/unit/MerchantRuleSuggestionUtilsTest.ts @@ -65,7 +65,7 @@ describe('isMerchantRuleSuggestionLive', () => { }); it('stays live after being seen, since seeing it is not taking it', () => { - expect(isMerchantRuleSuggestionLive(buildSuggestion({wasSeen: true}))).toBe(true); + expect(isMerchantRuleSuggestionLive(buildSuggestion({seenInReportID: '999'}))).toBe(true); }); it('is not live once this expense is dismissed', () => { From 24ed985b50b79da4b041ed6e67c2151a700487c3 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 11:16:25 +0530 Subject: [PATCH 50/51] Track edits only on write success, drop the failure rollback Signed-off-by: krishna2323 --- src/libs/actions/IOU/UpdateMoneyRequest.ts | 37 +++++++------- src/libs/actions/MerchantRuleSuggestion.ts | 59 +++++++--------------- 2 files changed, 35 insertions(+), 61 deletions(-) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 7204751be47d..75d47f654f51 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -43,7 +43,7 @@ import { } from '@libs/TransactionUtils'; import ViolationsUtils, {syncCustomUnitRateOutOfDateRangeViolation} from '@libs/Violations/ViolationsUtils'; -import {getMerchantRuleSuggestionRollback, trackMerchantRuleSuggestion} from '@userActions/MerchantRuleSuggestion'; +import {getMerchantRuleSuggestionTrackingUpdate} from '@userActions/MerchantRuleSuggestion'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {stringifyWaypointsForAPI} from '@userActions/Transaction'; @@ -350,20 +350,25 @@ function updateMoneyRequestDate({ } /** - * Adds a tracked edit's rollback to the update carrying it, so a rejected edit takes its offer down with it. Must run - * before the write, since the failure data is read when the request is queued. + * Adds a tracked edit to the update carrying it, so the offer to create a merchant rule only appears once the edit is + * confirmed. Sitting in `successData` rather than `optimisticData` means a rejected edit needs no rollback: it was + * never tracked, and an earlier edit's own tracking is left alone. Must run before the write, since success data is + * read when the request is queued. */ -function addMerchantRuleSuggestionRollback( +function addMerchantRuleSuggestionTracking( onyxData: OnyxData, transactionID: string | undefined, field: MerchantRuleSuggestionField, + reportID: string | undefined, + policy: OnyxEntry, + policyCategories: OnyxEntry, editedTagLevels?: number[], ) { - const rollback = getMerchantRuleSuggestionRollback(transactionID, field, editedTagLevels); - if (!rollback) { + const update = getMerchantRuleSuggestionTrackingUpdate(transactionID, field, reportID, policy, policyCategories, editedTagLevels); + if (!update) { return; } - onyxData.failureData?.push(rollback); + onyxData.successData?.push(update); } /** Updates the billable field of an expense */ @@ -429,9 +434,8 @@ function updateMoneyRequestBillable({ getCurrencyDecimals, getCurrencySymbol, }); - addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE); + addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, transactionThreadReport.reportID, policy, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, transactionThreadReport.reportID, policy, policyCategories); } function updateMoneyRequestReimbursable({ @@ -499,9 +503,8 @@ function updateMoneyRequestReimbursable({ getCurrencyDecimals, getCurrencySymbol, }); - addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE); + addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, transactionThreadReport.reportID, policy, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, transactionThreadReport.reportID, policy, policyCategories); } /** Updates the merchant field of an expense */ @@ -930,9 +933,8 @@ function updateMoneyRequestTag({ } else if (transaction) { editedTagLevels = getChangedTagLevels(getTag(transaction), tag); } - addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, editedTagLevels); + addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy, policyCategories, editedTagLevels); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy, policyCategories, editedTagLevels); } /** Updates the created tax amount of an expense */ @@ -1064,9 +1066,8 @@ function updateMoneyRequestTaxRate({ getCurrencySymbol, }); - addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX); + addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, transactionThreadReport?.reportID, policy, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, transactionThreadReport?.reportID, policy, policyCategories); } type UpdateMoneyRequestDistanceParams = { @@ -1307,9 +1308,8 @@ function updateMoneyRequestCategory({ getCurrencyDecimals, getCurrencySymbol, }); - addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY); + addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, transactionThreadReport?.reportID, policy, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, transactionThreadReport?.reportID, policy, policyCategories); } /** Updates the description of an expense */ @@ -1396,9 +1396,8 @@ function updateMoneyRequestDescription({ } const {params, onyxData} = data; params.description = parsedComment; - addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION); + addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, transactionThreadReport?.reportID, policy, policyCategories); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); - trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, transactionThreadReport?.reportID, policy, policyCategories); } /** Updates the distance rate of an expense */ diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index 2168cf8f924a..a86d29e94c5c 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -9,62 +9,44 @@ import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; /** - * Records an edit that could become a merchant rule, so the expense can offer to create one. + * The update that records an edit as one that could become a merchant rule, to sit in an update's `successData`. Kept + * out of `optimisticData` so a rejected edit never has to be untracked: it is simply never tracked, and an earlier + * edit's own tracking is left alone. * * Edits accumulate per expense until the offer is taken, so one rule can carry category, tag and tax together. Only * the most recently edited expense offers. Recorded for anyone on the workspace; `useMerchantRuleSuggestion` decides * who actually sees the callout. + * + * @param editedTagLevels - the levels being recorded alongside a tag edit */ -function trackMerchantRuleSuggestion( +function getMerchantRuleSuggestionTrackingUpdate( transactionID: string | undefined, field: MerchantRuleSuggestionField, reportID: string | undefined, policy: OnyxEntry, policyCategories: OnyxEntry, editedTagLevels?: number[], -) { +): OnyxUpdate | undefined { // Skip workspaces that could not hold a merchant rule, otherwise an edit made with Rules off would surface the // moment somebody turned Rules on. Control only, matching the rule page the callout leads to, so an edit on a // Collect workspace does not pay for a write that could never be shown. if (!transactionID || !reportID || !isControlPolicy(policy) || !arePolicyRulesEnabled(policy, policyCategories)) { - return; - } - - // Merged rather than set, so dismissals survive and `editedFields` accumulates. `isRetired` belongs to the offer - // being replaced, so it is cleared: a new edit is a new offer. - Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { - transactionID, - reportID, - editedFields: {[transactionID]: {[field]: true}}, - // Keyed by level so editing several levels of one tag accumulates, the same way fields do. - ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, true]))}} : {}), - seenInReportID: null, - isRetired: null, - }); -} - -/** - * The rollback for a tracked edit, to sit in an update's `failureData`. A rejected edit puts the old value back, and - * an offer left behind would seed a rule from a value the expense no longer holds. Forgetting the field is enough: - * once an expense has none left, it stops offering. - * - * @param editedTagLevels - the levels recorded alongside a tag edit, forgotten with it - */ -function getMerchantRuleSuggestionRollback( - transactionID: string | undefined, - field: MerchantRuleSuggestionField, - editedTagLevels?: number[], -): OnyxUpdate | undefined { - if (!transactionID) { return undefined; } return { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, + // Merged rather than set, so dismissals survive and `editedFields` accumulates. `isRetired` belongs to the + // offer being replaced, so it is cleared: a new edit is a new offer. value: { - editedFields: {[transactionID]: {[field]: null}}, - ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, null]))}} : {}), + transactionID, + reportID, + editedFields: {[transactionID]: {[field]: true}}, + // Keyed by level so editing several levels of one tag accumulates, the same way fields do. + ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, true]))}} : {}), + seenInReportID: null, + isRetired: null, }, }; } @@ -101,11 +83,4 @@ function retireMerchantRuleSuggestion() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); } -export { - trackMerchantRuleSuggestion, - dismissMerchantRuleSuggestion, - markMerchantRuleSuggestionSeen, - retireMerchantRuleSuggestion, - clearMerchantRuleSuggestionFields, - getMerchantRuleSuggestionRollback, -}; +export {getMerchantRuleSuggestionTrackingUpdate, dismissMerchantRuleSuggestion, markMerchantRuleSuggestionSeen, retireMerchantRuleSuggestion, clearMerchantRuleSuggestionFields}; From 8828ee1196a28dc06d3cb39485728d92bdbad9aa Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 10 Sep 2026 11:32:33 +0530 Subject: [PATCH 51/51] Track edits optimistically again, so the offer shows offline Signed-off-by: krishna2323 --- src/libs/actions/IOU/UpdateMoneyRequest.ts | 37 ++++++------ src/libs/actions/MerchantRuleSuggestion.ts | 67 ++++++++++++++++------ 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/src/libs/actions/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index 75d47f654f51..7204751be47d 100644 --- a/src/libs/actions/IOU/UpdateMoneyRequest.ts +++ b/src/libs/actions/IOU/UpdateMoneyRequest.ts @@ -43,7 +43,7 @@ import { } from '@libs/TransactionUtils'; import ViolationsUtils, {syncCustomUnitRateOutOfDateRangeViolation} from '@libs/Violations/ViolationsUtils'; -import {getMerchantRuleSuggestionTrackingUpdate} from '@userActions/MerchantRuleSuggestion'; +import {getMerchantRuleSuggestionRollback, trackMerchantRuleSuggestion} from '@userActions/MerchantRuleSuggestion'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {stringifyWaypointsForAPI} from '@userActions/Transaction'; @@ -350,25 +350,20 @@ function updateMoneyRequestDate({ } /** - * Adds a tracked edit to the update carrying it, so the offer to create a merchant rule only appears once the edit is - * confirmed. Sitting in `successData` rather than `optimisticData` means a rejected edit needs no rollback: it was - * never tracked, and an earlier edit's own tracking is left alone. Must run before the write, since success data is - * read when the request is queued. + * Adds a tracked edit's rollback to the update carrying it, so a rejected edit takes its offer down with it. Must run + * before the write, since the failure data is read when the request is queued. */ -function addMerchantRuleSuggestionTracking( +function addMerchantRuleSuggestionRollback( onyxData: OnyxData, transactionID: string | undefined, field: MerchantRuleSuggestionField, - reportID: string | undefined, - policy: OnyxEntry, - policyCategories: OnyxEntry, editedTagLevels?: number[], ) { - const update = getMerchantRuleSuggestionTrackingUpdate(transactionID, field, reportID, policy, policyCategories, editedTagLevels); - if (!update) { + const rollback = getMerchantRuleSuggestionRollback(transactionID, field, editedTagLevels); + if (!rollback) { return; } - onyxData.successData?.push(update); + onyxData.failureData?.push(rollback); } /** Updates the billable field of an expense */ @@ -434,8 +429,9 @@ function updateMoneyRequestBillable({ getCurrencyDecimals, getCurrencySymbol, }); - addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, transactionThreadReport.reportID, policy, policyCategories); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.BILLABLE, transactionThreadReport.reportID, policy, policyCategories); } function updateMoneyRequestReimbursable({ @@ -503,8 +499,9 @@ function updateMoneyRequestReimbursable({ getCurrencyDecimals, getCurrencySymbol, }); - addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, transactionThreadReport.reportID, policy, policyCategories); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_REIMBURSABLE, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.REIMBURSABLE, transactionThreadReport.reportID, policy, policyCategories); } /** Updates the merchant field of an expense */ @@ -933,8 +930,9 @@ function updateMoneyRequestTag({ } else if (transaction) { editedTagLevels = getChangedTagLevels(getTag(transaction), tag); } - addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy, policyCategories, editedTagLevels); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, editedTagLevels); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAG, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAG, transactionThreadReport?.reportID, policy, policyCategories, editedTagLevels); } /** Updates the created tax amount of an expense */ @@ -1066,8 +1064,9 @@ function updateMoneyRequestTaxRate({ getCurrencySymbol, }); - addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, transactionThreadReport?.reportID, policy, policyCategories); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_TAX_RATE, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.TAX, transactionThreadReport?.reportID, policy, policyCategories); } type UpdateMoneyRequestDistanceParams = { @@ -1308,8 +1307,9 @@ function updateMoneyRequestCategory({ getCurrencyDecimals, getCurrencySymbol, }); - addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, transactionThreadReport?.reportID, policy, policyCategories); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.CATEGORY, transactionThreadReport?.reportID, policy, policyCategories); } /** Updates the description of an expense */ @@ -1396,8 +1396,9 @@ function updateMoneyRequestDescription({ } const {params, onyxData} = data; params.description = parsedComment; - addMerchantRuleSuggestionTracking(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, transactionThreadReport?.reportID, policy, policyCategories); + addMerchantRuleSuggestionRollback(onyxData, transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION); API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); + trackMerchantRuleSuggestion(transactionID, CONST.MERCHANT_RULE_SUGGESTION_FIELDS.DESCRIPTION, transactionThreadReport?.reportID, policy, policyCategories); } /** Updates the distance rate of an expense */ diff --git a/src/libs/actions/MerchantRuleSuggestion.ts b/src/libs/actions/MerchantRuleSuggestion.ts index a86d29e94c5c..47133762aa7c 100644 --- a/src/libs/actions/MerchantRuleSuggestion.ts +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -9,44 +9,70 @@ import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; /** - * The update that records an edit as one that could become a merchant rule, to sit in an update's `successData`. Kept - * out of `optimisticData` so a rejected edit never has to be untracked: it is simply never tracked, and an earlier - * edit's own tracking is left alone. + * Records an edit that could become a merchant rule, so the expense can offer to create one. + * + * Written optimistically rather than from `successData`, so the offer appears at once, offline included: a queued + * write has no response to key off until reconnect, and this app works offline. `getMerchantRuleSuggestionRollback` + * is the failure-side counterpart. * * Edits accumulate per expense until the offer is taken, so one rule can carry category, tag and tax together. Only * the most recently edited expense offers. Recorded for anyone on the workspace; `useMerchantRuleSuggestion` decides * who actually sees the callout. - * - * @param editedTagLevels - the levels being recorded alongside a tag edit */ -function getMerchantRuleSuggestionTrackingUpdate( +function trackMerchantRuleSuggestion( transactionID: string | undefined, field: MerchantRuleSuggestionField, reportID: string | undefined, policy: OnyxEntry, policyCategories: OnyxEntry, editedTagLevels?: number[], -): OnyxUpdate | undefined { +) { // Skip workspaces that could not hold a merchant rule, otherwise an edit made with Rules off would surface the // moment somebody turned Rules on. Control only, matching the rule page the callout leads to, so an edit on a // Collect workspace does not pay for a write that could never be shown. if (!transactionID || !reportID || !isControlPolicy(policy) || !arePolicyRulesEnabled(policy, policyCategories)) { + return; + } + + // Merged rather than set, so dismissals survive and `editedFields` accumulates. `isRetired` belongs to the offer + // being replaced, so it is cleared: a new edit is a new offer. + Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, { + transactionID, + reportID, + editedFields: {[transactionID]: {[field]: true}}, + // Keyed by level so editing several levels of one tag accumulates, the same way fields do. + ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, true]))}} : {}), + seenInReportID: null, + isRetired: null, + }); +} + +/** + * The rollback for a tracked edit, to sit in an update's `failureData`. A rejected edit puts the old value back, and + * an offer left behind would seed a rule from a value the expense no longer holds. Forgetting the field is enough: + * once an expense has none left, it stops offering. + * + * Known limitation: if this field was already tracked from an earlier, successful edit, this still forgets it rather + * than restoring that earlier state, since the flag carries no history to restore. Narrower than the offline case + * above, and self-heals on the next edit, so it is left as is. + * + * @param editedTagLevels - the levels recorded alongside a tag edit, forgotten with it + */ +function getMerchantRuleSuggestionRollback( + transactionID: string | undefined, + field: MerchantRuleSuggestionField, + editedTagLevels?: number[], +): OnyxUpdate | undefined { + if (!transactionID) { return undefined; } return { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, - // Merged rather than set, so dismissals survive and `editedFields` accumulates. `isRetired` belongs to the - // offer being replaced, so it is cleared: a new edit is a new offer. value: { - transactionID, - reportID, - editedFields: {[transactionID]: {[field]: true}}, - // Keyed by level so editing several levels of one tag accumulates, the same way fields do. - ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, true]))}} : {}), - seenInReportID: null, - isRetired: null, + editedFields: {[transactionID]: {[field]: null}}, + ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, null]))}} : {}), }, }; } @@ -83,4 +109,11 @@ function retireMerchantRuleSuggestion() { Onyx.merge(ONYXKEYS.RAM_ONLY_MERCHANT_RULE_SUGGESTION, {isRetired: true}); } -export {getMerchantRuleSuggestionTrackingUpdate, dismissMerchantRuleSuggestion, markMerchantRuleSuggestionSeen, retireMerchantRuleSuggestion, clearMerchantRuleSuggestionFields}; +export { + trackMerchantRuleSuggestion, + getMerchantRuleSuggestionRollback, + dismissMerchantRuleSuggestion, + markMerchantRuleSuggestionSeen, + retireMerchantRuleSuggestion, + clearMerchantRuleSuggestionFields, +};