Skip to content

Support 'Select all matching' when moving expenses to a report - #98411

Draft
MelvinBot wants to merge 3 commits into
mainfrom
claude-selectAllMatchingMoveExpenses
Draft

Support 'Select all matching' when moving expenses to a report#98411
MelvinBot wants to merge 3 commits into
mainfrom
claude-selectAllMatchingMoveExpenses

Conversation

@MelvinBot

Copy link
Copy Markdown
Contributor

Explanation of Change

When an admin picks Select all matching expenses and then Add to report, the client was only able to move the first page of loaded results (50 items), even though the UI claimed all matching expenses were selected.

This change lets the "all matching" move be resolved by the backend from the search query itself instead of an explicit transaction list:

  • Adds jsonQuery + hash to ChangeTransactionsReportParams and a dedicated path in changeTransactionsReport that sends the serialized query with an empty transactionList, so the backend moves the whole matching set. Without a hash it falls back to the explicit list (and logs a warning), so only intentional all-matching moves take the query path.
  • useSearchBulkActions now surfaces the Move expenses option for all-matching selections only when it is safe: no excluded rows, and either the client has loaded every match or the query is unreported-only (those expenses live in the owner's self DM and are always movable). Offline all-matching moves prompt the reconnect modal, matching Export.
  • SearchTransactionsChangeReport and IOURequestStepUpgrade forward the query + hash only when all-matching is selected with no exclusions.
  • SearchQueryProvider now joins q and rawQuery behind a null-byte separator so the restored query and its hash can't drift apart (restoring only q would drop rawFilterList while keeping the same hash). This also removes two no-unsafe-type-assertion seatbelt entries.

Includes unit tests in tests/unit/TransactionTest.ts covering the query+hash path, the unreported fallback reportID, optimistic pending flag on the destination report, and the explicit-list fallback when a hash is passed without a query.

🤖 Note from MelvinBot: this change was applied verbatim from the diff ZhenjaHorbach provided. The local AI test suite (typecheck, lint, jest, etc.) could not be executed in this run's sandboxed environment, so those still need to be run before this PR is marked ready for review. I did statically verify the diff applies cleanly and that every referenced symbol/import resolves.

Fixed Issues

$ #92370
PROPOSAL:

Tests

  • Verify that no errors appear in the JS console

Offline tests

QA Steps

// TODO: These must be filled out, or the issue title must include "[No QA]."

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

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

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

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.

Files with missing lines Coverage Δ
src/CONST/index.ts 94.85% <ø> (ø)
src/libs/actions/Transaction.ts 80.66% <92.85%> (+0.22%) ⬆️
src/components/Search/SearchQueryProvider.tsx 95.00% <89.47%> (-5.00%) ⬇️
.../iou/request/step/DynamicIOURequestStepUpgrade.tsx 0.00% <0.00%> (ø)
src/hooks/useSearchBulkActions.ts 70.83% <74.19%> (-0.07%) ⬇️
...rc/pages/Search/SearchTransactionsChangeReport.tsx 0.00% <0.00%> (ø)
... and 7 files with indirect coverage changes

@MelvinBot

Copy link
Copy Markdown
Contributor Author

I analyzed the checks on this PR — there's no code failure for me to fix. Every code/test check is green (ESLint, typecheck, jest/test (job 1–8), Bun tests, typecheck, builds, storybook, react-compiler, etc.).

The only two failing checks require human action, not a code change:

  • checklist (PR Author Checklist) — fails with "You completed 0 out of 38 checklist items." This is the author checklist in the PR body; it needs the boxes checked off by a person (I don't fill these out on an author's behalf). It also currently has empty Tests / QA Steps sections that need filling out.
  • Check independent approval — fails because the PR hasn't yet received the required independent reviewer approval. This clears once a reviewer approves.

baseline-perf-tests is still running as an informational performance benchmark — it doesn't gate merge and isn't a code issue.

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.

@ZhenjaHorbach

Copy link
Copy Markdown
Contributor

@MelvinBot
Fix conflicts

…ingMoveExpenses

Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>

# Conflicts:
#	config/eslint/eslint.seatbelt.tsv
#	src/hooks/useSearchBulkActions.ts
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Merged main into this branch and resolved the two conflicts. The PR is now mergeable.

Conflicts resolved:

  • config/eslint/eslint.seatbelt.tsvmain had removed the SearchSaveButton.tsx and useSearchPageInput.tsx entries (those violations were fixed on main), while this branch removed the SearchQueryProvider.tsx entry (fixed by this PR). Both removals were honored, so the whole conflict block was dropped.
  • src/hooks/useSearchBulkActions.ts — two spots: main added the refreshSearchAfterReportAction import (kept), and both sides added deps to a useCallback array, so I kept both sets (hasUnloadedMatchingExpenses, isUnreportedOnlyQuery from this branch + currentSearchQueryJSON, currentSearchResults?.search?.isLoading, shouldCalculateTotalsOnRefresh from main).

The merge also correctly picked up main's rename of IOURequestStepUpgrade.tsxDynamicIOURequestStepUpgrade.tsx, applying this PR's changes to the renamed file.

Validation run locally:

  • npm run lint on the resolved files → 0 errors (10 pre-existing seatbelt warnings), confirming the seatbelt resolution matches actual violation counts.
  • npm run typecheck-tsgo → passed.
  • tests/unit/TransactionTest.ts → 70/70 passed.

@ZhenjaHorbach

Copy link
Copy Markdown
Contributor

@MelvinBot
Review this PR

@ZhenjaHorbach

Copy link
Copy Markdown
Contributor

@codex
Review this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

See SearchTransactionsChangeReport.tsx:80.

// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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).
  2. Drop the bypass — require !hasUnloadedMatchingExpenses. The move option then only appears when the client has loaded every match, so canAllTransactionsBeMoved fully 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.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Code Review — Support "Select all matching" when moving expenses

Reviewed the diff at 2e2ff61 (9 files, +350/−48). Overall this is a well-structured, well-commented change and the design (let the backend resolve the full match set from the query + hash, guard the client so the option only appears when the move is provably safe) is sound. A few things worth a closer look before merge.

What it does

  • Adds jsonQuery + hash to ChangeTransactionsReportParams and a dedicated early-return path in changeTransactionsReport that sends the serialized query with an empty transactionList so the backend moves the whole matching set. Missing hash → falls back to the explicit list and logs a warning.
  • useSearchBulkActions surfaces Move expenses for all-matching selections only when safe (no exclusions, and either all matches are loaded or the query is unreported-only). Offline all-matching moves prompt the reconnect modal.
  • SearchTransactionsChangeReport and DynamicIOURequestStepUpgrade forward query+hash only when all-matching with no exclusions.
  • SearchQueryProvider joins q/rawQuery behind a null-byte separator through a single usePreviousDefined so they can't drift.

Concerns worth discussing

  • Silent failure on backend rejection (medium). In the all-matching path, failureData only clears pendingFields.reportID — no user-facing error is surfaced. The client guard tries to prevent a reject, but for unreported-only queries with unloaded matches the hasUnloadedMatchingExpenses guard is intentionally bypassed on the assumption those expenses are always movable (useSearchBulkActions.ts:2126-2130). If that assumption is ever violated, the whole move is rejected and the user sees nothing happen. Consider surfacing an error (or a "some expenses couldn't be moved" message) in failureData.

  • No optimistic movement of transactions. The all-matching path only flags the destination report as pending; rows don't move until the backend responds and search refreshes. This is likely unavoidable (the client doesn't know the full set), but it's a UX difference from the explicit path worth confirming is acceptable.

  • Loaded-page heuristic for canMoveExpenses. canAllTransactionsBeMoved / owner checks only see the loaded page (useSearchBulkActions.ts:2085-2103). Correct by design when all matches are loaded; for the unreported-only-with-unloaded case it relies on the same always-movable assumption above. Fine as long as the backend is the real enforcer — just flagging that the client eligibility is a best-effort proxy.

Minor / nits

  • getAllMatchingQueryParams() in SearchTransactionsChangeReport is a non-memoized closure invoked inside three handlers. Cheap and correct, but if any handler is a useCallback, double-check the closure captures current values (it does here since it's recreated each render).
  • The all-matching API.write doesn't carry the no-multiple-api-calls disable that the fallback path now has — correct, since it's a single guarded call, just noting the asymmetry is intentional.
  • The \x00 separator + rawQuery || undefined normalization in SearchQueryProvider is a clean way to keep the pair atomic; the "null byte can't appear in a query" assumption is the load-bearing part — worth a quick confirmation that no query serialization can ever emit one.

Tests

Good coverage in TransactionTest.ts for the Transaction.ts paths: query+hash sent with empty list, unreported fallback reportID, optimistic pending flag set→cleared, and the hash-without-query fallback. The client-side eligibility logic in useSearchBulkActions (the isAllMatchingSelectionMovable matrix: exclusions, unloaded matches, unreported-only) is the trickiest part and isn't unit-tested — a few table-driven cases there would harden it against regressions.

Validation

I ran locally against this branch: lint (0 errors), typecheck-tsgo (pass), and TransactionTest.ts (70/70 pass). No conflict markers or merge fallout remain.

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.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

No code fix is needed — all automated checks pass. The two remaining ❌ checks aren't fixable by a code change:

  • Check independent approval — waiting on a human approval.
  • PR Reviewer Checklist / checklist — fails with "No PR Reviewer Checklist was found"; it needs a reviewer to complete the checklist on the PR.

When this request came in, typecheck and ESLint check were still running (the PR note flagged that the AI test suite hadn't been run locally). Both have since finished green, along with every other substantive check:

✓ 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.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

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:

  • Check independent approval — needs a reviewer's approval.
  • PR Reviewer Checklist / checklist — "No PR Reviewer Checklist was found"; needs a reviewer to complete the checklist.

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.

@ZhenjaHorbach

Copy link
Copy Markdown
Contributor

@MelvinBot
Fix comments
#98411 (comment)
#98411 (comment)
#98411 (comment)

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>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Worked through the three comments. Summary:

P1 — recheck connectivity before the query move (r3812170462): fixed in c499c08. SearchTransactionsChangeReport now re-checks isOffline at submission and blocks an all-matching query move (showing the reconnect modal, same as Export) instead of queuing a retryable write that would replay a stale query on reconnect. Replied in-thread with details.

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 canUnreportedBeMoved() (per-diem with missing rates is the counter-example). Fixing it means either accepting the tradeoff (+ surfacing backend rejection) or dropping the || isUnreportedOnlyQuery bypass so the option only shows when all matches are loaded — which reverses your deliberate design and drops coverage for multi-page unreported searches. I laid out both options in-thread and will implement whichever you pick.

My code review (comment): the two open items are judgment calls, left for you:

  • Silent failure on backend rejection — the all-matching failure path only clears pendingFields.reportID, but this matches the existing explicit-list path (Transaction.ts:1979-1986), so it's a pre-existing behavior rather than something this PR introduces. Surfacing a user-facing move error would be a worthwhile but separate, cross-cutting change. Tied to P2 option 1 above.
  • Missing unit tests for the isAllMatchingSelectionMovable matrix — valuable, but that logic lives inside the big headerButtonsOptions useMemo in useSearchBulkActions, so it needs hook-level rendering with mocked Onyx/contexts rather than a pure-function test. Happy to add those if you want them in this PR.

Validation for the P1 change: eslint (0 errors), typecheck-tsgo (pass), and react-compiler-compliance-check (pass). No existing test covers this component.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

The latest commit (c499c08) re-ran CI and all automated checks pass again — nothing to fix in code.

The only remaining ❌ checks are human-gated and can't be resolved by a push:

  • Check independent approval — needs a reviewer's approval.
  • PR Reviewer Checklist / checklist — "No PR Reviewer Checklist was found"; needs a reviewer to complete the checklist.

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.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

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:

  • Check independent approval — needs a reviewer's approval.
  • PR Reviewer Checklist / checklist — "No PR Reviewer Checklist was found"; a reviewer must complete the checklist on the PR.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants