Editing a cell where it sits, and where its refusal goes #135

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

Observation

something like dispatcharr implemented on their channel listigs

Second prerequisite. Dispatcharr edits in the cell — EditableTextCell, EditableNumberCell,
EditableGroupCell and friends, saving through a patch built from the one field that
changed. It is the feature that makes a long list feel like a spreadsheet rather than a
directory of forms, and it is the one that has to answer a question the current design has
never had to: where does a validation error go when there is no form around it?

What exists

Every edit is a page. A company's name changes on the company form; an application's status
changes through change_status, which writes a timeline entry. Errors render through
partials/field_feedback.html, under a labelled field, in a form with a heading and a submit
button — the arrangement aria-describedby and aria-invalid were wired for.

The table renders values, and nothing in core/tables.py knows how to write one.

What this asks for

Changing a value where it is shown, and the machinery that makes a refusal legible when it
happens in a cell four columns wide.

Worth being careful about

The event log is not optional. docs/PLAN.md and CLAUDE.md both say it: status changes
go through change_status, records are written with record_event, fields are never poked
directly. An inline cell that writes application.status = x; save() would produce an
application whose timeline disagrees with it, which is the one class of bug this project has
designed hardest against. Every editable cell posts to the same service the form uses, or it
is not offered.

A cell has nowhere to put an error. Under the cell breaks the row height; a toast is
gone before a screen reader reaches it; a tooltip is not announced at all. The likely answer
is that the cell keeps the invalid value, marks itself aria-invalid, and puts the message
in a live region — but it has to be decided and tested, not improvised per column.

Not every column can be edited, and the ones that cannot must not look editable. A
company's name can; the number of applications it has cannot; a date read from a posting
cannot. Column gains a declaration for this, beside sort and filter, so the answer is
in the table definition and not in the template.

Concurrency arrives with it. Two tabs on the same list, both editing, last write wins and
neither is told. A checksum or an updated_at sent back with the patch is the cheap version;
deciding to ignore it is fine, but it should be a decision.

Without JavaScript, the cell is a link to the form. That is a complete and honest
fallback, and it means the feature degrades to exactly today's behaviour rather than to
nothing.

The keyboard has to be able to do it. Tab into the cell, edit, Enter to save, Escape to
abandon, and focus stays where it was — otherwise a keyboard user is thrown to the top of a
re-rendered table on every edit. htmx swapping the row is what makes this delicate.

Each editable column is a new set of strings — labels, hints, errors — and every one is
39 translations.

## Observation > something like dispatcharr implemented on their channel listigs Second prerequisite. Dispatcharr edits in the cell — `EditableTextCell`, `EditableNumberCell`, `EditableGroupCell` and friends, saving through a patch built from the one field that changed. It is the feature that makes a long list feel like a spreadsheet rather than a directory of forms, and it is the one that has to answer a question the current design has never had to: **where does a validation error go when there is no form around it?** ## What exists Every edit is a page. A company's name changes on the company form; an application's status changes through `change_status`, which writes a timeline entry. Errors render through `partials/field_feedback.html`, under a labelled field, in a form with a heading and a submit button — the arrangement `aria-describedby` and `aria-invalid` were wired for. The table renders values, and nothing in `core/tables.py` knows how to write one. ## What this asks for Changing a value where it is shown, and the machinery that makes a refusal legible when it happens in a cell four columns wide. ## Worth being careful about **The event log is not optional.** `docs/PLAN.md` and CLAUDE.md both say it: status changes go through `change_status`, records are written with `record_event`, fields are never poked directly. An inline cell that writes `application.status = x; save()` would produce an application whose timeline disagrees with it, which is the one class of bug this project has designed hardest against. Every editable cell posts to the same service the form uses, or it is not offered. **A cell has nowhere to put an error.** Under the cell breaks the row height; a toast is gone before a screen reader reaches it; a tooltip is not announced at all. The likely answer is that the cell keeps the invalid value, marks itself `aria-invalid`, and puts the message in a live region — but it has to be decided and tested, not improvised per column. **Not every column can be edited, and the ones that cannot must not look editable.** A company's name can; the number of applications it has cannot; a date read from a posting cannot. `Column` gains a declaration for this, beside `sort` and `filter`, so the answer is in the table definition and not in the template. **Concurrency arrives with it.** Two tabs on the same list, both editing, last write wins and neither is told. A checksum or an `updated_at` sent back with the patch is the cheap version; deciding to ignore it is fine, but it should be a decision. **Without JavaScript, the cell is a link to the form.** That is a complete and honest fallback, and it means the feature degrades to exactly today's behaviour rather than to nothing. **The keyboard has to be able to do it.** Tab into the cell, edit, Enter to save, Escape to abandon, and focus stays where it was — otherwise a keyboard user is thrown to the top of a re-rendered table on every edit. htmx swapping the row is what makes this delicate. **Each editable column is a new set of strings** — labels, hints, errors — and every one is 39 translations.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:31:14 +00:00
Author
Owner

Done in e800a481.

Where a refusal goes — the question this existed to answer

While a cell is being edited, it is a form. That is the decision, made once rather than improvised per column: an input, a label, and the message where every other message in Postulo goes. The row is taller while that is true, which it already was, because it is holding an input.

The satisfying part is how little was needed. partials/field_feedback.html already carries role="alert", and its own comment says why:

role="alert" earns its place through htmx rather than through page loads: most screen readers do not announce an alert that was already in the document when it arrived, but these forms come back through a swap, and an error inserted into a live page is exactly what the role is for.

A cell arriving through a swap is precisely that. It already ties the message to the input with aria-describedby. The only thing missing was aria-invalid on the input, which the view sets. I wrote a second sr-only announcement first and then removed it — the same sentence twice is worse than once, and the browser test now asserts there is exactly one.

The event log is not optional

Every editable cell posts to the form the page already uses, narrowed to one field by modelform_factory. So a cell refuses exactly what the page refuses, in exactly the same words — there is a test that posts a duplicate name to both and checks they say the same thing.

Writing the field directly would have been a second way of saving, to keep in step with the first, and the day it drifted would be the day something was saved without its rules.

One small thing fell out of it: CompanyForm.scope_querysets assumed every field was present, so a form narrowed to one field crashed. Each scoping is guarded now, with the reason in the docstring — a form used one field at a time is a legitimate use, not an edge case to work around.

Which columns, and enforced rather than declared

Column.editable sits beside sort and filter where somebody reading the table can see it, and the view checks it: POST /jobs/companies/1/cell/postings/ is a 404, not merely a control that was not drawn. A declaration nothing enforces is a convention.

  • name — editable. Chosen first deliberately: it has a real refusal to place (two companies of one name in one account), which is the question the issue is about.
  • counts — not editable, because they are counts.
  • dates read off a posting — not editable, because they belong to the posting.
  • status — not offered here at all, because it goes through change_status, which writes a timeline entry. A cell that skipped that would leave an application whose timeline disagrees with it. There is a test asserting no ApplicationsTable column claims it.

Concurrency, decided rather than ignored

The editor carries the row's updated_at; a save whose stamp has moved is refused and the cell comes back showing what the row says now. Last-write-wins with nobody told is the outcome hardest to notice and hardest to undo.

An unreadable stamp is treated as not moved: refusing every save because a browser sent something odd would be a worse failure than the one this prevents.

Keyboard, focus, and scripts off

  • Enter saves — it is the form's own submit and needed nothing.
  • Escape abandons and puts the value back.
  • Focus follows the edit: the caret lands in the input when it opens, and returns to the value when it saves or is abandoned. That is the failure the issue names outright, and it is a browser test rather than an assertion about markup.
  • With no script the cell is a link to the company form — a complete fallback, not a degraded one: exactly what the table did before this existed.

What axe says

A table with an input in one of its cells is a shape axe had not seen here before, so the browser suite opens an editor and runs axe over the page in both themes. Nothing. The cell endpoint itself is a fragment with no layout of its own, so it is excused from the page walk with that reason written down and a pointer to where it is checked.

Also

  • tests/test_editable_cells.py (14) and tests/e2e/test_editable_cells.py (7). Suite 4424 passed, 29 skipped; browser suite 72 passed.
  • One new string, in all 39 European catalogues.
  • The machinery is core/cells.py; the second table to want this writes three attributes rather than a view.

One thing to flag. During this work the full suite failed once in test_language_picker.py, on an assertion that reads the whole settings page and checks the word "machine" is absent. It did not reproduce — not in isolation, not in the plausible orderings, not by rendering the page directly with or without an administrator's account — and I could not establish what put the word there. 2d348784 narrows that assertion to the picker it is actually about, which is right on its own terms; it does not claim to have fixed the failure, because I do not know that it has.

Done in `e800a481`. ## Where a refusal goes — the question this existed to answer **While a cell is being edited, it *is* a form.** That is the decision, made once rather than improvised per column: an input, a label, and the message where every other message in Postulo goes. The row is taller while that is true, which it already was, because it is holding an input. The satisfying part is how little was needed. `partials/field_feedback.html` already carries `role="alert"`, and its own comment says why: > `role="alert"` earns its place through htmx rather than through page loads: most screen readers do not announce an alert that was already in the document when it arrived, but these forms come back through a swap, and an error inserted into a live page is exactly what the role is for. A cell arriving through a swap is precisely that. It already ties the message to the input with `aria-describedby`. The only thing missing was `aria-invalid` on the input, which the view sets. I wrote a second `sr-only` announcement first and then removed it — the same sentence twice is worse than once, and the browser test now asserts there is exactly one. ## The event log is not optional Every editable cell posts to **the form the page already uses**, narrowed to one field by `modelform_factory`. So a cell refuses exactly what the page refuses, in exactly the same words — there is a test that posts a duplicate name to both and checks they say the same thing. Writing the field directly would have been a second way of saving, to keep in step with the first, and the day it drifted would be the day something was saved without its rules. One small thing fell out of it: `CompanyForm.scope_querysets` assumed every field was present, so a form narrowed to one field crashed. Each scoping is guarded now, with the reason in the docstring — a form used one field at a time is a legitimate use, not an edge case to work around. ## Which columns, and enforced rather than declared `Column.editable` sits beside `sort` and `filter` where somebody reading the table can see it, and **the view checks it**: `POST /jobs/companies/1/cell/postings/` is a 404, not merely a control that was not drawn. A declaration nothing enforces is a convention. - `name` — editable. Chosen first deliberately: it has a **real refusal** to place (two companies of one name in one account), which is the question the issue is about. - counts — not editable, because they are counts. - dates read off a posting — not editable, because they belong to the posting. - **status** — not offered here at all, because it goes through `change_status`, which writes a timeline entry. A cell that skipped that would leave an application whose timeline disagrees with it. There is a test asserting no `ApplicationsTable` column claims it. ## Concurrency, decided rather than ignored The editor carries the row's `updated_at`; a save whose stamp has moved is refused and the cell comes back showing what the row says now. Last-write-wins with nobody told is the outcome hardest to notice and hardest to undo. An unreadable stamp is treated as *not moved*: refusing every save because a browser sent something odd would be a worse failure than the one this prevents. ## Keyboard, focus, and scripts off - **Enter** saves — it is the form's own submit and needed nothing. - **Escape** abandons and puts the value back. - **Focus follows the edit**: the caret lands in the input when it opens, and returns to the value when it saves or is abandoned. That is the failure the issue names outright, and it is a browser test rather than an assertion about markup. - **With no script the cell is a link to the company form** — a complete fallback, not a degraded one: exactly what the table did before this existed. ## What axe says A table with an input in one of its cells is a shape axe had not seen here before, so the browser suite opens an editor and runs axe over the page in **both themes**. Nothing. The cell endpoint itself is a fragment with no layout of its own, so it is excused from the page walk with that reason written down and a pointer to where it *is* checked. ## Also - `tests/test_editable_cells.py` (14) and `tests/e2e/test_editable_cells.py` (7). Suite 4424 passed, 29 skipped; browser suite 72 passed. - One new string, in all 39 European catalogues. - The machinery is `core/cells.py`; the second table to want this writes three attributes rather than a view. **One thing to flag.** During this work the full suite failed once in `test_language_picker.py`, on an assertion that reads the whole settings page and checks the word "machine" is absent. It did not reproduce — not in isolation, not in the plausible orderings, not by rendering the page directly with or without an administrator's account — and I could not establish what put the word there. `2d348784` narrows that assertion to the picker it is actually about, which is right on its own terms; it does not claim to have fixed the failure, because I do not know that it has.
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#135
No description provided.