Selecting rows, and acting on all of them at once #134

Closed
opened 2026-09-09 10:31:14 +00:00 by tiagoagueda · 1 comment
Owner

Observation

i like the sorting and filtered you implemented, but i would berform something like
dispatcharr implemented on their channel listigs

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.py gives a table columns, a validated sort, filters and a page; there
is 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 from
the 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 rather
than 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. ApplicationsTable and
CompaniesTable are the only registered tables; jobs/listing_list.html is not a table at
all, 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.

## Observation > i like the sorting and filtered you implemented, but i would berform something like > dispatcharr implemented on their channel listigs 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.py` gives a table columns, a validated sort, filters and a page; there is 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 from the 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 rather than 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.** `ApplicationsTable` and `CompaniesTable` are the only registered tables; `jobs/listing_list.html` is not a table at all, 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.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:31:14 +00:00
Author
Owner

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, and
never 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/, written
first 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=delete does nothing, so adding it
later 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.js
adds 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 and
one 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, not update() — forty applications quietly
moved 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 all
39 European catalogues.

Shipped in aba7e18 on 0.3.0, with main kept level.

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, and never 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/`, written first 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=delete` does nothing, so adding it later 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.js` *adds* 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 and one 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`**, not `update()` — forty applications quietly moved 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 all 39 European catalogues. Shipped in `aba7e18` on `0.3.0`, with `main` kept level.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#134
No description provided.