Editing a cell where it sits, and where its refusal goes #135
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#135
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
Second prerequisite. Dispatcharr edits in the cell —
EditableTextCell,EditableNumberCell,EditableGroupCelland friends, saving through a patch built from the one field thatchanged. 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 throughpartials/field_feedback.html, under a labelled field, in a form with a heading and a submitbutton — the arrangement
aria-describedbyandaria-invalidwere wired for.The table renders values, and nothing in
core/tables.pyknows 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.mdand CLAUDE.md both say it: status changesgo through
change_status, records are written withrecord_event, fields are never pokeddirectly. An inline cell that writes
application.status = x; save()would produce anapplication 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 messagein 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.
Columngains a declaration for this, besidesortandfilter, so the answer isin 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_atsent 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.
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.htmlalready carriesrole="alert", and its own comment says why:A cell arriving through a swap is precisely that. It already ties the message to the input with
aria-describedby. The only thing missing wasaria-invalidon the input, which the view sets. I wrote a secondsr-onlyannouncement 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_querysetsassumed 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.editablesits besidesortandfilterwhere 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.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 noApplicationsTablecolumn 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
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) andtests/e2e/test_editable_cells.py(7). Suite 4424 passed, 29 skipped; browser suite 72 passed.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.2d348784narrows 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.