Server settings - Plugins: a switch per plugin instead of a four-way dropdown, disabled where it is nobody's to change #286
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.
Dependencies
No dependencies set.
Reference
Postulo/postulo#286
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?
Server settings → Plugins, under “What each plugin does, for everybody”, gives every plugin a
<select>of four states (server/plugins.html:85-91). A list of twelve plugins is therefore twelve dropdowns to read one at a time, when the question an administrator has is nearly always is this on or off?It should be a switch per row, and a switch that is shown but disabled where the answer is not the administrator's to change.
The person's own plugin page already works this way.
settings/plugins.html:58-62gives each plugin a checkbox, renders itdisabledwith anaria-describedbypointing at the sentence that explains who decided, and the view only acts on rows it has established are that person's. The server page is the odd one out, and making the two match is most of this issue.The thing that has to be decided first
A switch is two states. The stored policy is four (
plugins/models.py:107):availableunavailableonoffAnd the model's docstring defends the distinction the obvious simplification would destroy:
So this is not a like-for-like swap. Three ways out, in the order I'd rank them:
available/ forced distinction, andunavailablebecomes a third option there or moves out of the row entirely. Two simple controls beat one four-way menu, and nothing is lost.<select>. A segmented control or a radio group reads at a glance and still says four things. It answers the complaint (“a dropdown you must open to read”) without touching the model. Less of a change, less of an improvement.Recommendation: (1). It gives the page the user asked for, keeps every state reachable, and matches the person's page.
The second half: a disabled switch where it cannot be changed
This is where the codebase currently disagrees with the request, and the disagreement is worth reading before overruling it. The same rule is written down three times:
settings/plugins.html:38— “A row Postulo ships has no box at all rather than a disabled one: a control that can never be used is not a control.”server/plugins.html:279(#94) — “Saying so beside the row is better than a control that fails when pressed — and better than leaving the row out, because what the instance can do is the question this page answers.”server/plugins.html:414— “No button: installing would refuse with this same sentence, and a control that can only refuse is worse than the sentence.”The counter-argument is good, and it turns on switches not being buttons. A dead button offers an action that will not happen. A disabled switch is not offering an action at all — it is displaying a state, in the same vocabulary as every row above it, and saying that state is fixed.
settings/plugins.htmlalready accepts this for the case where a decision is somebody else's: the box is disabled, kept visible, and wired to an explanation. Extending that to the rows that are nobody's to change is consistent, not a reversal — and it means an administrator can read one column down the page instead of parsing a sentence in some rows and a control in others.So: do it, and keep the explanation. A disabled switch with no
aria-describedbypointing at why would be the version worth objecting to.Which plugins cannot be switched, and where they are now
plugins/policy.py:55,UNGOVERNED_KINDS = ("transport", "identifier"). A transport is instance plumbing and forced off would mean an account nobody can recover (#104); an identifier registry “is not a behaviour at all… off would leave every stored identifier without a label, a link or a check” (#109). These are not on the page at all today. Showing them with a fixed switch is the change, and #94's reasoning — what the instance can do is the question this page answers — argues for it.decide()returnsdecided_by == "shipped".policy.py:89carries the"infrastructure"explanation, “How this instance works, rather than a choice anybody holds.” It has a home now.Two traps in the implementation
A checkbox that is not ticked submits nothing.
_save_policies(core/server_views.py:942) readsrequest.POST.get(f"state:{plugin.name}")and skips anything that is not a valid state. With a<select>a value always arrives; with checkboxes, off and not on the page look identical, and every plugin would silently stay as it was. The save has to iterate the governed plugins and treat absence as off — which is what the person's page means by “the view only acts on rows it has already established are yours.”A disabled input submits nothing either, which is harmless here only because those rows have nothing to save. The view must never infer a state change from their absence.
Reuse rather than build
basecoat-css/dist/components/switch.css,input[type=checkbox][role=switch]. A real checkbox, so it posts with the form and needs no script, and it already carriesdisabled:cursor-not-allowed disabled:opacity-50. It is a fourth Basecoat import (today: button, popover, dropdown-menu,assets/css/app.css:24-26), so it is a deliberate addition —tests/test_stylesheet.py::test_basecoat_arrives_one_component_at_a_timeand::test_no_class_is_defined_on_both_sides_of_the_importboth have an opinion. The collision risk looks low: this file defines.field-label,.field-help,.field-errorand.field-input, not a bare.fieldor.input.tap-targetalready exists for the 24×24 problem and is used on the person's page for the same reason (#115): a browser-sized checkbox is 13 pixels and passes only on the spacing exception, which stops being true the moment a second control joins the row.Also worth doing in the same pass
server/person_plugins.html:72carries the same four-state<select>, for one person's exceptions. It should change with its sibling or the two pages diverge.Notes
tests/e2e/test_target_size.pywill measure the new control;test_reflow.pywill check the row at 320, where the current fixedsm:w-72column already has a comment about not fitting (#113).npm run build:cssafter, since the compiled CSS is committed and CI checks it.fr-fr,pt-ptandpt-brasdraft; the other 36 at the release sweep.