diff --git a/cspell.json b/cspell.json index 8084742fe8bf..46f7d2082dd3 100644 --- a/cspell.json +++ b/cspell.json @@ -1050,6 +1050,7 @@ "tosorted", "touchables", "tranid", + "travelled", "trinet", "trivago", "trustcacerts", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 76b24d23a573..6185c7de5e5c 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 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', + 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 @@ -527,6 +548,16 @@ const CONST = { MERCHANT_NAME_MAX_BYTES: 255, + /** The subset of EDIT_REQUEST_FIELD a merchant rule can govern, whose edit shows the "Create a rule" callout */ + MERCHANT_RULE_SUGGESTION_FIELDS: { + CATEGORY: editRequestFields.CATEGORY, + TAG: editRequestFields.TAG, + TAX: editRequestFields.TAX_RATE, + DESCRIPTION: editRequestFields.DESCRIPTION, + BILLABLE: editRequestFields.BILLABLE, + REIMBURSABLE: editRequestFields.REIMBURSABLE, + }, + MASKED_PAN_PREFIX: 'XXXXXXXXXXXX', REQUEST_PREVIEW: { @@ -5659,24 +5690,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/ONYXKEYS.ts b/src/ONYXKEYS.ts index a0a20048e485..c8744484aa13 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -678,6 +678,9 @@ 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 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', /** Stores the information about duplicated workspace */ @@ -1787,6 +1790,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/ROUTES.ts b/src/ROUTES.ts index 6f1c83cb8fd2..6eedc9640624 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1080,6 +1080,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 ac03bc5ee366..be41d952fbb0 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -975,6 +975,18 @@ const SCREENS = { RULES_CATEGORY_TO_MATCH: 'Rules_Category_To_Match', RULES_CATEGORY_TAX_EDIT: 'Rules_Category_Tax_Edit', 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 new file mode 100644 index 000000000000..b39a3f930608 --- /dev/null +++ b/src/components/MerchantRuleSuggestionBanner.tsx @@ -0,0 +1,212 @@ +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, 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'; +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 {useIsFocused, useRoute} from '@react-navigation/native'; +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +import Animated, {Easing, useAnimatedStyle, useSharedValue, withSpring, withTiming} from 'react-native-reanimated'; + +import Banner from './Banner'; +import Icon from './Icon'; +import Text from './Text'; +import TextLink from './TextLink'; +import {useWideRHPState} from './WideRHPContextProvider'; + +/** 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 */ + reportID: string | undefined; + + /** The workspace the expense belongs to */ + policyID: string | undefined; + + /** 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 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; +}; + +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(); + const icons = useMemoizedLazyExpensifyIcons(['Lightbulb']); + const {suggestion, fields, editedTagLevels, transaction, policy} = useMerchantRuleSuggestion(reportID, policyID); + const isShowing = !!suggestion && !!policyID; + + // 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); + + // 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(withSpring(0)); + opacity.set(withTiming(1, CALLOUT_FADE_CONFIG)); + }, + }); + return handle.cancel; + }, [translateY, opacity]); + + const slideStyle = useAnimatedStyle(() => ({ + opacity: opacity.get(), + transform: [{translateY: translateY.get()}], + })); + + // 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 || !reportID || isComposerFullSize || suggestion?.seenInReportID === reportID) { + return; + } + markMerchantRuleSuggestionSeen(reportID); + }, [isShowing, reportID, isComposerFullSize, suggestion?.seenInReportID]); + + if (!suggestion || !policyID) { + return null; + } + + const dismiss = () => dismissMerchantRuleSuggestion(suggestion); + + const createRule = () => { + const draft = getMerchantRuleDraftFromTransaction(transaction, fields, policy, editedTagLevels); + if (!draft) { + return; + } + // 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 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 composer check sits inside rather than around this wrapper on purpose. Expanding the composer should take + // the callout away at once, and emptying the wrapper does that without disturbing the slide. + return ( + + {!isComposerFullSize && ( + + + + + + + {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} + + {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} + + + } + /> + )} + + ); +} + +/** + * 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, 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. 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(); + const {wideRHPRouteKeys} = useWideRHPState(); + const isInWideRHP = !!route?.key && wideRHPRouteKeys.includes(route.key); + + // Both mounts always render; this picks the one for the layout. Deciding here keeps the two halves from drifting, + // and keeps the navigation-state subscription out of the report actions list, which re-renders far more often. + const isMountForThisLayout = isAnchoredToBottom ? !shouldUseNarrowLayout || isInWideRHP : shouldUseNarrowLayout && !isInWideRHP; + + // The same report can be mounted twice at once, in the central pane and in the RHP over it. Each tree measures its + // own layout, so the layout check alone elects a mount in both and the callout appears twice, each fading in on its + // own beat. Only the view the user is actually on should offer it. + const isFocused = useIsFocused(); + + // 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 (!isFocused || !isMountForThisLayout || !isMerchantRuleSuggestionLive(storedSuggestion)) { + return null; + } + + return ( + + ); +} + +export default MerchantRuleSuggestionBanner; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 555b2bfc6b13..ca7228307cee 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -946,6 +946,7 @@ function MoneyRequestView({ parentReport, iouReportOwnerLogin, tag: updatedTag, + tagListIndex, policy, policyTagList, policyRecentlyUsedTags: undefined, 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/hooks/useMerchantRuleSuggestion.ts b/src/hooks/useMerchantRuleSuggestion.ts new file mode 100644 index 000000000000..08cf00397306 --- /dev/null +++ b/src/hooks/useMerchantRuleSuggestion.ts @@ -0,0 +1,85 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive} from '@libs/MerchantRuleSuggestionUtils'; +import {arePolicyRulesEnabled, isControlPolicy} 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'; + +import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; +import usePolicyFeatureWriteAccess from './usePolicyFeatureWriteAccess'; +import useReportTransactions from './useReportTransactions'; + +type MerchantRuleSuggestionResult = { + /** 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[]; + + /** 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; + + /** The workspace that would own the rule */ + policy: Policy | undefined; +}; + +/** + * Resolves the "Create a rule" callout: someone who can write workspace rules just edited a field a merchant rule can + * govern, and hasn't dismissed the offer for that expense. + * + * @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}`); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(storedSuggestion?.transactionID)}`); + const {canWrite: canWriteRules} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); + + // The callout belongs on a screen showing the expense itself: its transaction thread, or a report holding only + // that expense. A report listing several shows no expense detail. Asking the report what it holds beats trusting + // whichever report was 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?.reportID || isOneExpenseReportForSuggestion); + const isForThisExpenseView = isMerchantRuleSuggestionLive(storedSuggestion) && isHostingReport; + // 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. + // + // 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 + // the draft. + 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 && 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. + if (!suggestion || !transaction || isMerchantMissing(transaction) || fields.length === 0 || !hasUpdatesToApply) { + return {suggestion: undefined, fields: [], editedTagLevels: undefined, transaction: undefined, policy: undefined}; + } + + return {suggestion, fields, editedTagLevels, transaction, policy}; +} + +export default useMerchantRuleSuggestion; diff --git a/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts new file mode 100644 index 000000000000..aa8d7454c841 --- /dev/null +++ b/src/hooks/useRetireMerchantRuleSuggestionOnLeave.ts @@ -0,0 +1,52 @@ +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 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. + * + * 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. + * + * @param reportID - the report this list belongs to, matched against the one the callout recorded itself in + */ +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 [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 = !!reportID && liveSeenInReportID === reportID; + }, [liveSeenInReportID, reportID]); + + useEffect( + () => () => { + if (!hasBeenSeenRef.current) { + return; + } + retireMerchantRuleSuggestion(); + }, + [], + ); +} + +export default useRetireMerchantRuleSuggestionOnLeave; diff --git a/src/languages/de.ts b/src/languages/de.ts index 64369fbe6d64..da12629cdf05 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8054,6 +8054,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und turnOnTaxesFirstPrompt: 'Kategorienregeln legen einen Standardsteuersatz fest. Aktivieren Sie Steuern in Ihren Workspace-Einstellungen, um sie zu verwenden.', categoryRulesApplyGoingForwardTitle: 'Kategorienregeln gelten ab jetzt', categoryRulesApplyGoingForwardPrompt: 'Ein Standardsatz für Steuern gilt für neue Ausgaben in dieser Kategorie. Bereits vorhandene Ausgaben werden nicht geändert.', + 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 9761562bca1f..50ee8c83724a 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8382,6 +8382,8 @@ ${reportName}`, categoryRulesApplyGoingForwardTitle: 'Οι κανόνες κατηγορίας ισχύουν από εδώ και στο εξής', categoryRulesApplyGoingForwardPrompt: 'Ένας προεπιλεγμένος φορολογικός συντελεστής εφαρμόζεται σε νέες δαπάνες σε αυτή την κατηγορία. Οι δαπάνες που υπάρχουν ήδη δεν θα αλλάξουν.', + createRuleFromExpenseAction: 'Δημιουργία κανόνα', + createRuleFromExpensePrompt: 'για να εφαρμόσετε τις αλλαγές σας σε όλες τις δαπάνες που ταιριάζουν με τα κριτήριά σας.', }, newRule: { title: 'Νέος κανόνας', diff --git a/src/languages/en.ts b/src/languages/en.ts index 3411b8da2b85..01063dc753b5 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8274,6 +8274,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/languages/es.ts b/src/languages/es.ts index c23f6ba2d1bf..088ad7d6920a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8053,6 +8053,8 @@ ${reportName}`, 'Las reglas de categoría establecen una tasa de impuesto predeterminada. Activa los impuestos en la configuración de tu espacio de trabajo para usarlos.', categoryRulesApplyGoingForwardTitle: 'Las reglas de categoría se aplican de ahora en adelante', categoryRulesApplyGoingForwardPrompt: 'Se aplica una tasa de impuesto predeterminada a los nuevos gastos de esta categoría. Los gastos que ya existen no cambiarán.', + 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 6a8fd7075903..ce12a214834a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8077,6 +8077,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip categoryRulesApplyGoingForwardTitle: 'Les règles de catégorie s’appliquent à partir de maintenant', categoryRulesApplyGoingForwardPrompt: 'Un taux de taxe par défaut s’applique aux nouvelles dépenses de cette catégorie. Les dépenses déjà existantes ne seront pas modifiées.', + 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 821601675e2e..0d73ab4b3fb6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8013,6 +8013,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo turnOnTaxesFirstPrompt: 'Le regole di categoria impostano un’aliquota fiscale predefinita. Attiva le imposte nelle impostazioni dello spazio di lavoro per usarle.', categoryRulesApplyGoingForwardTitle: 'Le regole di categoria si applicano da ora in poi', categoryRulesApplyGoingForwardPrompt: 'Un’aliquota fiscale predefinita viene applicata alle nuove spese in questa categoria. Le spese già esistenti non verranno modificate.', + 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 91ac703f21f7..75cf2834343b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7916,6 +7916,8 @@ ${reportName}`, turnOnTaxesFirstPrompt: 'カテゴリルールでは、デフォルトの税率を設定できます。利用するには、ワークスペース設定で税金を有効にしてください。', categoryRulesApplyGoingForwardTitle: 'カテゴリルールは今後に適用されます', categoryRulesApplyGoingForwardPrompt: 'このカテゴリーの新しい経費には、デフォルトの税率が適用されます。既存の経費は変更されません。', + createRuleFromExpenseAction: 'ルールを作成', + createRuleFromExpensePrompt: '条件に一致するすべての経費に変更を適用します。', }, categoryRules: { title: 'カテゴリルール', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d9d5feaf84d7..cafe8def0d37 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7989,6 +7989,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar turnOnTaxesFirstPrompt: 'Categorischregels stellen een standaardbelastingtarief in. Schakel belastingen in bij de instellingen van je workspace om ze te gebruiken.', categoryRulesApplyGoingForwardTitle: 'Categoriegregels gelden vanaf nu', categoryRulesApplyGoingForwardPrompt: 'Een standaardbelastingtarief is van toepassing op nieuwe uitgaven in deze categorie. Bestaande uitgaven veranderen niet.', + 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 1347db8696ef..cc2315f94935 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8014,6 +8014,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i turnOnTaxesFirstPrompt: 'Reguły kategorii ustawiają domyślną stawkę podatku. Włącz podatki w ustawieniach swojego workspace, aby z nich korzystać.', categoryRulesApplyGoingForwardTitle: 'Reguły kategorii będą stosowane od teraz', categoryRulesApplyGoingForwardPrompt: 'Domyślna stawka podatku będzie stosowana do nowych wydatków w tej kategorii. Istniejące wydatki nie zostaną zmienione.', + 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 f747ae51e755..9370a927fca7 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7997,6 +7997,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e turnOnTaxesFirstPrompt: 'As regras de categoria definem uma alíquota de imposto padrão. Ative os impostos nas configurações do seu workspace para usá-los.', categoryRulesApplyGoingForwardTitle: 'As regras de categoria se aplicam daqui em diante', categoryRulesApplyGoingForwardPrompt: 'Uma taxa de imposto padrão se aplica às novas despesas desta categoria. As despesas que já existem não serão alteradas.', + 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 d44fdfb3957a..8aebf2a07c3c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7720,6 +7720,8 @@ ${reportName}`, turnOnTaxesFirstPrompt: '类别规则会设置默认税率。请在工作区设置中启用税费以使用此功能。', categoryRulesApplyGoingForwardTitle: '类别规则将从现在起生效', categoryRulesApplyGoingForwardPrompt: '此类别中的新报销将应用默认税率,已存在的报销不会改变。', + createRuleFromExpenseAction: '创建规则', + createRuleFromExpensePrompt: '将您的更改应用于所有符合条件的报销。', }, categoryRules: { title: '类别规则', diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index b32d30db5e98..24935b50c9ea 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -329,6 +329,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.RAM_ONLY_IS_CHECKING_PUBLIC_ROOM, 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/MerchantRuleSuggestionUtils.ts b/src/libs/MerchantRuleSuggestionUtils.ts new file mode 100644 index 000000000000..239c11e38f80 --- /dev/null +++ b/src/libs/MerchantRuleSuggestionUtils.ts @@ -0,0 +1,115 @@ +import CONST from '@src/CONST'; +import type {MerchantRuleForm} from '@src/types/form'; +import type {MerchantRuleSuggestion, Policy, Transaction} from '@src/types/onyx'; +import type {MerchantRuleSuggestionField} from '@src/types/onyx/MerchantRuleSuggestion'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import Parser from './Parser'; +import {resolveCurrentTaxCode} from './PolicyUtils'; +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 { + if (!suggestion?.transactionID || suggestion.isRetired) { + return false; + } + return !suggestion.dismissedTransactionIDs?.includes(suggestion.transactionID); +} + +/** + * Which levels of a multi-level tag changed. Editing one level of `A:B:C` should seed a rule for that level alone, + * and the update action only sees the whole joined tag, so the levels are worked out by comparing before with after. + */ +function getChangedTagLevels(previousTag: string, nextTag: string): number[] { + const previousLevels = getTagArrayFromName(previousTag); + const nextLevels = getTagArrayFromName(nextTag); + const changed: number[] = []; + + for (let level = 0; level < Math.max(previousLevels.length, nextLevels.length); level++) { + if ((previousLevels.at(level) ?? '') === (nextLevels.at(level) ?? '')) { + continue; + } + changed.push(level); + } + + return changed; +} + +/** The rule draft for one edited field, in the shape the rule form expects. */ +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. 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); + 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. + 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: { + // An expense description is stored as HTML, while the rule form edits markdown + const description = getDescription(transaction); + return description ? {comment: Parser.htmlToMarkdown(description)} : {}; + } + // 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: + return {reimbursable: getReimbursable(transaction)}; + default: + return {}; + } +} + +/** + * 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, + editedTagLevels?: Record, +): Partial | undefined { + if (!transaction || isMerchantMissing(transaction)) { + return undefined; + } + + // 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) { + Object.assign(draft, getDraftForField(field, transaction, policy, editedTagLevels)); + } + + return draft; +} + +export {getChangedTagLevels, getMerchantRuleDraftFromTransaction, isMerchantRuleSuggestionLive}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 0ae6806b8391..87e3b9675b26 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1164,6 +1164,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_CATEGORY_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryToMatchPage').default, [SCREENS.WORKSPACE.RULES_CATEGORY_TAX_EDIT]: () => require('../../../../pages/workspace/rules/MerchantRules/EditCategoryTaxRulePage').default, [SCREENS.WORKSPACE.RULES_AGENT_NEW]: () => require('../../../../pages/workspace/rules/AgentRules/AddAgentRulePage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index e9b8c8f6f9c0..7c2e87ba7be1 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1311,6 +1311,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 9130fe65d7bd..d275bc882e79 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1712,6 +1712,57 @@ 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 | string; + ruleID?: undefined; + }; + [SCREENS.WORKSPACE.DYNAMIC_RULES_MERCHANT_TAX]: { + policyID: string; + ruleID?: undefined; + /** The callout flow only ever creates merchant rules, never a category tax default */ + categoryName?: 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/IOU/UpdateMoneyRequest.ts b/src/libs/actions/IOU/UpdateMoneyRequest.ts index dc42a4112eca..7204751be47d 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, @@ -41,6 +43,7 @@ import { } from '@libs/TransactionUtils'; import ViolationsUtils, {syncCustomUnitRateOutOfDateRangeViolation} from '@libs/Violations/ViolationsUtils'; +import {getMerchantRuleSuggestionRollback, trackMerchantRuleSuggestion} from '@userActions/MerchantRuleSuggestion'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import {stringifyWaypointsForAPI} from '@userActions/Transaction'; @@ -48,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'; @@ -345,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?.push(rollback); +} + /** Updates the billable field of an expense */ function updateMoneyRequestBillable({ transactionID, @@ -408,7 +429,9 @@ 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); } function updateMoneyRequestReimbursable({ @@ -476,7 +499,9 @@ 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); } /** Updates the merchant field of an expense */ @@ -826,6 +851,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; @@ -851,6 +878,7 @@ function updateMoneyRequestTag({ parentReport, iouReportOwnerLogin, tag, + tagListIndex, policy, policyTagList, policyRecentlyUsedTags, @@ -893,7 +921,18 @@ function updateMoneyRequestTag({ getCurrencyDecimals, getCurrencySymbol, }); + // 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. 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); } /** Updates the created tax amount of an expense */ @@ -1025,7 +1064,9 @@ 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); } type UpdateMoneyRequestDistanceParams = { @@ -1266,7 +1307,9 @@ 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); } /** Updates the description of an expense */ @@ -1353,7 +1396,9 @@ 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); } /** Updates the distance rate of an expense */ @@ -1645,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 new file mode 100644 index 000000000000..47133762aa7c --- /dev/null +++ b/src/libs/actions/MerchantRuleSuggestion.ts @@ -0,0 +1,119 @@ +import {arePolicyRulesEnabled, isControlPolicy} from '@libs/PolicyUtils'; + +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, 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. + * + * 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. + */ +function trackMerchantRuleSuggestion( + transactionID: string | undefined, + field: MerchantRuleSuggestionField, + 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. 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, + value: { + editedFields: {[transactionID]: {[field]: null}}, + ...(editedTagLevels?.length ? {editedTagLevels: {[transactionID]: Object.fromEntries(editedTagLevels.map((level) => [level, null]))}} : {}), + }, + }; +} + +/** + * 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}); +} + +/** + * 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, { + dismissedTransactionIDs: [...new Set([...(suggestion.dismissedTransactionIDs ?? []), suggestion.transactionID])], + }); +} + +/** + * 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}, editedTagLevels: {[transactionID]: null}}); +} + +/** 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}); +} + +export { + trackMerchantRuleSuggestion, + getMerchantRuleSuggestionRollback, + dismissMerchantRuleSuggestion, + markMerchantRuleSuggestionSeen, + retireMerchantRuleSuggestion, + clearMerchantRuleSuggestionFields, +}; diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 4785cdee515e..408514130258 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 useConciergeSessionStartTime from '@hooks/useConciergeSessionStartTime'; @@ -11,6 +12,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'; @@ -141,6 +143,9 @@ 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); + // 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(':'); @@ -444,6 +449,14 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return ( <> + {/* 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. */} + ); + // The callout decides for itself whether this mount suits the layout and the composer size. + const merchantRuleBanner = ( + + ); return ( + {merchantRuleBanner} {shouldShowEnableNotificationsBanner ? ( <> diff --git a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx index b9e8947b97b9..fb6faac15039 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepTag.tsx @@ -175,6 +175,7 @@ function DynamicIOURequestStepTag({ parentReport, iouReportOwnerLogin, tag: updatedTag, + tagListIndex, policy, policyTagList: policyTags, policyRecentlyUsedTags, 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..aef9c80dd2eb 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 ROUTES, {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,23 @@ type MatchTypeItem = ListItem & { function AddMatchTypePage({route}: AddMatchTypePageProps) { const {policyID, ruleID} = route.params; + // 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, + ruleID, + ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, ruleID !== ROUTES.NEW ? ruleID : undefined), + ); 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 d4ee3c04241c..fc284f45341a 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -15,7 +15,7 @@ import React, {useEffect} from 'react'; import MerchantRulePageBase from './MerchantRulePageBase'; -type AddMerchantRulePageProps = PlatformStackScreenProps; +type AddMerchantRulePageProps = PlatformStackScreenProps; function AddMerchantRulePage({route}: AddMerchantRulePageProps) { const {policyID} = route.params; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx index 4a176be43c68..585a9a69f613 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) => { @@ -117,7 +121,11 @@ function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { 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)), + ) + } value={getMatchTypeLabel()} /> 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 26a32db3434d..6e9643bed77b 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,15 @@ 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 {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); const [policyTags = getEmptyArray>()] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {selector: getTagLists}); @@ -51,8 +55,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 c4897c48a6ed..dff2c3f98e3d 100644 --- a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx @@ -11,16 +11,25 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; 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 React from 'react'; -type AddTaxPageProps = PlatformStackScreenProps; +import useMerchantRuleRoute from './useMerchantRuleRoute'; + +type AddTaxPageProps = PlatformStackScreenProps; function AddTaxPage({route}: AddTaxPageProps) { const {policyID, ruleID, categoryName} = route.params; - const isEditing = ruleID !== ROUTES.NEW; + // A category tax default carries no ruleID, so it routes back by category instead. Everything else follows the + // flow this page was opened from, which the callout enters through dynamic routes. + const {backToRoute} = useMerchantRuleRoute( + DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, + policyID, + ruleID, + categoryName ? ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, categoryName) : undefined, + ); const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -44,15 +53,6 @@ function AddTaxPage({route}: AddTaxPageProps) { const selectedTaxItem = form?.tax ? taxItems.find(({value}) => value === form.tax) : undefined; - // A category tax default carries no ruleID, so it routes back by category instead. - const getBackToRoute = () => { - if (categoryName) { - return ROUTES.RULES_CATEGORY_TAX_EDIT.getRoute(policyID, categoryName); - } - return isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); - }; - const backToRoute = getBackToRoute(); - 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 01576c8185ea..d27b63ccbb6e 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -46,7 +46,7 @@ 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 {ExpenseDefaultRuleType} from '@src/types/form/MerchantRuleForm'; @@ -61,6 +61,8 @@ 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; @@ -151,6 +153,7 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe const [isClosing, setIsClosing] = 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 isEditingCategoryTaxRule = !!editCategoryTaxRuleFor; // A category tax default has no ruleID, so neither flag alone means "saved". const isEditingSavedRule = isEditing || isEditingCategoryTaxRule; @@ -393,7 +396,11 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe } setPolicyCodingRule(policyID, form, policy, ruleID, shouldUpdateMatchingTransactions); - if (!isEditing && isRulesRevampEnabled) { + if (isCreatedFromExpense) { + // 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) { goBackToExpenseDefaults(); } else { Navigation.goBack(); @@ -477,7 +484,10 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe // The rule's only condition, since the type is chosen before this page opens. 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), }, isRulesRevampEnabled && isScopedToCategory @@ -499,7 +509,7 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe 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() @@ -507,7 +517,8 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe 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, @@ -522,7 +533,13 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe key: `tag-${name}-${orderWeight}`, description: name, title: isTagAvailable && 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), }; }) @@ -534,7 +551,10 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe key: 'tax', description: translate('common.tax'), title: taxDisplayName, - onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID, ruleID, editCategoryTaxRuleFor)), + onPress: () => + Navigation.navigate( + getRuleRoute(DYNAMIC_ROUTES.RULES_MERCHANT_TAX_FROM_EXPENSE.path, ROUTES.RULES_MERCHANT_TAX.getRoute(policyID, ruleID, editCategoryTaxRuleFor)), + ), icon: getItemIcon(icons.InvoiceGeneric), } : undefined, @@ -543,7 +563,7 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe 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, @@ -551,7 +571,8 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe 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), }, @@ -559,7 +580,8 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe 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 @@ -567,7 +589,8 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe 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, @@ -582,7 +605,7 @@ function MerchantRulePageBase({policyID, ruleID, editCategoryTaxRuleFor, titleKe 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 && !isClosing) { diff --git a/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx b/src/pages/workspace/rules/MerchantRules/PreviewMatchesPage.tsx index e956104be0ad..da556c16ca26 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(); @@ -84,12 +89,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..34c44c3d0c68 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/useMerchantRuleRoute.ts @@ -0,0 +1,66 @@ +import useDynamicBackPath from '@hooks/useDynamicBackPath'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import isDynamicRouteScreen from '@libs/Navigation/helpers/dynamicRoutesUtils/isDynamicRouteScreen'; + +import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import type {Screen} from '@src/SCREENS'; + +import {useRoute} from '@react-navigation/native'; + +type MerchantRuleRoute = { + /** 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 creates, so it is never editing. */ + isEditing: boolean; + + /** 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 the stack the flow was entered from. + * + * @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, reachable both from workspace settings and from the "Create a rule" callout. + * + * 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 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 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 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 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; + // 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); + + return { + isCreatedFromExpense, + isEditing, + backToRoute: isCreatedFromExpense ? dynamicBackToRoute : (staticBackToRoute ?? ruleRoute), + getRuleRoute, + }; +} + +export default useMerchantRuleRoute; diff --git a/src/setup/index.ts b/src/setup/index.ts index 3a7382a3dd5f..93c6856283d3 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -86,6 +86,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.RAM_ONLY_MERCHANT_RULE_SUGGESTION, ONYXKEYS.COLLECTION.RAM_ONLY_EXPENSIFY_CARD_LOADING_STATE, ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ONYXKEYS.RAM_ONLY_MERGE_HR_LINK_TOKEN, diff --git a/src/styles/index.ts b/src/styles/index.ts index dab1b938ea21..ca635ee221a9 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4441,6 +4441,45 @@ const staticStyles = (theme: ThemeColors) => flexShrink: 1, }, + merchantRuleCalloutContainer: { + backgroundColor: theme.tooltipHighlightBG, + borderRadius: variables.componentBorderRadiusNormal, + }, + + // 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: 101, + }, + + // Floats above the composer without taking height, so the conversation does not jump when it appears. + merchantRuleCalloutComposerOverlay: { + ...positioning.pAbsolute, + ...positioning.bFull, + ...positioning.l0, + ...positioning.r0, + zIndex: 100, + }, + + merchantRuleCalloutText: { + ...textVariants.label, + color: theme.textReversed, + // 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, so text and link + // use the reversed colors. + merchantRuleCalloutAction: { + ...textVariants.labelStrong, + color: theme.linkReversed, + }, + quickReactionsContainer: { gap: 12, flexDirection: 'row', diff --git a/src/types/onyx/MerchantRuleSuggestion.ts b/src/types/onyx/MerchantRuleSuggestion.ts new file mode 100644 index 000000000000..1f6ac87538cb --- /dev/null +++ b/src/types/onyx/MerchantRuleSuggestion.ts @@ -0,0 +1,49 @@ +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +/** Expense field a merchant rule can govern */ +type MerchantRuleSuggestionField = ValueOf; + +/** + * The expense edits that can become a merchant rule, which drive the "Create a rule" callout. + * + * RAM-only, so the callout can appear again on the same expense next session. + */ +type MerchantRuleSuggestion = { + /** The most recently edited expense, the one currently offering */ + transactionID: string; + + /** That expense's transaction thread, where the callout can show */ + reportID: string; + + /** + * 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>>; + + /** + * 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[]; + + /** + * 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. + */ + seenInReportID?: string; + + /** Whether the offer was seen and left. Unlike dismissing, editing the expense again offers afresh. */ + isRetired?: boolean; +}; + +export default MerchantRuleSuggestion; +export type {MerchantRuleSuggestionField}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 37f34aa2f56e..29c04f7e9fbc 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -113,6 +113,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'; @@ -358,6 +359,7 @@ export type { TaxRates, TaxRatesWithDefault, Transaction, + MerchantRuleSuggestion, MergeTransaction, TransactionViolation, TransactionViolations, diff --git a/tests/unit/MerchantRuleSuggestionUtilsTest.ts b/tests/unit/MerchantRuleSuggestionUtilsTest.ts new file mode 100644 index 000000000000..4c95590467f0 --- /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({seenInReportID: '999'}))).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); + }); +});