Skip to content

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

Merged
mjasikowski merged 21 commits into
mainfrom
claude-selectAllMatchingMoveExpenses
Sep 25, 2026
Merged

mjasikowski merged 21 commits into
mainfrom
claude-selectAllMatchingMoveExpenses

Conversation

@MelvinBot

@MelvinBot MelvinBot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

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

  • jsonQuery and hash are added to ChangeTransactionsReportParams, and changeTransactionsReport gets a dedicated path 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.
  • SearchTransactionsChangeReport, DynamicIOURequestStepUpgrade, and DynamicNewReportWorkspaceSelectionPage build those params through the new getAllMatchingQueryParams helper, 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 getChangeTransactionsReportOnyxData for 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:

  • The optimistically generated MOVED_TRANSACTION action and thread IDs are forwarded in transactionIDToReportActionAndThreadData. Sending an empty map would make the backend mint its own for those same expenses, giving each loaded row two moved messages.
  • The destination report keeps pendingFields.reportID for the whole request, because expenses the client never loaded are still being moved server-side.
  • When none of the loaded transactions actually move, for example because the loaded page already sits in the destination, the builder returns nothing. Unlike the explicit path, this branch can't bail there, so it falls back to destination-pending-only optimistic data and still sends the request for the unloaded matches.

Guarding when the option is offered

  • useSearchBulkActions surfaces 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.
  • Offline all-matching moves prompt the reconnect modal, matching Export. The backend resolves the match set when it runs the query, so a queued move would replay a stale query on reconnect and sweep in expenses that started matching while offline. Where a button stays enabled offline, the query params are dropped and the frozen explicit list is moved instead.

Keeping the query and its hash in sync

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.

Tests

tests/unit/TransactionTest.ts covers 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.

🤖 Note from MelvinBot: main has been merged in and the local AI test suite was run against the result. typecheck passed, lint-changed passed, spell-changed passed, and tests/unit/TransactionTest.ts passed 73/73. The React Compiler check reports failures in useSearchBulkActions.ts, but they are identical on main (10 Babel and 9 OXC errors, same kinds, only line numbers shifted by the lines this PR inserts), so there is no regression. One thing still needs a human: this assumes CHANGE_TRANSACTIONS_REPORT honors transactionIDToReportActionAndThreadData for the loaded subset when jsonQuery and hash are also present. If the backend ignores that map on the query path, the loaded rows will get duplicate moved messages.

Fixed Issues

$ #92370
PROPOSAL:

Tests

Precondition:

  • Account is a member of a workspace expenses can be moved to.
  • Self-DM has more than 50 unreported expenses without per-diem expense
  1. Navigate to Spend > Expenses.
  2. Filter expenses by Unreported status filter.
  3. Click the checkbox header dropdown and choose Select all.
  4. Waiting for loading count total matching expense
  5. Choose Move to report
  6. Choose the destination report

Verify All unreported expenses are successfully moved to the selected report

  • Verify that no errors appear in the JS console

Offline tests

QA Steps

Precondition:

  • Account is a member of a workspace expenses can be moved to.
  • Self-DM has more than 50 unreported expenses without per-diem expense
  1. Navigate to Spend > Expenses.
  2. Filter expenses by Unreported status filter.
  3. Click the checkbox header dropdown and choose Select all.
  4. Waiting for loading count total matching expense
  5. Choose Move to report
  6. Choose the destination report

Verify All unreported expenses are successfully moved to the selected report

  • 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.06% <ø> (ø)
src/libs/actions/Transaction.ts 80.52% <94.44%> (+0.32%) ⬆️
...c/pages/DynamicNewReportWorkspaceSelectionPage.tsx 85.29% <66.66%> (-0.71%) ⬇️
src/components/Search/SearchQueryProvider.tsx 92.10% <81.25%> (-7.90%) ⬇️
.../iou/request/step/DynamicIOURequestStepUpgrade.tsx 0.00% <0.00%> (ø)
src/libs/getAllMatchingQueryParams.ts 0.00% <0.00%> (ø)
src/hooks/useSearchBulkActions.ts 78.39% <67.44%> (-0.13%) ⬇️
...rc/pages/Search/SearchTransactionsChangeReport.tsx 0.00% <0.00%> (ø)
... and 8 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.tsv — main 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.tsx → DynamicIOURequestStepUpgrade.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.

@ZhenjaHorbach

Copy link
Copy Markdown
Contributor

@MelvinBot
Can you fix conflicts?
And then open this PR

…ingMoveExpenses

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

# Conflicts:
#	tests/unit/TransactionTest.ts
@MelvinBot
MelvinBot marked this pull request as ready for review August 24, 2026 08:58
@MelvinBot
MelvinBot requested a review from a team as a code owner August 24, 2026 08:58
@melvin-bot
melvin-bot Bot requested a review from ZhenjaHorbach August 24, 2026 08:58
@melvin-bot

melvin-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

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

@melvin-bot
melvin-bot Bot requested review from joekaufmanexpensify and removed request for a team August 24, 2026 08:58
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Conflicts fixed and the PR is now Ready for review (it was a draft).

What conflicted: only tests/unit/TransactionTest.ts. Both sides appended new tests to the end of the same changeTransactionsReport describe block — this branch added the describe('when all matching items are selected (jsonQuery + hash)') suite, while main added the MOVED_TRANSACTION Draft / non-Draft tests. It was a pure additive conflict, so I kept both sets of tests.

Resolution details
  • Merged main into the branch with git merge origin/main (no rebase/squash).
  • Kept every test from both sides; the only closing brace I added was the }); needed to close this branch's new describe block before main's two it(...) tests.
  • The shared typeGuards import line auto-merged into a superset (isReportMergeUpdate, isReportStateMergeUpdate from this branch + hasDefinedProperty, parseJSONRecord, readProperty from main).
  • Verified the resolution is a clean union: git diff origin/main shows no removed lines (nothing dropped from main), and the only change vs the pre-merge tip is that superset import line.

Testing: I could not run the local AI suite (typecheck, lint, jest, etc.) in this run's sandboxed environment — code execution is gated here. The conflict was confined to a test file and I statically verified the merge is a clean union with balanced describe/it structure, but the checks still need to pass. CI on the PR will run them; please confirm they're green before merging.


view run

@suneox

suneox commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot merge main

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Merged main. The branch is mergeable again, and typecheck, ESLint, and all Jest jobs pass.

Four files conflicted. Two were trivial; two needed real decisions, so please sanity-check these:

src/hooks/useSearchBulkActions.ts:2350 — main added canAutoReportAcrossSubmitters (mixed-owner moves allowed when every expense is on a managed card) to the same if this PR hoisted into canMoveExpenses. I folded main's condition into the hoisted version, so the all-matching path now inherits it too. I also dropped the break from the owner loop, matching main — with it, moveHasUnknownOwner could stay false after an early exit and wrongly enable the cross-submitter path.

src/hooks/useSearchBulkActions.ts:2398 — main split the memo into {headerButtonsOptions, dropdownButtonsOptions} via buildResult. The all-matching branch now returns buildResult(allMatchingOptions) instead of openExportOptionsDirectlyIfSoleAction(...), so the move option reaches both the bulk action bar and the dropdown.

The other two were additive: isVendorMatchingBetaEnabled alongside jsonQuery/hash in ChangeTransactionsReportProps, and main's three new IOURequestEditReportCommon props inside this PR's fragment wrapper.

Typecheck then failed on five of this PR's new tests that predate main's now-required isVendorMatchingBetaEnabled, so I added it to each. That's the second commit.

One thing worth a look, not a merge artifact

Main's new autoReport calls autoReportTransactions(selectedTransactionsKeys) — loaded keys only, no jsonQuery. Under "select all matching" it would auto-report just the loaded page, not every match. I left it alone since changing it is a design call, not a conflict resolution. It also doesn't need this PR's offline guard, because it never sends a query that could go stale.


view run

@suneox suneox left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 mjasikowski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please simplify long comments

Comment thread src/hooks/useSearchBulkActions.ts Outdated
Comment on lines +2328 to +2336
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
// 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.

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.

Applied in 67bee6d. I kept your wording and only added the comma after "resolved owner" so the three conditions read as a list.

Comment thread src/hooks/useSearchBulkActions.ts Outdated
Comment on lines +2384 to +2386
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
// 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

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.

Applied verbatim in 67bee6d.

Comment thread src/libs/actions/Transaction.ts Outdated
Comment on lines +2041 to +2045
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
// 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.

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.

Applied verbatim in 67bee6d.

Comment on lines +68 to +73
/**
* 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.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
/**
* 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
*/

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.

Applied verbatim in 67bee6d.

Comment on lines +159 to +162
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
// 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

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.

Applied verbatim in 67bee6d. I used the same two lines for the identical drop-the-query-offline spot in DynamicNewReportWorkspaceSelectionPage.tsx.

@suneox

suneox commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot simply all code comments following suggestion above

Co-authored-by: Cong Pham <suneox@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Simplified the comments in 67bee6d1902 — all five of your suggestions applied, plus the same treatment for the other long comments this PR added. 7 files, 42 comment lines down to 19. No code changed.

Your five suggestions

File Note
src/hooks/useSearchBulkActions.ts:2328 Applied, with a comma added after "resolved owner" so the three conditions read as a list
src/hooks/useSearchBulkActions.ts:2377 Applied verbatim
src/libs/actions/Transaction.ts:2041 Applied verbatim
src/pages/Search/SearchTransactionsChangeReport.tsx:68 Applied verbatim
src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx:159 Applied verbatim

Same treatment elsewhere

Checks: typecheck passed, lint-changed passed, spell-changed passed (9 files, 0 issues), tests/unit/TransactionTest.ts passed 74/74.

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.

@mjasikowski

Copy link
Copy Markdown
Contributor

@suneox let's fix conflicts and we're good to merge

@suneox

suneox commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

@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

@MelvinBot

Copy link
Copy Markdown
Contributor Author

main is merged in and the branch is mergeable again — pushed as dc3215e. Two files conflicted, both resolved by keeping main's refactor and re-applying this PR's behavior on top.

src/components/Search/SearchQueryProvider.tsx:29-42 — main replaced getDeepestFocusedScreen + SCREENS.SEARCH.ROOT with getSearchRootParamsFromRootState. selectSearchQueryParams now reads q and rawQuery off that resolved params object and still joins them behind the null-byte separator, so the restored query and its hash can't drift. The manual typeof checks the PR added are gone — isSearchRootParams already validates both fields, so SearchRootParams is typed and no assertion is needed.

src/hooks/useSearchBulkActions.ts:2449-2467 — both sides appended to the all-matching options list. main added downloadPDFOption for expense-report searches; this PR added moveExpensesOption. Both are kept, ordered pay → export → download PDF → move, matching the non-all-matching branch.

Tests run against the merge result
  • npm run typecheck — passed. It initially failed on 'searchRootParams' is possibly 'undefined', fixed by guarding the whole params object instead of just q.
  • npm run lint-changed — passed.
  • npm run spell-changed — 3 issues, all in files this PR doesn't touch (AccountingConnectionConfirmationTest.tsx, useOutstandingBalanceGuard.test.tsx, useDialogContainerFocusTest.tsx); identical on main.
  • tests/unit/TransactionTest.ts — 75/75 passed.
  • tests/unit/hooks/useSearchBulkActions*, tests/unit/Search/SearchQueryUtilsTest.ts, tests/unit/Search/SearchBulkActionsButtonTest.tsx — 546/546 passed across 10 suites.
  • React Compiler check on useSearchBulkActions.ts — still fails (10 Babel, 9 OXC), but I ran the same check against origin/main's copy of the file and got the identical errors, only line numbers shifted (2189→2196 Babel, 1932→1939 OXC). No regression from this PR or the merge.

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.


view run

@suneox

suneox commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

@suneox let's fix conflicts and we're good to merge

@mjasikowski The functionality still works as expected after merging main, so it's ready to merge.

Move all unreported expenses
CleanShot.2026-09-25.at.14.05.51.1.mp4

@mjasikowski
mjasikowski merged commit 33be3a0 into main Sep 25, 2026
42 of 43 checks passed
@mjasikowski
mjasikowski deleted the claude-selectAllMatchingMoveExpenses branch September 25, 2026 07:22
@github-actions

Copy link
Copy Markdown
Contributor

🚧 mjasikowski has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/mjasikowski in version: 9.4.94-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@MelvinBot

MelvinBot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

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:

  • Web and mobile steps to move all matching expenses to a report.
  • When Move to report appears: no expenses unchecked, and every match loaded or the Status filter set to Unreported only. It also covers the offline message.
  • A FAQ entry for when Move to report is missing.

I checked the labels against the App strings and dev NewDot web. The draft PR has the HelpDot label and is assigned to suneox.

@suneox, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR Ready for review


view run · no recording available

@lanitochka17

Copy link
Copy Markdown

Deploy Blocker #102309 was identified to be related to this PR.

@lanitochka17

Copy link
Copy Markdown

Deploy Blocker #102310 was identified to be related to this PR.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/Julesssss in version: 9.4.94-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

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.

7 participants