Server settings - Plugins: a switch per plugin instead of a four-way dropdown, disabled where it is nobody's to change #286

Closed
opened 2026-09-19 10:12:50 +00:00 by tiagoagueda · 0 comments
Owner

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-62 gives each plugin a checkbox, renders it disabled with an aria-describedby pointing 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):

Value Label today
available Available — the person chooses
unavailable Unavailable — not offered at all
on On — and they may not switch it off
off Off — and they may not switch it on

And the model's docstring defends the distinction the obvious simplification would destroy:

“Unavailable means the plugin is not part of your Postulo and you do not see it. Forced off means you can see that it exists and that somebody switched it off for you. The second is more honest and the first is quieter, and which is right depends on why.”

So this is not a like-for-like swap. Three ways out, in the order I'd rank them:

  1. A switch for on/off, plus a smaller “who decides” control per row. The switch means is this plugin on for this instance; a second, quieter control — a checkbox, let each person choose — carries the available / forced distinction, and unavailable becomes a third option there or moves out of the row entirely. Two simple controls beat one four-way menu, and nothing is lost.
  2. Keep four states, stop using a <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.
  3. Collapse to two states and drop the distinction. Simplest, and it discards something the code argued for on purpose. Only worth doing if the forced-on/forced-off states have turned out not to be used — which is a question about real instances, not about the code.

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.html already 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-describedby pointing at why would be the version worth objecting to.

Which plugins cannot be switched, and where they are now

  • Ungoverned kinds — 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.
  • Shipped-inside plugins, where decide() returns decided_by == "shipped".
  • There is already a sentence written for exactly this: policy.py:89 carries 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) reads request.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 ships a switch — 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 carries disabled: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_time and ::test_no_class_is_defined_on_both_sides_of_the_import both have an opinion. The collision risk looks low: this file defines .field-label, .field-help, .field-error and .field-input, not a bare .field or .input.
  • tap-target already 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:72 carries the same four-state <select>, for one person's exceptions. It should change with its sibling or the two pages diverge.

Notes

  • No migration: the four states stay in the model whichever option is chosen.
  • tests/e2e/test_target_size.py will measure the new control; test_reflow.py will check the row at 320, where the current fixed sm:w-72 column already has a comment about not fitting (#113).
  • The browser suite reads the live tree, so no template or stylesheet edit while it runs; npm run build:css after, since the compiled CSS is committed and CI checks it.
  • New or reworded strings into fr-fr, pt-pt and pt-br as draft; the other 36 at the release sweep.
*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-62` gives each plugin a checkbox, renders it `disabled` with an `aria-describedby` pointing 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`): | Value | Label today | |---|---| | `available` | *Available — the person chooses* | | `unavailable` | *Unavailable — not offered at all* | | `on` | *On — and they may not switch it off* | | `off` | *Off — and they may not switch it on* | And the model's docstring defends the distinction the obvious simplification would destroy: > *“Unavailable means the plugin is not part of your Postulo and you do not see it. Forced off means you can see that it exists and that somebody switched it off for you. The second is more honest and the first is quieter, and which is right depends on why.”* So this is not a like-for-like swap. Three ways out, in the order I'd rank them: 1. **A switch for on/off, plus a smaller “who decides” control per row.** The switch means *is this plugin on for this instance*; a second, quieter control — a checkbox, *let each person choose* — carries the `available` / forced distinction, and `unavailable` becomes a third option there or moves out of the row entirely. Two simple controls beat one four-way menu, and nothing is lost. 2. **Keep four states, stop using a `<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. 3. **Collapse to two states and drop the distinction.** Simplest, and it discards something the code argued for on purpose. Only worth doing if the forced-on/forced-off states have turned out not to be used — which is a question about real instances, not about the code. **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.html` already 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-describedby` pointing at *why* would be the version worth objecting to. ### Which plugins cannot be switched, and where they are now - **Ungoverned kinds** — `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. - **Shipped-inside plugins**, where `decide()` returns `decided_by == "shipped"`. - There is already a sentence written for exactly this: `policy.py:89` carries 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`) reads `request.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 ships a switch** — `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 carries `disabled: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_time` and `::test_no_class_is_defined_on_both_sides_of_the_import` both have an opinion. The collision risk looks low: this file defines `.field-label`, `.field-help`, `.field-error` and `.field-input`, not a bare `.field` or `.input`. - **`tap-target`** already 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:72` carries **the same four-state `<select>`**, for one person's exceptions. It should change with its sibling or the two pages diverge. ## Notes - No migration: the four states stay in the model whichever option is chosen. - `tests/e2e/test_target_size.py` will measure the new control; `test_reflow.py` will check the row at 320, where the current fixed `sm:w-72` column already has a comment about not fitting (#113). - The browser suite reads the live tree, so no template or stylesheet edit while it runs; `npm run build:css` after, since the compiled CSS is committed and CI checks it. - New or reworded strings into `fr-fr`, `pt-pt` and `pt-br` as `draft`; the other 36 at the release sweep.
tiagoagueda added this to the 0.4.0 milestone 2026-09-19 10:12:50 +00:00
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.

Dependencies

No dependencies set.

Reference
Postulo/postulo#286
No description provided.