Skip to content

Add "select all records matching this query" to list bulk actions - #1542

Merged
LukeTowers merged 3 commits into
developfrom
wip/list-select-all-matching
Sep 26, 2026
Merged

LukeTowers merged 3 commits into
developfrom
wip/list-select-all-matching

Conversation

@LukeTowers

@LukeTowers LukeTowers commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

Docs: wintercms/docs#265

List checkboxes only ever reach the current page, so acting on a filtered set of any size means paginating through it: select 30, delete, select 30, delete. This adds the affordance Gmail has had for years — select the page, then take the offer to select all N records matching the current filters.

select-all-matching.mp4

How the selection is resolved

Not by sending ids. The active query is already server-side session state — the search term (Search::getActiveTerm()), the filter scope values (Filter::getScopeValue()), sort and per-page — so makeLists() + prepareQuery() reconstructs exactly what the user is looking at from any AJAX handler. FormController::formGetRecordNavigation() and Lists::onReorder() already rely on this.

So the client posts a flag, not a set, and a handler asks the widget for it:

// Before
$checkedIds = post('checked');
foreach ($checkedIds as $id) { ... }

// After
$this->listGetSelectionQuery()->chunkById(500, function ($records) {
    foreach ($records as $record) { ... }
});
// or, to keep an id array: $checkedIds = $this->listGetSelectedKeys();

Both modes resolve through prepareQuery(), which tightens the existing behaviour too: explicitly checked ids are now validated against the active query instead of the model's base scope, so an id outside the current search or filter is dropped rather than acted on.

A query fingerprint guards against session drift. Two tabs share one backend session, so a filter changed in one silently redefines "all matching" for the other. The banner carries md5(sql + bindings) of the query it describes, the client posts it back, and the handler refuses a selection whose fingerprint no longer matches (with backend::lang.list.selection_stale) instead of acting on the wrong set. Ordering and the visible column set are excluded, since neither changes which records match — re-sorting or hiding a column keeps the selection. It is a comparison value, not a capability: whole-query mode is independently gated server-side on the list actually offering it, so a forged flag or fingerprint cannot enable it on a list that opted out.

Opt-in per list, off by default

selectAllMatching: true alongside showCheckboxes: true. It defaults to false, which is the one judgement call worth a second opinion.

A document-level ajaxSetup bridge injects the mode into any request that already posts checked, so existing bulk buttons need no markup changes — but a handler still reading post('checked') keeps acting on the page only. With the banner on by default, such a list would tell the user "all 5,000 matching records are selected", have them confirm, and act on 25. Every bulk handler in the plugins I have locally (Winter.User, Winter.Blog, Winter.Forum, Winter.Location, LukeTowers.EasyForms) reads post('checked') only, so opt-in makes adoption a deliberate act by whoever owns the handler. Flipping the default later is one word.

Also in this PR

  • EventLogs, RequestLogs and ThemeLogs lose their duplicated index_onDelete() and inherit the behaviour's. They were identical to each other and strictly weaker: they ignored listExtendQuery(), the deleteMessage/noRecordsDeletedMessage config and the posted definition. The three lists opt into the new selection.
  • Bulk deletion chunks the selection (chunkById, qualified key) and deletes one record at a time, so model events and cascades still run and a whole-query selection stays within memory. The selection query is returned reorder()ed on purpose: chunkById() strips only same-column orders and then pages by key, so a list sorted by anything else would silently skip records — with a 600-record fixture it leaves ~100 behind.
  • The scroll container moved inside _list.php. Its :before/:after indicators are absolutely positioned at top: 1px to sit over the table header; with the banner as the first child of .list-widget they landed on the banner instead.
  • Relation lists don't offer it. Their handlers read post('checked') and relationRefresh() replaces the element the client state lives on, so makeViewWidget()/makeManageWidget() force it off rather than showing a banner that lies.

Testing

# Seed a list with more matches than fit on a page
php artisan tinker --execute "
\$rows = [];
for (\$i = 1; \$i <= 120; \$i++) { \$rows[] = ['level' => 'error', 'message' => \"[demo] webhook delivery failed for order #\" . (1000 + \$i), 'created_at' => now()->subMinutes(\$i), 'updated_at' => now()->subMinutes(\$i)]; }
for (\$i = 1; \$i <= 80; \$i++) { \$rows[] = ['level' => 'info', 'message' => \"[demo] scheduled cron run completed in \" . (120 + \$i) . 'ms', 'created_at' => now()->subHours(\$i), 'updated_at' => now()->subHours(\$i)]; }
DB::table('system_event_logs')->insert(\$rows);
"

Then, in Settings → Event Log (222 records, 30 to a page):

  1. Check one row — no banner. A partial selection is a deliberate choice, so nothing is offered.
  2. Check the header box — "All 30 records on this page are selected. Select all 222 matching records".
  3. Take the offer — "All 222 records matching the current filters are selected."
  4. Paginate, re-sort, toggle a column in the list setup popup — the selection survives all three.
  5. Change the search or the Date & Time filter — the banner clears itself and "Delete selected" disables again.
  6. Uncheck a single row while in whole-query mode — back to an ordinary selection.
  7. Search webhook (120 matches), select all matching, Delete selected — "Deleted 120 records."
  8. Clear the search — 102 records remain, so nothing outside the query was touched.

Cross-tab guard: select all matching in tab A, change the search in tab B, then act in tab A → an error dialog, and nothing deleted.

Cleanup: php artisan tinker --execute "System\Models\EventLog::where('message', 'like', '[demo]%')->delete();"

Verification

  • vendor/bin/phpunit green, including 32 new tests in ListsSelectionTest and ListControllerSelectionTest; phpcs clean on every changed file.
  • The regression tests were mutation-checked, not just written: removing reorder() leaves 100 of 600 records undeleted, removing selectAllMatching from $configFieldsToTransfer breaks the opt-in, dropping the select-list stripping breaks the fingerprint, and reverting the narrowed pluck makes getSelectedKeys() read whole rows.
  • Driven end to end in a browser against 222 records: every step above, plus the cross-tab guard, with no console errors and no new system.log errors. Two bugs came out of that pass and are fixed here — the scroll-indicator overlap, and a selection-consumption handler that loader.stripe.js silences for [data-stripe-load-indicator] elements by stopping ajaxPromise propagation at the document.

Follow-up

Needs a wintercms/docs PR: the selectAllMatching list option, listGetSelectionQuery()/listGetSelectedKeys() for plugin authors, and an upgrade note covering three traps — a restriction implemented inside the old id loop must move onto the query, per-record side effects now run at whole-query scale, and the query's bindings must be stable between requests (a scope binding now() makes every selection look stale).

Summary by CodeRabbit

  • New Features

    • Lists can select all records matching current searches and filters across multiple pages.
    • Selection banners show page and matching-record counts, with controls to select all or clear the selection.
    • Theme, event, and request logs support selecting and deleting matching records.
    • Bulk deletion reports the number of records successfully deleted.
  • Bug Fixes

    • Stale selections are rejected when filters or search results change.
    • Duplicate records from joined lists are deleted only once, and vetoed deletions are excluded from success counts.

List checkboxes only ever reached the current page, so acting on a filtered set
of any size meant paginating through it. A list can now offer "select all N
matching records" once the matches outgrow the page, in the shape Gmail uses:
select the page, then take the offer.

The selection is resolved server-side from the list widget's prepared query -
the search and filter state the widget already holds in the session - so record
ids never cross the wire and the set cannot reach outside what the list shows.
Handlers ask for it through ListController::listGetSelectionQuery() or
listGetSelectedIds(); explicitly checked ids now resolve through that same query
rather than the model's base scope. A fingerprint of the query travels with the
selection, so a filter changed in another browser tab is refused with a message
instead of silently redefining what "all matching" means. Ordering and the
visible column set are excluded from it, since neither changes which records
match.

Opt-in per list (selectAllMatching), off by default: a bulk handler still
reading post('checked') keeps acting on the page, so a list whose handler has
not been migrated would otherwise promise more than it delivers. Event Log,
Request Log and Theme Log opt in, and their duplicated index_onDelete() handlers
are removed in favour of the behavior's - which also gains them
listExtendQuery(), the deleteMessage config and definition routing.

Bulk deletion walks the selection with chunkById() and deletes one record at a
time, so a whole-query selection stays within memory while model events and
cascades still run. The selection query is returned unordered on purpose:
chunkById() keeps any other ORDER BY in place and then pages by key, which
silently skips records.

Relation lists do not offer the selection - their handlers read post('checked'),
and relationRefresh() replaces the element the client state lives on.
@coderabbitai

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

This comment was marked as resolved.

Deleting a selection resolved from a query that joins one-to-many fired each
record's model events once per joined row and counted it that many times: a
15-record list joined to 15 rows each fired 225 `deleting` events. The chunked
walk now acts once per key, and getSelectedKeys() returns each key once.

The key is taken from the query being run rather than from the widget's model.
`backend.list.extendQuery` may return a replacement query - its own docblock
shows one built from a different model - and the old code then filtered on a
table that query does not select from.

A HAVING added by a query extension can reference a selected expression by
alias, which makes the select list decide which records match. The fingerprint
now keeps the select list when the query has a HAVING instead of always
dropping it, erring towards clearing a selection the list can no longer
describe.

getSelection() returns the field names the server reads, so the result of the
documented client accessor can be posted as request data directly instead of
silently falling back to the visible page.

The request bridge matches the list by the definition the request names, so a
bulk action in a multi-definition controller cannot pick up another list's
selection. The list container carries its definition for that lookup.
@LukeTowers

Copy link
Copy Markdown
Member Author

Thanks both — four of the five findings were real, and two of them were bugs I could reproduce. Fixed in aa41d0b.

Duplicate model keys when the query joins one-to-many (CodeRabbit). Reproduced before fixing: a list scoped to 15 records and left-joined to 15 rows each fired 225 deleting events and would have flashed "Deleted 225 records". Every row hydrates its own model, and delete() on the second instance still fires its events and returns true. The chunked walk now acts once per key, and getSelectedKeys() returns each key once. chunkById() pages with key > last, so duplicates of a key straddling a chunk boundary are dropped with it.

The key came from the widget's model, not the query (Copilot). backend.list.extendQuery may return a replacement query — its own docblock shows one built from a different model — and the old code produced where "original_table"."id" in (?) against a query that selects from another table. Now taken from $query->getModel(), with a test that asserts the qualified key follows the replacement.

getSelection() returned all/fingerprint (Copilot) while the server reads checked_all/checked_fingerprint, so a hand-built request posting the documented accessor's result would silently act on the visible page. It now returns the server's field names.

Multi-list routing (both). The bridge took state from the first whole-query list on the page, which for a multi-definition controller could hand list B's request list A's fingerprint. The list container now carries its definition and the bridge matches on the definition the request names, falling back to the single selected list when none is posted (which is every single-list page, and what the server resolves as the primary definition anyway). Verified in a browser: a request naming another definition receives no injection, the matching one does.

HAVING-dependent selects (CodeRabbit) — implemented differently. Retaining "only the select expressions referenced by HAVING" would mean parsing SQL for alias references, which I did not want in a hash function. Instead the select list is dropped only when the query has no having; with one present it stays in the hash. That is conservative in the exotic case — a column-visibility change on such a list clears the selection rather than silently keeping it — and unchanged in the normal one. Covered by a test where two lists select different expressions under the same alias behind the same HAVING.

All four new tests were mutation-checked: each one fails against the pre-fix code. Full suite green (843 tests), phpcs clean.

@LukeTowers LukeTowers added this to the v1.2.15 milestone Sep 26, 2026
Comment thread modules/backend/behaviors/ListController.php Outdated
Comment thread modules/backend/behaviors/ListController.php Outdated
Comment thread modules/cms/controllers/themelogs/config_list.yaml
…edIds()

listGetWidget() now builds the list widgets when the request has not run the index action, which was the only thing the separate listGetSelectionWidget() helper added; it still returns null for an unknown definition. The selection accessors throw for that case inline, and the now-redundant makeLists() calls in FormController and the selection tests are dropped.

listGetSelectedIds() is renamed to listGetSelectedKeys() to match the widget's getSelectedKeys().
@LukeTowers
LukeTowers merged commit 10b2bee into develop Sep 26, 2026
16 checks passed
@LukeTowers
LukeTowers deleted the wip/list-select-all-matching branch September 26, 2026 03:24
LukeTowers added a commit to wintercms/docs that referenced this pull request Sep 26, 2026
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