Repository navigation
Confirm new expenses with a “View” toast instead of scrolling to and highlighting the row v2 #100015
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
mountiny
merged 39 commits into
Expensify:main
from
software-mansion-labs:declarative-expense-added-growl-v2
Oct 1, 2026
Merged
Confirm new expenses with a “View” toast instead of scrolling to and highlighting the row v2 #100015
Changes from all commits
Commits
Show all changes
39 commits
Select commit
Hold shift + click to select a range
e207d6f
Reapply "Confirm new expenses with a “View” toast instead of scrollin…
mhawryluk fd766b6
Fix conflict resolution
mhawryluk b90f5bf
fix: Expense - Report on central pane changes to not here page after …
mhawryluk f8c10c3
fix: Expense added growl is not shown after creating per diem expense…
mhawryluk aaed79a
fix: Android - Expense - Report is opened a second time when tapping …
mhawryluk 1bb1dc8
fix: Expense - Expense added growl is hidden behind Concierge side panel
mhawryluk a78dca1
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 2dc4d7b
Fix after merge
mhawryluk 92431c5
fix: Android - Workspace chat opens briefly after opening expense fro…
mhawryluk 208954f
fix: Report - WS avatar disappears when moving expense opened via gro…
mhawryluk 3daf9a3
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 2eaed47
Add more tests for expense added growls
mhawryluk edaf067
Fix eslint error
mhawryluk 78022dc
Use getSearchRowBackgroundStyle in ChatListItem and TaskListItem
mhawryluk 1519ca3
Use getFocusedReportId for checking current report in ExpenseAddedGrowl
mhawryluk 1358757
Move navigateToCreatedExpense to a separate file
mhawryluk 63cde1b
Drop broder-radius:0 in TransactionListItemNarrow wrapper style
mhawryluk e290ed3
Make EXPENSE_ADDED_GROWL_TRANSACTION_IDS onyx key ram only
mhawryluk 00be077
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 8089abf
Fix after merge
mhawryluk c01860c
Fix test
mhawryluk b72298d
Fix formatting
mhawryluk a4474d7
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 576165c
Clear pending growl in dismissModalAndOpenReportInInboxTab when navig…
mhawryluk df79307
Ensure growl for split and duplicate shows up in Spend only
mhawryluk 2c87e05
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 560dd21
Fix after merge
mhawryluk b6639c1
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 7a33bee
Replace ONYXKEYS.PERSONAL_DETAILS_LIST usage in ExpenseAddedGrowlContent
mhawryluk 0d2fa31
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 564a15e
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk b6f1eee
Remove unused getSearchTableHighlightBorderRadius
mhawryluk d0a86cc
Add comment explaining dropped backTo in navigateToCreatedExpense
mhawryluk 20b6ec7
Expand the comment for signalExpenseAddedGrowl
mhawryluk 93711ee
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 46220ca
Fix after merge
mhawryluk c0fc03a
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 84e8c0f
Fix after merge
mhawryluk 99ee7a1
Merge branch 'main' into declarative-expense-added-growl-v2
mhawryluk 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
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,168 @@ | ||
| import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; | ||
| import useLocalize from '@hooks/useLocalize'; | ||
| import useOnyx from '@hooks/useOnyx'; | ||
| import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; | ||
| import useReportTransactions from '@hooks/useReportTransactions'; | ||
|
|
||
| import {createTransactionThreadReport, setOptimisticTransactionThread} from '@libs/actions/Report'; | ||
| import {mergeExpenseAddedGrowlTransactionIDs} from '@libs/actions/Transaction'; | ||
| import Log from '@libs/Log'; | ||
| import navigateToCreatedExpense from '@libs/Navigation/helpers/navigateToCreatedExpense'; | ||
| import Navigation from '@libs/Navigation/Navigation'; | ||
| import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; | ||
| import {findSelfDMReportID, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; | ||
|
|
||
| import CONST from '@src/CONST'; | ||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
| import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; | ||
|
|
||
| import type {Dispatch, SetStateAction} from 'react'; | ||
|
|
||
| import {useEffect, useRef, useState} from 'react'; | ||
|
|
||
| import GrowlNotificationContent from './GrowlNotification/GrowlNotificationContent'; | ||
|
|
||
| type ActiveGrowl = { | ||
| /** ID of the transaction the growl was raised for */ | ||
| transactionID: string; | ||
|
|
||
| /** Search data type the growl belongs to (expense, invoice, etc.) */ | ||
| dataType: SearchDataTypes; | ||
|
|
||
| /** Identifies each growl instance so a stale slide-out dismissal can't clear a newer growl */ | ||
| nonce: number; | ||
| }; | ||
|
|
||
| type ExpenseAddedGrowlContentProps = { | ||
| /** Transaction to read for the growl: the active growl's transaction, or the latest pending signal ID */ | ||
| transactionID: string; | ||
|
|
||
| /** Pending expense-added notifications, keyed by transaction ID with its search data type as the value */ | ||
| signal: Record<string, SearchDataTypes> | undefined; | ||
|
|
||
| /** Currently displayed growl */ | ||
| active: ActiveGrowl | null; | ||
|
|
||
| /** Setter for the active growl */ | ||
| setActive: Dispatch<SetStateAction<ActiveGrowl | null>>; | ||
| }; | ||
|
|
||
| /** Watches the "an expense was just added" Onyx signal and shows an "Expense added" growl with a "View" action. */ | ||
| function ExpenseAddedGrowl() { | ||
| const [active, setActive] = useState<ActiveGrowl | null>(null); | ||
| const [signal] = useOnyx(ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS); | ||
| const transactionID = active?.transactionID ?? Object.keys(signal ?? {}).at(-1); | ||
|
|
||
| if (!transactionID) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <ExpenseAddedGrowlContent | ||
| transactionID={transactionID} | ||
| signal={signal} | ||
| active={active} | ||
| setActive={setActive} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| function ExpenseAddedGrowlContent({transactionID, signal, active, setActive}: ExpenseAddedGrowlContentProps) { | ||
| const nonceRef = useRef(0); | ||
|
|
||
| const {translate} = useLocalize(); | ||
| const currentUserPersonalDetails = useCurrentUserPersonalDetails(); | ||
| const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); | ||
| const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); | ||
| const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); | ||
| const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); | ||
| const reportID = transaction?.reportID; | ||
| const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); | ||
|
|
||
| // A tracked expense's IOU action lives on the self-DM report, not on transaction.reportID, so read actions | ||
| // from the report that actually hosts it so "View" can resolve the real transaction thread. | ||
| const isUnreportedExpense = !reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID; | ||
| const selfDMReportID = isUnreportedExpense ? findSelfDMReportID() : undefined; | ||
| const hostReportID = isUnreportedExpense ? selfDMReportID : reportID; | ||
| const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${hostReportID}`); | ||
| const iouAction = getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID); | ||
| const [personalDetails] = usePersonalDetailsByIDs([currentUserPersonalDetails?.accountID, iouAction?.actorAccountID]); | ||
|
|
||
| // A tracked expense lives in a chat, not an expense report. Leaving iouReportID undefined tells | ||
| // navigateToCreatedExpense to open the transaction thread directly instead of an expense report RHP. | ||
| const iouReport = isMoneyRequestReport(report) || isInvoiceReport(report) ? report : undefined; | ||
| const iouReportID = iouReport?.reportID; | ||
| const reportTransactions = useReportTransactions(iouReportID); | ||
|
|
||
| useEffect(() => { | ||
| if (active) { | ||
| return; | ||
| } | ||
| const pendingTransactionIDs = Object.keys(signal ?? {}); | ||
|
|
||
| // The last key is the last-created transaction: IDs are large (rand64), so they exceed the array-index | ||
| // range and JS preserves insertion order rather than sorting them numerically ascending. | ||
| const latestTransactionID = pendingTransactionIDs.at(-1); | ||
| const dataType = latestTransactionID ? signal?.[latestTransactionID] : undefined; | ||
| if (!latestTransactionID || !dataType || !transaction) { | ||
| return; | ||
| } | ||
|
|
||
| // Clear every pending ID but surface only the latest: rapid creations that pile up before this runs | ||
| // collapse into a single growl for the newest expense. | ||
| mergeExpenseAddedGrowlTransactionIDs(Object.fromEntries(pendingTransactionIDs.map((id) => [id, null]))); | ||
|
|
||
| // Suppress the growl when the user is already viewing the expense's money-request report | ||
| if (Navigation.getFocusedReportId() === transaction.reportID) { | ||
| return; | ||
| } | ||
| nonceRef.current += 1; | ||
| setActive({transactionID: latestTransactionID, dataType, nonce: nonceRef.current}); | ||
| }, [signal, active, transaction, setActive]); | ||
|
|
||
| if (!active) { | ||
| return null; | ||
| } | ||
|
|
||
| const isInvoice = active.dataType === CONST.SEARCH.DATA_TYPES.INVOICE; | ||
|
|
||
| // Build the thread on press rather than when the growl shows, so it is only created if the user taps | ||
| // "View" and is built against the freshest Onyx data. | ||
| const navigateToExpense = () => { | ||
| let threadReportID = transaction?.transactionThreadReportID ?? iouAction?.childReportID; | ||
| if (threadReportID) { | ||
| setOptimisticTransactionThread(threadReportID, iouReport?.reportID, iouAction?.reportActionID, iouReport?.policyID); | ||
| } else { | ||
| const optimisticThread = createTransactionThreadReport({ | ||
| introSelected, | ||
| conciergeChat, | ||
| currentUserLogin: currentUserPersonalDetails?.login ?? '', | ||
| currentUserAccountID: currentUserPersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID, | ||
| iouReport, | ||
| iouReportAction: iouAction, | ||
| transaction, | ||
| personalDetails, | ||
| }); | ||
| threadReportID = optimisticThread?.reportID; | ||
| } | ||
| if (!threadReportID) { | ||
| Log.warn('[ExpenseAddedGrowl] Unable to resolve transaction thread reportID on View press.'); | ||
| return; | ||
| } | ||
| navigateToCreatedExpense({threadReportID, transactionID: active.transactionID, iouReportID, reportTransactions}); | ||
| }; | ||
|
|
||
| return ( | ||
| <GrowlNotificationContent | ||
| key={active.nonce} | ||
| nonce={active.nonce} | ||
| type={CONST.GROWL.SUCCESS} | ||
| duration={CONST.GROWL.DURATION_WITH_ACTION} | ||
| bodyText={translate(isInvoice ? 'iou.invoiceSent' : 'iou.expenseAdded')} | ||
| action={{label: translate('common.view'), onPress: navigateToExpense}} | ||
| onDismissed={(dismissedNonce) => setActive((prev) => (prev?.nonce === dismissedNonce ? null : prev))} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| export default ExpenseAddedGrowl; |
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
8 changes: 8 additions & 0 deletions
8
src/components/GrowlNotification/GrowlNotificationPortal/index.native.tsx
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,8 @@ | ||
| import type React from 'react'; | ||
|
|
||
| /** Native has no shared DOM stacking context to escape, so growls render where they are mounted. */ | ||
| function GrowlNotificationPortal({children}: React.PropsWithChildren) { | ||
| return children; | ||
| } | ||
|
|
||
| export default GrowlNotificationPortal; |
13 changes: 13 additions & 0 deletions
13
src/components/GrowlNotification/GrowlNotificationPortal/index.tsx
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,13 @@ | ||
| import type React from 'react'; | ||
|
|
||
| import {createPortal} from 'react-dom'; | ||
|
|
||
| /** | ||
| * Renders growls at the document root. Inside the app's own stacking context they are covered by overlays that | ||
| * portal to the body themselves, such as the Side Panel, no matter how high their z-index is. | ||
| */ | ||
| function GrowlNotificationPortal({children}: React.PropsWithChildren) { | ||
| return createPortal(children, document.body); | ||
| } | ||
|
|
||
| export default GrowlNotificationPortal; |
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.
Uh oh!
There was an error while loading. Please reload this page.