Add "select all records matching this query" to list bulk actions - #1542
Conversation
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.
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.
|
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 The key came from the widget's model, not the query (Copilot).
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 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 All four new tests were mutation-checked: each one fails against the pre-fix code. Full suite green (843 tests), phpcs clean. |
…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().
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 — somakeLists()+prepareQuery()reconstructs exactly what the user is looking at from any AJAX handler.FormController::formGetRecordNavigation()andLists::onReorder()already rely on this.So the client posts a flag, not a set, and a handler asks the widget for it:
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 (withbackend::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: truealongsideshowCheckboxes: true. It defaults to false, which is the one judgement call worth a second opinion.A document-level
ajaxSetupbridge injects the mode into any request that already postschecked, so existing bulk buttons need no markup changes — but a handler still readingpost('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) readspost('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,RequestLogsandThemeLogslose their duplicatedindex_onDelete()and inherit the behaviour's. They were identical to each other and strictly weaker: they ignoredlistExtendQuery(), thedeleteMessage/noRecordsDeletedMessageconfig and the posteddefinition. The three lists opt into the new 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 returnedreorder()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._list.php. Its:before/:afterindicators are absolutely positioned attop: 1pxto sit over the table header; with the banner as the first child of.list-widgetthey landed on the banner instead.post('checked')andrelationRefresh()replaces the element the client state lives on, somakeViewWidget()/makeManageWidget()force it off rather than showing a banner that lies.Testing
Then, in Settings → Event Log (222 records, 30 to a page):
webhook(120 matches), select all matching, Delete selected — "Deleted 120 records."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/phpunitgreen, including 32 new tests inListsSelectionTestandListControllerSelectionTest;phpcsclean on every changed file.reorder()leaves 100 of 600 records undeleted, removingselectAllMatchingfrom$configFieldsToTransferbreaks the opt-in, dropping the select-list stripping breaks the fingerprint, and reverting the narrowed pluck makesgetSelectedKeys()read whole rows.system.logerrors. Two bugs came out of that pass and are fixed here — the scroll-indicator overlap, and a selection-consumption handler thatloader.stripe.jssilences for[data-stripe-load-indicator]elements by stoppingajaxPromisepropagation at the document.Follow-up
Needs a
wintercms/docsPR: theselectAllMatchinglist 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 bindingnow()makes every selection look stale).Summary by CodeRabbit
New Features
Bug Fixes