The tables take the shape Dispatcharr gives its channel list #136

Closed
opened 2026-09-09 10:31:15 +00:00 by tiagoagueda · 2 comments
Owner

Observation

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

Checked: Dispatcharr/Dispatcharr,
frontend/src/components/tables/ChannelsTable.jsx — TanStack Table with Mantine and
dnd-kit, against a Django backend.

What exists, beside what they do

Dispatcharr's channel table Postulo's tables today
Per-column filters yes, server-side, debounced 500 ms yes, server-side, debounced 300 ms
Where the query lives session storage; sorting in memory the URL, on purpose
Pagination server-side, manualPagination: true server-side, page size on the profile
Column visibility yes yes, and the order too, on the profile
Column widths resizable, saved in browser storage not present
Sorting tri-state: ascending, descending, none two-state: ascending ↔ descending
Select rows, act in bulk yes — batch dialog, bulk delete not present
Edit in the cell yes — EditableTextCell and friends not present
Drag to reorder rows yes, persisted not present

Two of their features are already here, which is worth saying before building anything:
per-column filtering as you type is hx-trigger="input changed delay:300ms, search" in
partials/table/head.html, and it is server-side exactly as theirs is.

One of them is already better here, and the difference is a decision, not an accident.
core/tables.py says why:

Sort and filters live in the URL: they are a question, and a question should be
shareable, bookmarkable and safe with the back button. Which columns show, in what order,
and how many rows a page holds live on the profile
: they are a preference, and a
preference should follow the person to every device rather than clutter every link.

Dispatcharr keeps the query in session storage and the sort in memory: reload and the sort is
gone, and a filtered view cannot be sent to anybody. Whatever is adopted from them, this
should not be — a table narrowed to quiet applications at agencies is a thing to bookmark.

What this asks for

The parts of that table Postulo has not got: selection with bulk actions, editing in the
cell, resizable columns, and the sort that clicks back to none.

Worth being careful about

The two large ones each have a prerequisite — selection because bulk actions are the
first request that arrives as a list of ids from the client, and inline editing because a
cell has nowhere to put a validation error and the event log must not be bypassed.

The two small ones are nearly free and could go first.

Tri-state sorting is three lines in Table.next_sort, which today cycles
ascending → descending → ascending and never returns to the default order. Clicking back to
unsorted is a real gap: there is currently no way to undo a sort except editing the URL.

Resizable columns are the one place a script is unavoidable — a width is a pointer gesture
— but the storage already exists: Profile.table_settings holds visibility, order and page
size per table, and a width map beside them changes no schema. Without a script the columns
size themselves, as now, which is a complete fallback.

Row reordering is theirs and should stay theirs. A channel list has an order somebody
chose; a company list has an order somebody sorted. Dragging a row in a sorted table means
either abandoning the sort or lying about it. Not worth taking.

The listings page is not a table at all. ApplicationsTable and CompaniesTable are the
only two registered; jobs/listing_list.html renders its own rows, so none of the sorting and
filtering the suggestion opens by praising is available there. If the tables are getting
richer, the page most like a channel list — many rows, triaged in bulk, mostly discarded —
is the one that has none of it. Worth deciding whether this issue covers making listings a
table, or whether that is its own.

A table of a thousand rows is where these features earn their keep, and where Postulo has
never been tested.
Dispatcharr manages "thousands of channels"; the largest Postulo table
in practice holds a few hundred applications. Bulk actions and inline editing are worth
building for the shape of the data rather than for the shape of the screenshot.

Every one of these is a page axe already walks in two themes, and three of them
(selection, inline edit, resize) introduce controls that did not exist. The browser suite
grows with them rather than after them.

## Observation > on companies listings, i like the sorting and filtered you implemented, but i would berform > something like dispatcharr implemented on their channel listigs, check repo Checked: [Dispatcharr/Dispatcharr](https://github.com/Dispatcharr/Dispatcharr), `frontend/src/components/tables/ChannelsTable.jsx` — TanStack Table with Mantine and dnd-kit, against a Django backend. ## What exists, beside what they do | | Dispatcharr's channel table | Postulo's tables today | | --- | --- | --- | | Per-column filters | yes, server-side, debounced 500 ms | **yes**, server-side, debounced 300 ms | | Where the query lives | session storage; sorting in memory | **the URL**, on purpose | | Pagination | server-side, `manualPagination: true` | server-side, page size on the profile | | Column visibility | yes | yes, and the order too, on the profile | | Column widths | resizable, saved in browser storage | not present | | Sorting | tri-state: ascending, descending, none | two-state: ascending ↔ descending | | Select rows, act in bulk | yes — batch dialog, bulk delete | **not present** | | Edit in the cell | yes — `EditableTextCell` and friends | **not present** | | Drag to reorder rows | yes, persisted | not present | **Two of their features are already here**, which is worth saying before building anything: per-column filtering as you type is `hx-trigger="input changed delay:300ms, search"` in `partials/table/head.html`, and it is server-side exactly as theirs is. **One of them is already better here, and the difference is a decision, not an accident.** `core/tables.py` says why: > **Sort and filters live in the URL**: they are a question, and a question should be > shareable, bookmarkable and safe with the back button. **Which columns show, in what order, > and how many rows a page holds live on the profile**: they are a preference, and a > preference should follow the person to every device rather than clutter every link. Dispatcharr keeps the query in session storage and the sort in memory: reload and the sort is gone, and a filtered view cannot be sent to anybody. Whatever is adopted from them, this should not be — a table narrowed to *quiet applications at agencies* is a thing to bookmark. ## What this asks for The parts of that table Postulo has not got: selection with bulk actions, editing in the cell, resizable columns, and the sort that clicks back to none. ## Worth being careful about **The two large ones each have a prerequisite** — selection because bulk actions are the first request that arrives as *a list of ids from the client*, and inline editing because a cell has nowhere to put a validation error and the event log must not be bypassed. **The two small ones are nearly free and could go first.** *Tri-state sorting* is three lines in `Table.next_sort`, which today cycles ascending → descending → ascending and never returns to the default order. Clicking back to unsorted is a real gap: there is currently no way to undo a sort except editing the URL. *Resizable columns* are the one place a script is unavoidable — a width is a pointer gesture — but the storage already exists: `Profile.table_settings` holds visibility, order and page size per table, and a width map beside them changes no schema. Without a script the columns size themselves, as now, which is a complete fallback. **Row reordering is theirs and should stay theirs.** A channel list has an order somebody chose; a company list has an order somebody *sorted*. Dragging a row in a sorted table means either abandoning the sort or lying about it. Not worth taking. **The listings page is not a table at all.** `ApplicationsTable` and `CompaniesTable` are the only two registered; `jobs/listing_list.html` renders its own rows, so none of the sorting and filtering the suggestion opens by praising is available there. If the tables are getting richer, the page most like a channel list — many rows, triaged in bulk, mostly discarded — is the one that has none of it. Worth deciding whether this issue covers making listings a table, or whether that is its own. **A table of a thousand rows is where these features earn their keep, and where Postulo has never been tested.** Dispatcharr manages "thousands of channels"; the largest Postulo table in practice holds a few hundred applications. Bulk actions and inline editing are worth building for the shape of the data rather than for the shape of the screenshot. **Every one of these is a page axe already walks in two themes**, and three of them (selection, inline edit, resize) introduce controls that did not exist. The browser suite grows with them rather than after them.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:31:15 +00:00
Author
Owner

Done in 452057c5. All four are here now, and two of them arrived before this commit did.

Then Now
Select rows, act in bulk not present #134
Edit in the cell not present #135
Tri-state sorting two-state this
Resizable columns not present this
Row reordering not present and not meant to be

Sorting, in three states

Two lines of arithmetic and one real gap closed: there was no way to undo a sort except by editing the address.

A column that is the table's default sort keeps two states. There is nothing to go back to — the table's own order is that column ascending — so a third click would change nothing, and offering it would be a control that lies about what it does.

The arrow no longer says the whole of it, because the third state takes the arrow away and an arrow that disappears announces nothing. Every header now carries the sentence: Sort by applications, highest first, Stop sorting by postings.

Widths, on the profile

You drew the line already and it decided this: sort and filters are a question and live in the URL; which columns show and how many rows a page holds are a preference and live on the profile. A width is a preference, so it went beside them — no schema change, as you predicted.

That is also the one thing from Dispatcharr deliberately not adopted, and there is a test saying so: theirs keeps the query in session storage and the sort in memory, so a reload loses the sort and a filtered view cannot be sent to anybody. A table narrowed to quiet applications at agencies is a thing to bookmark.

The handle, and two things the suite caught

A width is a pointer gesture, so this is the one place a script is unavoidable — but it is still an addition rather than a replacement. Without a script no handle exists at all and the columns size themselves exactly as before, which is a complete fallback.

It is not pointer-only. The handle is a <button>: the arrow keys widen and narrow it (flipped for right-to-left), and Home lets the column size itself again — because a column dragged too narrow once must not be too narrow for ever. A control only a mouse can reach is a control half the people using this application cannot use.

Two things went wrong and were caught rather than shipped:

  • The first handle was 8 pixels wide, and test_target_size.py failed it under SC 2.5.8. It is 24 now, and the script adds the room for it at the same moment it adds the handle — so a page with no script does not reserve space for a control that is not there. That is the third time this criterion has caught this project, and the second time the suite caught it before anybody else could.
  • The browser tests slept instead of waiting. The save is a background request, and a test that ends while one is in flight tears the live server down underneath it — two errors in a full run, nothing at all in isolation. They wait for the response now.

Row reordering

Not taken, and I agree with your reasoning: a channel list has an order somebody chose; a company list has an order somebody sorted, and dragging a row in a sorted table means either abandoning the sort or lying about it.

The listings page

The decision you asked for: not in this issue. Making listings a table is a page rewrite rather than a table feature, and folding it into an issue that was already four features would have meant doing it badly to finish something else. It is filed as #160 for 0.4.0, with what makes that page different written down — the capture-and-discard workflow is not a column, bulk discard needs a reason, and a table's furniture over an empty list is worse than the page there is now.

Also

  • tests/test_table_shape.py (23) and tests/e2e/test_column_widths.py (7). Suite 4464 passed, 29 skipped; browser suite 78 passed.
  • Five new strings, filled in all 39 European catalogues.
Done in `452057c5`. All four are here now, and two of them arrived before this commit did. | | Then | Now | | --- | --- | --- | | Select rows, act in bulk | not present | `#134` | | Edit in the cell | not present | `#135` | | Tri-state sorting | two-state | **this** | | Resizable columns | not present | **this** | | Row reordering | not present | **and not meant to be** | ## Sorting, in three states Two lines of arithmetic and one real gap closed: there was no way to undo a sort except by editing the address. **A column that *is* the table's default sort keeps two states.** There is nothing to go back *to* — the table's own order is that column ascending — so a third click would change nothing, and offering it would be a control that lies about what it does. **The arrow no longer says the whole of it**, because the third state takes the arrow away and an arrow that disappears announces nothing. Every header now carries the sentence: *Sort by applications, highest first*, *Stop sorting by postings*. ## Widths, on the profile You drew the line already and it decided this: sort and filters are a **question** and live in the URL; which columns show and how many rows a page holds are a **preference** and live on the profile. A width is a preference, so it went beside them — no schema change, as you predicted. That is also the one thing from Dispatcharr deliberately **not** adopted, and there is a test saying so: theirs keeps the query in session storage and the sort in memory, so a reload loses the sort and a filtered view cannot be sent to anybody. A table narrowed to *quiet applications at agencies* is a thing to bookmark. ## The handle, and two things the suite caught A width is a pointer gesture, so this is the one place a script is unavoidable — but it is still an addition rather than a replacement. **Without a script no handle exists at all** and the columns size themselves exactly as before, which is a complete fallback. **It is not pointer-only.** The handle is a `<button>`: the arrow keys widen and narrow it (flipped for right-to-left), and *Home* lets the column size itself again — because a column dragged too narrow once must not be too narrow for ever. A control only a mouse can reach is a control half the people using this application cannot use. Two things went wrong and were caught rather than shipped: - **The first handle was 8 pixels wide**, and `test_target_size.py` failed it under SC 2.5.8. It is 24 now, and the script adds the room for it at the same moment it adds the handle — so a page with no script does not reserve space for a control that is not there. That is the third time this criterion has caught this project, and the second time the suite caught it before anybody else could. - **The browser tests slept instead of waiting.** The save is a background request, and a test that ends while one is in flight tears the live server down underneath it — two errors in a full run, nothing at all in isolation. They wait for the response now. ## Row reordering Not taken, and I agree with your reasoning: a channel list has an order somebody chose; a company list has an order somebody *sorted*, and dragging a row in a sorted table means either abandoning the sort or lying about it. ## The listings page The decision you asked for: **not in this issue.** Making listings a table is a page rewrite rather than a table feature, and folding it into an issue that was already four features would have meant doing it badly to finish something else. It is filed as **#160** for 0.4.0, with what makes that page different written down — the capture-and-discard workflow is not a column, bulk discard needs a reason, and a table's furniture over an empty list is worse than the page there is now. ## Also - `tests/test_table_shape.py` (23) and `tests/e2e/test_column_widths.py` (7). Suite 4464 passed, 29 skipped; browser suite 78 passed. - Five new strings, filled in all 39 European catalogues.
Author
Owner

Follow-up, fixed in a8368c7d: the width did nothing on a real deployment.

It shipped as style="width: 320px" on the header cell, and the policy Postulo serves is
style-src 'self', which refuses a style attribute as firmly as it refuses an inline
script. The browser dropped it, the column sized itself, and every load of a table with a
stored width also collected a console violation — behind which a genuine one would have been
invisible.

Neither existing test could have caught it, and that is the part worth recording.
tests/e2e/test_column_widths.py drives a real browser but under the development settings,
where the policy is not enforced; tests/e2e/test_csp.py serves the production policy but
visited no page that had a stored width. Both were true, both passed, and the feature was
broken in production. Two tests close it: the header carries no style= at all, and a table
with a stored width now loads under the real policy with no complaint and a column that is
measurably wider.

The fix puts the width where it belongs anyway. The script that owns the handle applies it
through the DOM — which style-src does not govern — so a width produced by a pointer
gesture is applied by the same script that provides the gesture, and with scripts off the
columns size themselves exactly as the fallback already promised.

Follow-up, fixed in `a8368c7d`: **the width did nothing on a real deployment.** It shipped as `style="width: 320px"` on the header cell, and the policy Postulo serves is `style-src 'self'`, which refuses a style *attribute* as firmly as it refuses an inline script. The browser dropped it, the column sized itself, and every load of a table with a stored width also collected a console violation — behind which a genuine one would have been invisible. **Neither existing test could have caught it, and that is the part worth recording.** `tests/e2e/test_column_widths.py` drives a real browser but under the development settings, where the policy is not enforced; `tests/e2e/test_csp.py` serves the production policy but visited no page that had a stored width. Both were true, both passed, and the feature was broken in production. Two tests close it: the header carries no `style=` at all, and a table with a stored width now loads under the real policy with no complaint and a column that is measurably wider. The fix puts the width where it belongs anyway. The script that owns the handle applies it through the DOM — which `style-src` does not govern — so a width produced by a pointer gesture is applied by the same script that provides the gesture, and with scripts off the columns size themselves exactly as the fallback already promised.
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#136
No description provided.