-
Notifications
You must be signed in to change notification settings - Fork 4k
Surface "Create a rule" prompt on expense edits to drive merchant rule adoption in-situ #99164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
58 commits
Select commit
Hold shift + click to select a range
bd95724
Surface "Create a rule" prompt on expense edits
Krishna2323 3e1ea38
Render the merchant rule prompt as a product training tooltip
Krishna2323 dbf97a6
Merge branch 'main' into krishna2323/issue-96354
Krishna2323 9029ae6
Render the merchant rule prompt as a product training tooltip
Krishna2323 08162e8
Fix the merchant rule tooltip's highlight color and scroll behavior
Krishna2323 840858a
Align the merchant rule tooltip with the product training conventions
Krishna2323 e22352d
Use a link color and bold weight for the tooltip's highlighted phrase
Krishna2323 23f8c24
Retire the merchant rule offer once it has been seen
Krishna2323 b166e9e
Drop the deprecated backTo param from the merchant rule flow
Krishna2323 3797282
Scope the merchant rule callout to one expense at a time
Krishna2323 ab0ddbe
Persist merchant rule callout dismissals per expense
Krishna2323 c00c76a
Scope merchant rule callout dismissals to the session
Krishna2323 dd08701
Render the merchant rule prompt as a pinned banner
Krishna2323 b1630d9
Keep navigating away from re-offering, without silencing the expense
Krishna2323 39c61b4
Stop the merchant rule callout rendering twice in a wide RHP
Krishna2323 b8b1fc5
Float the composer callout instead of giving it layout height
Krishna2323 6307b9b
Compose the merchant rule overlays from the positioning helpers
Krishna2323 ccba862
Retire the merchant rule callout when the offer is taken
Krishna2323 8813e3e
Merge branch 'Expensify:main' into krishna2323/issue-96354
Krishna2323 29b4e12
Simplify the merchant rule callout comments and tighten two rule viol…
Krishna2323 00e0a1b
Slide the merchant rule callout in and out
Krishna2323 cf4ec20
Stop the merchant rule callout breaking mid-word
Krishna2323 efd5d87
Drop the deprecated Onyx.connectWithoutView from the merchant rule ac…
Krishna2323 5d7a035
Guard the wide RHP lookup against a missing route
Krishna2323 97d84f6
update translations.
Krishna2323 99d98e1
Match the callout link's size and open it to everyone who can write r…
Krishna2323 3fd6dc4
Merge branch 'Expensify:main' into krishna2323/issue-96354
Krishna2323 98be3f1
Only record a merchant rule offer where a rule could exist
Krishna2323 ff6842e
Open the merchant rule flow as a suffix on the expense that started it
Krishna2323 5341423
fix knip check.
Krishna2323 99208d3
Use the reversed link color and the label typography on the callout
Krishna2323 2cb0570
Record every field edited on an expense, not just the last one
Krishna2323 3912c8b
Merge branch 'Expensify:main' into krishna2323/issue-96354
Krishna2323 51ff413
Address review: fix the match type back button and tighten the callout
Krishna2323 ce0991f
Coerce the tag page's orderWeight, which dynamic routes deliver as a …
Krishna2323 c5210c2
Retire the rule suggestion when the report is left, not when the call…
Krishna2323 45280fb
Derive the merchant rule fields from EDIT_REQUEST_FIELD and use the s…
Krishna2323 9c7a62e
Shorten and simplify the comments added in this PR
Krishna2323 0c6799a
Seed a merchant rule with only the tag levels the user actually edited
Krishna2323 6562772
Take the edited tag level from the caller, and clear it once the rule…
Krishna2323 9b1fd7f
fix merge conflicts.
Krishna2323 7d84ba0
Retire the offer when the expense page is left, and skip offers that …
Krishna2323 062c134
Drop the callout instantly when the composer expands, without animati…
Krishna2323 4281f48
Seed the rule type from the callout so it opens the merchant editor d…
Krishna2323 3b31b89
Paint the create-a-rule callout above the new-messages pill
Krishna2323 fd93496
Hide the callout on Collect, where the rule page is out of reach
Krishna2323 8050a3e
Hold the create-a-rule callout behind the rules revamp beta
Krishna2323 009198a
Merge branch 'main' into krishna2323/issue-96354
Krishna2323 530ecdd
Stop the callout flickering in, and show it only on the focused repor…
Krishna2323 167aeea
Spring the callout in instead of fading it with a layout animation
Krishna2323 4880d5a
Keep hidden offers unseen, and stop the retire hook waking the report…
Krishna2323 acea7d9
Roll back offers on failed edits, skip unused back-path work, and cov…
Krishna2323 135ec85
fix spell check.
Krishna2323 eebf269
fix ESLint.
Krishna2323 6a94c72
Merge branch 'main' into krishna2323/issue-96354
Krishna2323 8e16905
Scope offer retirement to the report that showed it, and stop trackin…
Krishna2323 24ed985
Track edits only on write success, drop the failure rollback
Krishna2323 8828ee1
Track edits optimistically again, so the offer shows offline
Krishna2323 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1050,6 +1050,7 @@ | |
| "tosorted", | ||
| "touchables", | ||
| "tranid", | ||
| "travelled", | ||
| "trinet", | ||
| "trivago", | ||
| "trustcacerts", | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ViewStyle>; | ||
|
|
||
| /** When set, floats the callout in a wrapper carrying these styles instead of laying it out inline */ | ||
| overlayStyles?: StyleProp<ViewStyle>; | ||
|
|
||
| /** | ||
| * 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 ( | ||
| <Animated.View style={[overlayStyles, slideStyle]}> | ||
| {!isComposerFullSize && ( | ||
| <Banner | ||
| containerStyles={[styles.merchantRuleCalloutContainer, styles.p4, containerStyles]} | ||
| shouldShowCloseButton | ||
| onClose={dismiss} | ||
| content={ | ||
| <> | ||
| <View style={styles.mr3}> | ||
| <Icon | ||
| src={icons.Lightbulb} | ||
| fill={theme.tooltipHighlightText} | ||
| width={variables.iconSizeNormal} | ||
| height={variables.iconSizeNormal} | ||
| /> | ||
| </View> | ||
| <Text style={[styles.flex1, styles.merchantRuleCalloutText, styles.mr3]}> | ||
| <TextLink | ||
| style={styles.merchantRuleCalloutAction} | ||
| onPress={createRule} | ||
| > | ||
| {translate('workspace.rules.merchantRules.createRuleFromExpenseAction')} | ||
| </TextLink> | ||
| {` ${translate('workspace.rules.merchantRules.createRuleFromExpensePrompt')}`} | ||
| </Text> | ||
| </> | ||
| } | ||
| /> | ||
| )} | ||
| </Animated.View> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 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 ( | ||
| <MerchantRuleSuggestionBannerContent | ||
| reportID={reportID} | ||
| policyID={policyID} | ||
| containerStyles={containerStyles} | ||
| overlayStyles={overlayStyles} | ||
| isAnchoredToBottom={isAnchoredToBottom} | ||
| isComposerFullSize={isComposerFullSize} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export default MerchantRuleSuggestionBanner; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This gate re-implements the
isRetired/ dismissed /transactionIDchecks fromuseMerchantRulesSuggestionhere and subscribes to the same Onyx key a second time.One of the two should own the decision.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isMerchantRuleSuggestionLiveowns the predicate now, so the inline checks are gone. The duplicate subscription is deliberate, it’s a cheap gate on a RAM-only key that keeps the inner hook’s subscriptions unmounted until there’s an edit.