Selecting rows, and acting on all of them at once #134
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
Reference
Postulo/postulo#134
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observation
First prerequisite. Of everything Dispatcharr's channel table does that ours does not,
selecting rows and acting on all of them at once is the largest, and it is the one with
consequences that are not undoable.
What exists
Nothing.
core/tables.pygives a table columns, a validated sort, filters and a page; thereis no notion of a selected row anywhere in Postulo, and every action is a link or a form
acting on exactly one record.
Dispatcharr's model, for reference: multi-select with a batch dialog that opens when more
than one row is selected, and a bulk delete with warning suppression.
What this asks for
A selection that survives filtering and paging, a bar that says what will happen to how
many, and a server path that cannot be talked into acting on somebody else's rows.
Worth being careful about
Owner scoping is the whole risk, and it moves. Today a view fetches one object through
OwnedObjectMixin, and a foreign id is a 404. A bulk action receives a list of ids fromthe client, and the list is exactly the shape of request that goes wrong: every id has to be
re-scoped with
for_user()server-side, and the action must act on the intersection ratherthan refusing the whole batch — refusing tells the sender which ids exist.
tests/security/gets a case for this, not an afterthought.
Deleting forty things is a different act from deleting one. The confirmation has to name
the count and the kind, and for companies it has to name what goes with them: deleting a
company cascades to its postings, which the interface already says for one company and would
be saying about an unseen number. Consider whether destructive bulk actions are offered at
all in the first version, or only the additive ones — tagging, status, shortlisting.
Selection and filtering interact badly by default. Tick twelve rows, narrow the filter,
press the button: does it act on twelve or on the four still visible? Both answers surprise
somebody. The honest ones are to clear the selection when the query changes, or to show
"12 selected, 4 shown" and act on twelve. Decide it here rather than in the template.
Select-all is two different promises. "All on this page" and "all 340 matching" are
different, and a checkbox in a header row means the first. If the second is offered it has to
be an explicit second step, and it has to work off the query rather than a list of ids.
Without JavaScript this is a form. Checkboxes named the same thing plus a submit button
is a complete implementation of bulk actions and needs no script at all — which is the
version to build first, with htmx making it live afterwards. The board's rule applies:
scripted behaviour is an addition to the control that works everywhere.
Screen readers need the count said out loud. A selection that only exists as a visual
bar is not a selection. The count belongs in a live region, the select-all checkbox needs an
indeterminate state that is actually announced, and axe walks both pages in both themes on
every run.
Two tables exist, and one page that should be a third.
ApplicationsTableandCompaniesTableare the only registered tables;jobs/listing_list.htmlis not a table atall, so the sorting and filtering the suggestion likes is absent from the listings page
entirely. Whatever selection model is chosen should be one implementation for all three.
Selection and additive bulk actions on both registered tables, through one
implementation in
postulo.core.bulk.Owner scoping, which the issue said was the whole risk. Every id is re-scoped with
for_user()and the action works on the intersection — never the list that was sent, andnever by refusing the whole batch, because refusing is an answer and an answer says which ids
exist. What is reported afterwards counts only what changed, since the difference between sent
and changed is a count of somebody else's rows. Sixteen tests in
tests/security/, writtenfirst rather than as an afterthought, including that a companion field (the tag, the industry)
is re-scoped too — otherwise a bulk action becomes a way to read somebody's taxonomy.
Destructive actions: not offered. The issue asked whether they should be in a first
version, and the answer is no. Deleting forty things is a different act from deleting one, and
for a company it cascades to every posting under it — a confirmation naming an unseen number
is a confirmation nobody reads. A test asserts
bulk-action=deletedoes nothing, so adding itlater is deliberate.
Selection versus filtering: a changed query clears it. Acting on twelve while showing four
is the surprise with consequences. The checkboxes are form state and a filter change loads a
fresh page, so this is also what happens naturally and cannot drift out of true — no stashing,
no "12 selected, 4 shown" to keep honest.
Select-all: this page, and a button rather than a header checkbox. A checkbox in a header
row is inert without script, and an inert control is worse than a missing one — so
app.jsadds a button saying exactly what it does. "All 340 matching" is not offered: a different
promise, which has to work from the query rather than a list of ids, and which wants its own
step.
Without JavaScript it is a form, and that is the version that exists: checkboxes sharing a
name, joined to the form by
form=because the table already sits inside the filter form andone form cannot hold another. The count and the select-all button are what the script adds on
top.
Said out loud: the count lives in a live region, with its two sentences as translated
attributes rather than strings in the script. Both list pages are walked by the browser suite
under axe-core in both themes, as they already were.
Status changes go through
change_status, notupdate()— forty applications quietlymoved would be forty records that cannot say when they moved, which is the one thing the event
log exists for. A test checks the timeline entry, and another that moving something to the
status it already has is not counted as a change.
One thing the issue asked for that this does not do: the listings page is still not a
table, so it has no selection. The mechanism is shared and table-agnostic — a table declares
its fields partial and its action URL — so it applies there the day that page becomes one,
which is #136's territory.
16 tests in
tests/security/test_bulk_actions.py, a wiki section, and sixteen strings in all39 European catalogues.
Shipped in
aba7e18on0.3.0, withmainkept level.