Add the dashboard settings channel, wired for theme and lock
A dashboard could only ever receive as a set of tiles. This adds the dashboard itself as a receiver: `settings` maps a name to a value plus an optional binding. Unbound, the setting is simply its value — a wall panel that is always dark costs no flow. Bound, a flow drives it live and the value is the fallback. Two settings are wired: `theme` (system/light/dark) and `locked` (read-only). There is no schedule field on purpose — a node publishing to the bound message on a cron is what a schedule is here, which is the point of a channel. - `messages_for()` now walks a dashboard's bound settings as well as its widgets' bindings. Without this a paired screen is refused its own theme message, on the one surface the setting exists for; it bounds the socket too. - `locked` is gated in `usePublish`, so every control inherits it, and each control also draws itself disabled — a dead button reads as broken otherwise. The panel surface says Read-only in the corner. - The theme is a class on the dashboard's own surface, never the root: inside the app shell it must not flip the chrome. `.light` gains the tokens `.dark` already had (mirrored in the index repo) so both directions work on a subtree. - Settings bindings are type-checked from the document alone, the rule widget bindings follow, and mirrored on the server. - A bound setting is drawn on the flow canvas as a dashboard-level endpoint. - The demo's house flow now publishes `home.panel_theme`, which the demo dashboard's theme binds to: the panel goes dark after sunset, at no tile cost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
@@ -313,6 +313,86 @@ def test_a_panel_speaks_only_its_own_widgets_messages(
|
||||
)
|
||||
|
||||
|
||||
def test_a_panel_may_read_its_dashboards_own_settings(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""A bound setting is no widget's binding, and a panel still needs it.
|
||||
|
||||
The allowlist is a walk of what the dashboards name, so the walk has to
|
||||
reach the dashboard itself: a theme driven over a message is the one thing
|
||||
a screen on a wall cannot be told any other way, and a panel refused that
|
||||
message would leave the feature silently doing nothing on the only surface
|
||||
it exists for.
|
||||
"""
|
||||
from fluksio.api.routes.flows import panel_scope
|
||||
|
||||
_dashboard_with(
|
||||
client, superuser_token_headers, "panel_themed", [PANEL_WIDGETS[-1]]
|
||||
)
|
||||
saved = client.get(
|
||||
f"{DASHBOARDS}/panel_themed?draft=true", headers=superuser_token_headers
|
||||
).json()
|
||||
saved["settings"] = {
|
||||
"theme": {"value": "dark", "message": "demo.panel_theme", "dtype": "str"},
|
||||
# Bound to nothing: a static setting entitles a panel to nothing.
|
||||
"locked": {"value": False},
|
||||
}
|
||||
written = client.put(
|
||||
f"{DASHBOARDS}/panel_themed", headers=superuser_token_headers, json=saved
|
||||
)
|
||||
assert written.status_code == 200, written.text
|
||||
published = client.post(
|
||||
f"{DASHBOARDS}/panel_themed/publish",
|
||||
headers=superuser_token_headers,
|
||||
json={"version": written.json()["version"]},
|
||||
)
|
||||
assert published.status_code == 200, published.text
|
||||
|
||||
_panels(
|
||||
client,
|
||||
superuser_token_headers,
|
||||
{"panels": [{"id": "hall", "dashboards": ["panel_themed"]}]},
|
||||
)
|
||||
panel_headers = _pair(client, superuser_token_headers, "hall")
|
||||
messages = f"{settings.API_V1_STR}/messages"
|
||||
|
||||
# Not 403: the gate lets it through, and the engine then answers for a
|
||||
# message no flow in this suite declares.
|
||||
assert (
|
||||
client.get(
|
||||
f"{messages}/demo.panel_theme/history", headers=panel_headers
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
# And the socket is bounded by the same walk, so the value actually lands.
|
||||
token = panel_headers["Authorization"][7:]
|
||||
assert panel_scope(token, client.app) == {
|
||||
"demo.panel_theme",
|
||||
"demo.temperature",
|
||||
}
|
||||
|
||||
# A setting still buys nothing beyond itself.
|
||||
assert (
|
||||
client.get(
|
||||
f"{messages}/demo.unrelated/history", headers=panel_headers
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_a_dashboard_setting_bound_to_the_wrong_type_is_refused(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""The same answer the server gives a mis-wired widget."""
|
||||
saved = _dashboard(client, superuser_token_headers, "panel_mistyped")
|
||||
saved["settings"] = {"theme": {"value": "dark", "message": "a.b", "dtype": "float"}}
|
||||
|
||||
refused = client.put(
|
||||
f"{DASHBOARDS}/panel_mistyped", headers=superuser_token_headers, json=saved
|
||||
)
|
||||
assert refused.status_code == 422, refused.text
|
||||
|
||||
|
||||
def test_unpairing_a_screen_leaves_its_panel_standing(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user