Skip to content

[6.x] Select all entries, including all pages - #15283

Open
jaygeorge wants to merge 24 commits into
6.xfrom
select-all-including-all-pages
Open

[6.x] Select all entries, including all pages#15283
jaygeorge wants to merge 24 commits into
6.xfrom
select-all-including-all-pages

Conversation

@jaygeorge

@jaygeorge jaygeorge commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Description of the Problem

If you have many entries in an index, pagination occurs and it's not possible to select every single entry.
It's only possible to select the entries on the current page.

NB This PR was created in part, so that it would be possible to bulk-localize all entries in a collection, to go with the PR #15280

Before this PR

Even though there are 3 pages here, you can only effectively select page 1

2026-08-26 at 19 29 24@2x

What this PR Does

2026-08-26 at 19 28 07@2x

This PR adds select all matching results across pages on shared CP listings (entries, users, terms, assets, etc.).

Before: The header checkbox only selected the current page. You could accumulate IDs by paging, but there was no “select everything that matches this listing.”

After:

Select all rows on the current page.
If more results exist, the floating toolbar offers Select :total items across all pages (shortcut A).
That fetches every matching ID (paging through the listing API so it isn’t capped by cpPerPage) and selects them for bulk actions.

How to Reproduce

  1. Go somewhere with an index pagination, such as /cp/collections/blog
  2. Make sure you have more than 50 entries so pagination kicks in
  3. Observe that you can only select the first page with the top "select all" checkbox

Page through listing results instead of one oversized request, track
all-matching mode explicitly, and cover cancel detection plus paging in tests.
Pass modelValue 'indeterminate' instead of a ignored indeterminate prop so the indicator mounts and shows the dash when listings are partially selected.
Hide the expand control when selections already cover the total, only reset all-matching mode on search/filter/sort changes, and use meta.total in header screen-reader copy.
@jackmcdade

Copy link
Copy Markdown
Member

Hell yea!

@jasonvarga jasonvarga left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice work — the util/listing-selections.js extraction is well factored and genuinely well covered, and moving the header checkbox from global-selection semantics to page-scoped semantics is the right model. The Checkbox/Item.vue indeterminate fix is a real catch too.

A few things need sorting before this can land:

  1. The select-all state survives listing-scope changes. matchingQueryKey doesn't account for url or additionalParameters, so navigating between asset folders (or switching user groups) leaves selectedAllMatching set against a different result set. Details inline.
  2. IDs aren't deduped when paging through results.
  3. There's no ceiling on how many items can be selected, and the resulting bulk action runs synchronously in a single request. This is the one I'd most like your thoughts on — see the inline comment.

The rest are smaller notes, including a translation regression worth avoiding.

Comment thread resources/js/components/ui/Listing/Listing.vue
Comment thread resources/js/util/listing-selections.js Outdated

if (selectAllMatchingSource !== controller) return;

selections.value.splice(0, selections.value.length, ...ids);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There's no upper bound on how many IDs can end up here, and the downstream cost scales badly. On a 20k-entry collection:

  1. perPage is clamped to max(pagination_size_options) = 100 by Statamic::cpPerPage(), so this is ~200 sequential XHRs, each fully augmenting every visible column server-side
  2. components/actions/BulkActions.vue:44 watches selections deeply and POSTs the whole array to /actions/list on change — 20k IDs
  3. ActionController::run() then loads all 20k items, authorizes each one, and runs the action synchronously in a single request

That won't complete on most hosts, and the user just gets a spinner with no sense of the scale involved. Also worth noting that splice(0, len, ...ids) spreads the whole array as call arguments, which throws RangeError somewhere north of ~100k.

I think this needs either a configurable cap on the feature, or for bulk actions to grow a server-side "all matching" mode where the query is sent rather than the ID list. Happy to discuss which direction you'd prefer — the second is more work but is the version that actually holds up on a large site.

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.

I've added a configurable cap for this PR. AI suggested 1000 as a sensible number—but I understand this is not a satisfactory way to resolve things on a larger site. We could see what the response is? And plan a different PR to tackle that if needed.

Comment thread resources/js/components/ui/Listing/Listing.vue
Comment thread resources/js/components/ui/Listing/Listing.vue Outdated
Comment thread resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue Outdated
Comment thread resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue
Comment thread resources/js/components/ui/Listing/BulkActionsFloatingToolbar.vue Outdated
Comment thread resources/js/components/ui/Listing/Listing.vue Outdated
Comment thread resources/js/components/ui/Checkbox/Item.vue
…'s useful there and it causes complications

Asset folders are counted in meta.total but aren't selectable, so the
across-pages control under-selected. Opt out via allowSelectAllMatching.
matchingQueryKey ignored url and additionalParameters, so folder/group
switches could leave selectedAllMatching set against a new result set.
Overlapping pages (e.g. if the result set shifts mid-fetch) could push
the same id more than once into selections.
Cap "Select all matching" at config('statamic.cp.select_all_limit')
(default 1000) so sync bulk actions aren't invited on huge result sets.
Null disables the ceiling.
Comparing against meta.total cleared the flag (and resurrected the CTA)
whenever search indexes or mid-fetch deletes returned fewer IDs.
Track the fetched count instead and only exit all-matching when the
user removes a selection.
Use the pagination size ceiling instead of the listing's current
per-page, and ask only for the id column so each page skips full
augmentation.
Reserving it only while the select-all row was visible made action
shortcut letters shift mid-interaction.
These values are always booleans by the time they reach the toolbar, so
the Object union and toValue() unwrapping were unnecessary.
The optional call was only added for the test mock, which already
defines isCancel.
@jaygeorge

Copy link
Copy Markdown
Contributor Author

By the way, I've removed this from /assets because the presence of folders cause some complications, and I'm not sure it's a valuable thing to have with assets anyway

@jaygeorge
jaygeorge requested a review from jasonvarga August 27, 2026 11:06
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.

3 participants