Repository navigation
Support 'Select all matching' when moving expenses to a report - #98411
Conversation
Send the search jsonQuery + hash to CHANGE_TRANSACTIONS_REPORT so the backend moves all matching unreported expenses, not just the loaded page. Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
|
I analyzed the checks on this PR — there's no code failure for me to fix. Every code/test check is green (ESLint, The only two failing checks require human action, not a code change:
So: no fix to push. To get this green, complete the author checklist (and fill in the Tests/QA sections), then get an independent approval. |
|
@MelvinBot |
…ingMoveExpenses Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com> # Conflicts: # config/eslint/eslint.seatbelt.tsv # src/hooks/useSearchBulkActions.ts
|
🤖 Merged Conflicts resolved:
The merge also correctly picked up Validation run locally:
|
|
@MelvinBot |
|
@codex |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e2ff610e9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| Log.warn('[SearchTransactionsChangeReport] All matching expenses are selected but the search query is unavailable; only the loaded expenses will be moved.'); | ||
| return {}; | ||
| } | ||
| return {jsonQuery: serializeQueryJSONForBackend(currentSearchQueryJSON), hash: currentSearchQueryJSON.hash}; |
There was a problem hiding this comment.
Recheck connectivity before submitting the query move
If the connection drops after the user opens this RHP but before they choose a destination, this still supplies the all-matching query to API.write; the earlier offline check in useSearchBulkActions only runs while navigating here. The retryable write is then queued, and because hash identifies the query rather than a frozen result set, reconnecting can move expenses that started matching while offline. Check the current network state at submission and block the query-based move instead of queuing it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c499c08.
The move RHP (SearchTransactionsChangeReport) now re-checks isOffline at submission time — in selectReport, removeFromReport, and createReport — and, when the selection is an all-matching query move, blocks it and shows the reconnect modal (same UX as Export) instead of queuing the retryable write. The guard only trips for query-based moves (getAllMatchingQueryParams() returning a jsonQuery); explicit-list moves still queue normally offline since they carry a frozen transaction list.
| // The backend moves the whole query, so the query must guarantee every match is movable: a non-movable | ||
| // expense hiding in unloaded results makes it reject the entire move. Unreported-only queries are safe | ||
| // because those expenses live in their owner's self DM. Exclusions can't be expressed in a query. | ||
| const isAllMatchingSelectionMovable = isEmptyObject(excludedTransactions) && (!hasUnloadedMatchingExpenses || isUnreportedOnlyQuery); |
There was a problem hiding this comment.
Do not assume every unloaded unreported expense is movable
For a status:unreported search spanning multiple pages, this bypasses hasUnloadedMatchingExpenses after checking canChangeReport only on the loaded rows. Unreported expenses are not universally movable: canUnreportedBeMoved() rejects per-diem transactions when their policy or rates are unavailable, so an invalid expense on a later page makes the backend reject the entire query move even though this option is offered. Do not treat the status filter alone as proof that all unseen matches are eligible.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — this is a real edge case, but I didn't change the behavior here because the honest fix is a product tradeoff that belongs to the PR author rather than a mechanical change, and I don't want to silently reverse the deliberate || isUnreportedOnlyQuery bypass.
The core constraint: the client can't enumerate the unloaded matches, so it can't prove every unseen unreported expense passes canUnreportedBeMoved() (per-diem with unavailable policy/rates being the counter-example you flagged). The backend is the authoritative enforcer either way. Two options:
- Keep the bypass (current), accept the tradeoff. Rare per-diem-on-a-later-page cases make the backend reject the whole move. This should be paired with surfacing that failure to the user (today the all-matching failure path only clears
pendingFields.reportID— same silent behavior as the existing explicit-list path, so it isn't a regression, but it's worth fixing). - Drop the bypass — require
!hasUnloadedMatchingExpenses. The move option then only appears when the client has loaded every match, socanAllTransactionsBeMovedfully covers the set. Cost: all-matching move isn't offered for unreported searches with more than one page of results.
I'd lean toward (2) if guaranteed correctness matters more than covering the >1-page unreported case. ZhenjaHorbach — which tradeoff do you want? Reply and I'll implement it.
Code Review — Support "Select all matching" when moving expensesReviewed the diff at What it does
Concerns worth discussing
Minor / nits
TestsGood coverage in ValidationI ran locally against this branch: Nothing here is blocking — the silent-failure path and the unreported-only always-movable assumption are the two I'd want an answer on before shipping. |
|
No code fix is needed — all automated checks pass. The two remaining ❌ checks aren't fixable by a code change:
When this request came in, ✓ ESLint check · ✓ typecheck · ✓ test (jobs 1–8, Jest) · ✓ Bun tests · ✓ Storybook tests · ✓ spellcheck · ✓ Check for unused styles · ✓ Compare knip issues against main · ✓ React Compiler Compliance · ✓ Oxfmt Nothing to push. To clear the last two, this PR just needs a reviewer to complete the checklist and grant approval. |
|
Nothing has changed since my previous analysis — there's still no code fix to push. The only two remaining ❌ checks are human-gated, not automated:
Every substantive automated check is green (ESLint, typecheck, Jest jobs 1–8, Bun, Storybook, spellcheck, unused styles, knip, React Compiler, Oxfmt). To clear the last two, this PR just needs a reviewer to complete the checklist and approve. |
|
@MelvinBot |
If the connection drops after the move RHP is open, block the query-based move and prompt the user to reconnect instead of queuing a retryable write. A queued all-matching move replays a stale query on reconnect (the hash resolves the match set at backend execution time), so expenses that started matching while offline could be swept in. Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
|
🤖 Worked through the three comments. Summary: P1 — recheck connectivity before the query move (r3812170462): fixed in c499c08. P2 — "unreported ⇒ always movable" assumption (r3812170470): needs your call, not changed. The client can't enumerate unloaded matches, so it can't prove every unseen unreported expense passes My code review (comment): the two open items are judgment calls, left for you:
Validation for the P1 change: |
|
The latest commit ( The only remaining ❌ checks are human-gated and can't be resolved by a push:
Green on this commit: ✓ ESLint · ✓ typecheck · ✓ test (jobs 1–8, Jest) · ✓ Bun tests · ✓ Storybook · ✓ knip. (The perf-test jobs are still running but are non-blocking benchmarks.) To clear the last two, this PR just needs a reviewer to complete the checklist and approve. |
|
Still no code fix needed — all automated checks are green. The only two ❌ checks are human-gated and can't be resolved by a commit:
Everything substantive passes: ✓ ESLint · ✓ typecheck · ✓ test (jobs 1–8, Jest) · ✓ Bun tests · ✓ Storybook · ✓ spellcheck · ✓ unused styles · ✓ knip · ✓ React Compiler. To clear the last two, this PR just needs a reviewer to fill in the checklist and approve — there's nothing for me to push. |
|
@MelvinBot |
…ingMoveExpenses Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com> # Conflicts: # tests/unit/TransactionTest.ts
|
@ZhenjaHorbach Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
|
🤖 Conflicts fixed and the PR is now Ready for review (it was a draft). What conflicted: only Resolution details
Testing: I could not run the local AI suite ( |
|
@MelvinBot merge main |
|
🤖 Merged Four files conflicted. Two were trivial; two needed real decisions, so please sanity-check these:
The other two were additive: Typecheck then failed on five of this PR's new tests that predate main's now-required One thing worth a look, not a merge artifactMain's new |
suneox
left a comment
There was a problem hiding this comment.
With the current filter from the OP status:unreported, select-all -> Move to report works as expected without loading the full list. For status:drafts, the move option only appears after loading all results — unlike bulk Pay, which no longer gates on the loaded page (#94075), Move still needs the whole set loaded because one non-movable expense in the match set rejects the entire request. I think we can go ahead with the current code changes; the drafts case can be improved once the BE pre-check discussed here.
CleanShot.2026-09-21.at.14.05.39.1.mp4
mjasikowski
left a comment
There was a problem hiding this comment.
Please simplify long comments
| // Across submitters the only destination the App can offer is "Auto report". Every other mixed-owner selection | ||
| // stays hidden as before, so there is no entry into a screen that could only offer one submitter's reports to | ||
| // everybody else's expenses. Requirements: | ||
| // - every owner resolved, or the count below cannot tell one cardholder's bulk selection from a mixed one | ||
| // - every expense on a managed card, because the backend resolves each destination through the card; one | ||
| // expense without a card fails the whole request with "404 Card not found" | ||
| // - nothing whose validity depends on the destination workspace, which the backend picks: per diem rates and | ||
| // the map/GPS rules on manual and odometer distance can only be checked against a known workspace | ||
| // An expense we cannot read fails all three, so it withholds the flow rather than risking a rejected move. |
There was a problem hiding this comment.
| // Across submitters the only destination the App can offer is "Auto report". Every other mixed-owner selection | |
| // stays hidden as before, so there is no entry into a screen that could only offer one submitter's reports to | |
| // everybody else's expenses. Requirements: | |
| // - every owner resolved, or the count below cannot tell one cardholder's bulk selection from a mixed one | |
| // - every expense on a managed card, because the backend resolves each destination through the card; one | |
| // expense without a card fails the whole request with "404 Card not found" | |
| // - nothing whose validity depends on the destination workspace, which the backend picks: per diem rates and | |
| // the map/GPS rules on manual and odometer distance can only be checked against a known workspace | |
| // An expense we cannot read fails all three, so it withholds the flow rather than risking a rejected move. | |
| // For selections across submitters, offer only Auto report when every expense has a resolved owner | |
| // is on a managed card, and does not depend on the destination workspace. Otherwise hide the flow. |
There was a problem hiding this comment.
Applied in 67bee6d. I kept your wording and only added the comma after "resolved owner" so the three conditions read as a list.
| // The backend moves everything the query matches, so one expense it can't move rejects the whole move. | ||
| // Only offer this when the query guarantees every match is movable. An unreported query is safe because | ||
| // those expenses live in their owner's self DM. A query also can't express excluded rows. |
There was a problem hiding this comment.
| // The backend moves everything the query matches, so one expense it can't move rejects the whole move. | |
| // Only offer this when the query guarantees every match is movable. An unreported query is safe because | |
| // those expenses live in their owner's self DM. A query also can't express excluded rows. | |
| // Offer an all-matching move only when no rows are excluded and unloaded matches are unreported. | |
| // The backend rejects the entire move if any matching expense is invalid |
| // The backend resolves the whole matching set from the query, but the client has only loaded part of it. | ||
| // Build the normal optimistic updates for the loaded transactions so their rows leave the list right away, | ||
| // just like a per-page selection does. The rest of the set arrives with the response. | ||
| // This is undefined when none of the loaded transactions move, but the request still has to go out for the | ||
| // expenses the client never loaded. |
There was a problem hiding this comment.
| // The backend resolves the whole matching set from the query, but the client has only loaded part of it. | |
| // Build the normal optimistic updates for the loaded transactions so their rows leave the list right away, | |
| // just like a per-page selection does. The rest of the set arrives with the response. | |
| // This is undefined when none of the loaded transactions move, but the request still has to go out for the | |
| // expenses the client never loaded. | |
| // The backend moves the full query result while the client only has loaded transactions. | |
| // Apply normal optimistic updates to loaded transactions, then send the request even if none are loaded. |
| /** | ||
| * A queued all-matching move would replay a stale query on reconnect. The backend resolves the match set when it | ||
| * runs the query, not when the user submitted it, so expenses that started matching while offline get swept in. | ||
| * `useSearchBulkActions` only checks this when the user opens this RHP, so check again in case the connection | ||
| * dropped since then, and ask the user to reconnect like export does. | ||
| */ |
There was a problem hiding this comment.
| /** | |
| * A queued all-matching move would replay a stale query on reconnect. The backend resolves the match set when it | |
| * runs the query, not when the user submitted it, so expenses that started matching while offline get swept in. | |
| * `useSearchBulkActions` only checks this when the user opens this RHP, so check again in case the connection | |
| * dropped since then, and ask the user to reconnect like export does. | |
| */ | |
| /** | |
| * Block offline all-matching moves because the backend reevaluates the query after reconnecting, | |
| * which can include newly matching expenses. Recheck here because the connection may have dropped after the RHP opened | |
| */ |
| // The query has to travel through the upgrade flow too, or only the loaded page moves. The confirmation | ||
| // button stays enabled offline, so check the connection again here. A queued move would replay a stale | ||
| // query on reconnect and sweep in expenses that started matching while offline, so drop the query when | ||
| // offline and move the explicit list instead. |
There was a problem hiding this comment.
| // The query has to travel through the upgrade flow too, or only the loaded page moves. The confirmation | |
| // button stays enabled offline, so check the connection again here. A queued move would replay a stale | |
| // query on reconnect and sweep in expenses that started matching while offline, so drop the query when | |
| // offline and move the explicit list instead. | |
| // Do not send all-matching query params offline because reconnecting reevaluates the query and can include newly matching expenses. | |
| // Move the explicit transaction list instead |
There was a problem hiding this comment.
Applied verbatim in 67bee6d. I used the same two lines for the identical drop-the-query-offline spot in DynamicNewReportWorkspaceSelectionPage.tsx.
|
@MelvinBot simply all code comments following suggestion above |
Co-authored-by: Cong Pham <suneox@users.noreply.github.com>
|
Simplified the comments in Your five suggestions
Same treatment elsewhere
Checks: I couldn't verify in a browser on this run — the web environment failed to come up. That's fine here: the diff touches only comments, so there's no runtime behavior to re-test. |
|
@suneox let's fix conflicts and we're good to merge |
I'll re-verify once the conflict is resolved and update you @MelvinBot merge main and resolve conflict |
|
Tests run against the merge result
I didn't run the manual test steps — they need an account with more than 50 unreported expenses in the self-DM, which I can't set up here. Over to you for the re-verify. |
@mjasikowski The functionality still works as expected after merging main, so it's ready to merge. Move all unreported expensesCleanShot.2026-09-25.at.14.05.51.1.mp4 |
|
🚧 mjasikowski has triggered a test Expensify/App build. You can view the workflow run here. |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/mjasikowski in version: 9.4.94-0 🚀
|
|
Yes, help site changes are needed. I opened a draft PR: Docs updates for E/A#98411 Explain moving all matching expenses to a report. This PR adds Move to report to the Spend page toolbar when a member chooses Select all. No help article described that flow, or when the option is hidden. The docs PR updates Getting-Started-with-the-Spend-Page.md with:
I checked the labels against the App strings and dev NewDot web. The draft PR has the @suneox, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR view run · no recording available |
|
Deploy Blocker #102309 was identified to be related to this PR. |
|
Deploy Blocker #102310 was identified to be related to this PR. |
|
🚀 Deployed to production by https://github.com/Julesssss in version: 9.4.94-0 🚀
Bundle Size Analysis (Sentry): |
Explanation of Change
When an admin picks Select all matching expenses and then Add to report, the client could only move the first page of loaded results (50 items), even though the UI claimed every matching expense was selected.
This change lets the "all matching" move be resolved by the backend from the search query itself instead of an explicit transaction list.
Sending the query instead of a list
jsonQueryandhashare added toChangeTransactionsReportParams, andchangeTransactionsReportgets a dedicated path that sends the serialized query with an emptytransactionList, so the backend moves the whole matching set. Without ahashit falls back to the explicit list and logs a warning, so only intentional all-matching moves take the query path.SearchTransactionsChangeReport,DynamicIOURequestStepUpgrade, andDynamicNewReportWorkspaceSelectionPagebuild those params through the newgetAllMatchingQueryParamshelper, which returns nothing unless all matching items are selected with no exclusions, since a query can't express unchecked rows.Optimistic updates
The query path reuses
getChangeTransactionsReportOnyxDatafor the transactions the client has loaded, so their rows leave the list immediately, exactly as a per-page move does. Three details follow from that:MOVED_TRANSACTIONaction and thread IDs are forwarded intransactionIDToReportActionAndThreadData. Sending an empty map would make the backend mint its own for those same expenses, giving each loaded row two moved messages.pendingFields.reportIDfor the whole request, because expenses the client never loaded are still being moved server-side.Guarding when the option is offered
useSearchBulkActionssurfaces Move expenses for an all-matching selection only when the query guarantees every match is movable: no excluded rows, and either the client has loaded every match or the query is unreported-only, since those expenses live in the owner's self DM and are always movable. One expense the backend can't move rejects the entire move, so this can't be decided from the loaded page alone.Keeping the query and its hash in sync
SearchQueryProvidernow joinsqandrawQuerybehind a null-byte separator so the restored query and its hash can't drift apart. Restoring onlyqwould droprawFilterListwhile keeping the same hash. This also removes twono-unsafe-type-assertionseatbelt entries.Tests
tests/unit/TransactionTest.tscovers the query and hash path, the unreported fallback reportID, the optimistic pending flag on the destination report, the optimistic move of the loaded transactions while the request is still in flight, and the explicit-list fallback when a hash is passed without a query.Fixed Issues
$ #92370
PROPOSAL:
Tests
Precondition:
Verify All unreported expenses are successfully moved to the selected report
Offline tests
QA Steps
Precondition:
Verify All unreported expenses are successfully moved to the selected report
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari