feat(web): honour x-display: hidden in plugin settings (#585)

* feat(web): honour x-display: hidden in plugin settings

Plugins keep deprecated and internal keys declared so stored configs keep
validating (weather api_key/radar_zoom, countdown's auto-generated row id),
but the settings form drew them as live controls.

A property marked "x-display": "hidden" -- or an object whose children are
all hidden -- now gets no control at any depth: top level, nested sections,
Advanced Settings (not counted either), array-table columns and the row
editor. A hidden top-level key is not reported in __rendered_section.

Saving never changes a hidden value. Plain and nested fields aren't posted,
so the save's deep merge keeps them; _set_missing_booleans_to_false skips
hidden booleans at every depth. A posted array row replaces the stored item,
so hidden row properties are carried as JSON-encoded hidden inputs and
decoded exactly on save (an id "1" stays a string). New rows get none.
JSON API saves are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(web): read hidden row keys without dynamic property access

Build the set of x-display: hidden item properties once and look values up
through Object.entries, instead of indexing objects by a variable key on
the lines this branch added (Codacy: object injection sink, 6 warnings).
Behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-15 15:47:06 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 8360220809
commit fddb0e06db
8 changed files with 826 additions and 11 deletions
+9
View File
@@ -39,6 +39,15 @@ this):
`rgbmatrix` and never import `src.base_classes`, `src.display_manager` or
`src.plugin_system` at module level.
Web interface:
- The plugin settings form honours `"x-display": "hidden"` in config schemas:
the property gets no control at any depth (top level, nested objects, array
rows, Advanced Settings), and saving the form never changes its stored value.
JSON API saves are unaffected. Lets plugins keep deprecated or internal keys
declared, e.g. countdown's row `id` and weather's `api_key` / `radar_zoom`.
See `docs/widget-guide.md`.
## 3.4.0
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
+36
View File
@@ -281,6 +281,42 @@ Guidelines:
their own collapsible sections) and is safely ignored by older cores, so
adding it never breaks compatibility.
## Hiding Fields From the Form (`x-display: "hidden"`)
Add `"x-display": "hidden"` to a property that must stay in the schema but
should not appear as a control: a deprecated key kept so existing configs keep
validating, or an internal value such as an auto-generated row id.
```json
{
"properties": {
"radar_zoom": {
"type": "integer",
"default": 6,
"title": "Radar Zoom Level (deprecated)",
"x-display": "hidden"
}
}
}
```
What the core does with it:
- **Not rendered** at any depth: top-level fields, children of an object
section, and properties of array-of-object items (never a table column, even
if `x-columns` names it, and never in the row editor). A hidden field flagged
`x-advanced` is not listed or counted in Advanced Settings, and an object
whose children are all hidden draws no empty section. Hidden fields don't
show up in the settings search either, since it indexes the rendered form.
- **Stored value preserved on save.** Saving the form never changes a hidden
value. The unchecked-checkbox rule ignores a hidden boolean. Array rows carry
a hidden property's stored value through the form, so the value survives the
row being posted back; a new row gets no value (the plugin fills it in).
- **The API is unaffected.** A JSON save to `POST /api/v3/plugins/config` can
still set a hidden field.
Older cores ignore the flag and render the field as a normal control.
## Creating Custom Widgets
### Step 1: Create Widget File
+577
View File
@@ -0,0 +1,577 @@
"""``"x-display": "hidden"`` keeps a setting declared but off the form.
Plugins keep deprecated keys in their schema so configs that still carry them
keep validating (weather's ``api_key`` and ``radar_zoom``, olympics'
notification keys), and some keys are internal (countdown's auto-generated
row ``id``). Drawn as live controls they invite edits that do nothing, or
worse, change an internal id.
The contract pinned here:
* nothing hidden is drawn -- top level, nested, in Advanced Settings, or as an
array-table column -- and visible siblings still are;
* an object of nothing but hidden children draws no empty section;
* a hidden top-level key is not reported in ``__rendered_section`` and does
not count toward the Advanced Settings total;
* saving the rendered form never changes a hidden value: plain and nested ones
are simply not posted (the save deep-merges over the stored section), and a
hidden array-row property is carried through JSON-encoded, because a posted
row replaces the stored item wholesale;
* an unchecked *visible* checkbox beside them is still cleared;
* a JSON API save is unaffected.
"""
import json
import re
import sys
from html.parser import HTMLParser
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from werkzeug.datastructures import MultiDict
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
HIDDEN = {"x-display": "hidden"}
SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"city": {"type": "string", "title": "City", "default": "Dallas"},
"legacy_key": {"type": "string", "title": "Legacy Key", **HIDDEN},
"legacy_flag": {"type": "boolean", "title": "Legacy Flag", **HIDDEN},
"timeout": {"type": "integer", "title": "Timeout", "default": 10,
"x-advanced": True},
"old_zoom": {"type": "integer", "title": "Old Zoom", "default": 6,
"minimum": 4, "maximum": 8, "x-advanced": True, **HIDDEN},
"display": {
"type": "object",
"title": "Display",
"properties": {
"show_clock": {"type": "boolean", "title": "Show Clock",
"default": True},
"old_flag": {"type": "boolean", "title": "Old Flag", **HIDDEN},
"old_mode": {"type": "string", "title": "Old Mode", **HIDDEN},
},
},
# Every child hidden: no empty section.
"internal": {
"type": "object",
"title": "Internal Stuff",
"properties": {
"token_seen": {"type": "boolean", **HIDDEN},
"revision": {"type": "integer", **HIDDEN},
},
},
"entries": {
"type": "array",
"title": "Entries",
"x-widget": "array-table",
# x-columns naming a hidden property must not draw it either.
"x-columns": ["enabled", "name", "id"],
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "title": "Row ID", **HIDDEN},
"enabled": {"type": "boolean", "default": True},
"name": {"type": "string"},
"pinned": {"type": "boolean", "title": "Pinned", **HIDDEN},
"weight": {"type": "number", "title": "Weight", **HIDDEN},
"layout": {
"type": "object",
"properties": {
"x": {"type": "integer", "default": 0},
"legacy_x": {"type": "integer", **HIDDEN},
},
},
},
"required": ["name"],
},
},
},
}
STORED = {
"enabled": True,
"city": "Dallas",
"legacy_key": "keep-me",
"legacy_flag": True,
"timeout": 10,
"old_zoom": 5,
"display": {"show_clock": True, "old_flag": True, "old_mode": "classic"},
"internal": {"token_seen": True, "revision": 7},
"entries": [
# An id that looks like a number must come back a string.
{"id": "1", "enabled": True, "name": "First", "pinned": True,
"weight": 2.5, "layout": {"x": 3, "legacy_x": 9}},
{"id": "cd_ab12cd34ef56", "enabled": False, "name": "Second",
"pinned": False},
],
}
HIDDEN_LABELS = ("Legacy Key", "Legacy Flag", "Old Zoom", "Old Flag",
"Old Mode", "Internal Stuff", "Row ID", "Pinned", "Weight")
class _FormFields(HTMLParser):
"""Collect what a browser would submit from the rendered form."""
def __init__(self):
super().__init__()
self.pairs = []
self._select = None
self._first = None
self._chosen = None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
name = a.get("name")
if tag == "input" and name and "disabled" not in a:
if a.get("type") in ("checkbox", "radio") and "checked" not in a:
return
if a.get("type") in ("button", "submit", "file"):
return
self.pairs.append((name, a.get("value", "")))
elif tag == "select" and name:
self._select, self._first, self._chosen = name, None, None
elif tag == "option" and self._select:
if self._first is None:
self._first = a.get("value", "")
if "selected" in a:
self._chosen = a.get("value", "")
def handle_endtag(self, tag):
if tag == "select" and self._select:
chosen = self._chosen if self._chosen is not None else self._first
self.pairs.append((self._select, chosen or ""))
self._select = None
def _render(schema, config, plugin_id="demo"):
from jinja2 import Environment, FileSystemLoader, select_autoescape
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT / "web_interface" / "templates")),
autoescape=select_autoescape(["html"]),
)
plugin = {"id": plugin_id, "name": "Demo", "description": "",
"enabled": True, "author": "me", "version": "1.0.0"}
return env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=schema, config=json.loads(json.dumps(config)))
def _form_pairs(html):
parser = _FormFields()
parser.feed(html)
return parser.pairs
def _names(html):
return {name for name, _ in _form_pairs(html)}
def _visible_names(html):
"""Names of controls a user can see (anything but type=hidden)."""
names = set()
for tag in re.findall(r"<(?:input|select|textarea)\b[^>]*>", html):
m = re.search(r'\bname="([^"]+)"', tag)
if m and 'type="hidden"' not in tag:
names.add(m.group(1))
return names
def _make_post(tmp_path, schema, stored, plugin_id="demo"):
from src.plugin_system.schema_manager import SchemaManager
from web_interface.blueprints import api_v3 as a
originals = {k: getattr(a.api_v3, k, None)
for k in ("config_manager", "schema_manager", "plugin_manager")}
pdir = tmp_path / "plugins" / plugin_id
pdir.mkdir(parents=True)
(pdir / "config_schema.json").write_text(json.dumps(schema), encoding="utf-8")
store = {plugin_id: json.loads(json.dumps(stored))}
cm = MagicMock()
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
cm.get_raw_file_content.return_value = {}
cm.get_config_path.return_value = str(tmp_path / "config.json")
def _save(cfg, **_kw):
store.clear()
store.update(cfg)
return type("R", (), {"status": type("S", (), {"value": "success"})(),
"message": None})()
cm.save_config_atomic.side_effect = _save
a.api_v3.config_manager = cm
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
pm = MagicMock()
pm.plugins = {}
pm.get_plugin.return_value = None
a.api_v3.plugin_manager = pm
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
client = app.test_client()
def _post(pairs):
resp = client.post(f"/api/v3/plugins/config?plugin_id={plugin_id}",
data=MultiDict(pairs))
return resp, store.get(plugin_id, {})
def _post_json(config):
resp = client.post("/api/v3/plugins/config",
json={"plugin_id": plugin_id, "config": config})
return resp, store.get(plugin_id, {})
def _restore():
for k, v in originals.items():
setattr(a.api_v3, k, v)
return _post, _post_json, _restore
@pytest.fixture
def post(tmp_path):
_post, _post_json, restore = _make_post(tmp_path, SCHEMA, STORED)
_post.json = _post_json
try:
yield _post
finally:
restore()
# --------------------------------------------------------------------------
# Rendering
# --------------------------------------------------------------------------
class TestRendering:
html = None
@classmethod
def setup_class(cls):
cls.html = _render(SCHEMA, STORED)
def test_no_hidden_field_gets_a_visible_control(self):
visible = _visible_names(self.html)
for name in ("legacy_key", "legacy_flag", "old_zoom",
"display.old_flag", "display.old_mode",
"internal.token_seen", "internal.revision"):
assert name not in visible, name
assert not [n for n in visible if re.fullmatch(
r"entries\.\d+\.(id|pinned|weight|layout\.legacy_x)", n)], visible
def test_plain_and_nested_hidden_fields_are_not_posted_at_all(self):
names = _names(self.html)
for name in ("legacy_key", "legacy_flag", "old_zoom",
"display.old_flag", "display.old_mode",
"internal.token_seen", "internal.revision"):
assert name not in names, name
def test_no_hidden_label_is_drawn(self):
for label in HIDDEN_LABELS:
assert f">{label}<" not in self.html, label
assert f"{label}\n" not in self.html, label
def test_visible_siblings_still_render(self):
visible = _visible_names(self.html)
assert {"city", "timeout", "display.show_clock",
"entries.0.name", "entries.0.enabled"} <= visible
def test_rendered_sections_exclude_hidden_top_level_keys(self):
sections = re.findall(r'name="__rendered_section" value="([^"]+)"',
self.html)
assert set(sections) == {"city", "timeout", "display", "entries"}
def test_an_object_of_only_hidden_children_draws_no_section(self):
assert "Internal Stuff" not in self.html
assert "section-internal" not in self.html
def test_the_advanced_count_excludes_hidden_fields(self):
assert re.findall(r"Advanced Settings \((\d+)\)", self.html) == ["1"]
def test_advanced_settings_disappear_when_all_advanced_are_hidden(self):
schema = json.loads(json.dumps(SCHEMA))
del schema["properties"]["timeout"]
html = _render(schema, STORED)
assert "Advanced Settings" not in html
def test_hidden_array_properties_are_not_columns(self):
headers = re.findall(r"<th[^>]*>([^<]+)</th>", self.html)
assert "Row ID" not in headers and "Pinned" not in headers
cols = re.search(r"data-display-columns='([^']*)'", self.html)
assert json.loads(cols.group(1)) == ["enabled", "name"]
def test_hidden_array_properties_stay_out_of_the_row_editor(self):
"""The editor draws a field for every data-nested-prop and every key
of the advanced cell's data-prop-schema."""
assert 'data-nested-prop="pinned"' not in self.html
assert 'data-nested-prop="layout.legacy_x"' not in self.html
assert 'data-nested-prop="layout.x"' in self.html
for m in re.finditer(r"class=\"array-table-advanced-data\"\s+"
r"data-prop-schema='([^']*)'", self.html):
import html as _h
adv = json.loads(_h.unescape(m.group(1)))
assert "pinned" not in adv and "weight" not in adv and "id" not in adv
def test_stored_hidden_row_values_are_carried_json_encoded(self):
pairs = dict(_form_pairs(self.html))
assert pairs["entries.0.id"] == '"1"'
assert pairs["entries.0.pinned"] == "true"
assert pairs["entries.0.weight"] == "2.5"
assert pairs["entries.0.layout.legacy_x"] == "9"
assert pairs["entries.1.id"] == '"cd_ab12cd34ef56"'
# Absent stored values are not invented.
assert "entries.1.weight" not in pairs
assert "entries.1.layout.legacy_x" not in pairs
def test_a_schema_without_the_flag_renders_as_before():
"""Regression guard: the flag's absence changes nothing."""
schema = json.loads(json.dumps(SCHEMA))
def strip(node):
if isinstance(node, dict):
node.pop("x-display", None)
for v in node.values():
strip(v)
strip(schema)
html = _render(schema, STORED)
visible = _visible_names(html)
assert {"legacy_key", "legacy_flag", "old_zoom", "display.old_flag",
"internal.token_seen", "entries.0.id"} <= visible
# Not a column, so it lives in the row editor's advanced cell as before.
assert 'data-nested-prop="pinned"' in html
assert re.findall(r"Advanced Settings \((\d+)\)", html) == ["2"]
# --------------------------------------------------------------------------
# Saving
# --------------------------------------------------------------------------
class TestSavingTheRenderedForm:
def test_the_untouched_form_changes_no_hidden_value(self, post):
resp, cfg = post(_form_pairs(_render(SCHEMA, STORED)))
assert resp.status_code == 200, resp.get_json()
assert cfg["legacy_key"] == "keep-me"
assert cfg["legacy_flag"] is True
assert cfg["old_zoom"] == 5
assert cfg["display"]["old_flag"] is True
assert cfg["display"]["old_mode"] == "classic"
assert cfg["internal"] == {"token_seen": True, "revision": 7}
first, second = cfg["entries"]
assert first["id"] == "1" and isinstance(first["id"], str)
assert first["pinned"] is True
assert first["weight"] == 2.5
assert first["layout"]["legacy_x"] == 9
assert second["id"] == "cd_ab12cd34ef56"
assert second["pinned"] is False
def test_visible_edits_apply_and_an_unchecked_visible_box_is_cleared(self, post):
pairs = [(k, v) for k, v in _form_pairs(_render(SCHEMA, STORED))
if k != "display.show_clock"]
pairs = [(k, "Austin" if k == "city" else v) for k, v in pairs]
resp, cfg = post(pairs)
assert resp.status_code == 200, resp.get_json()
assert cfg["city"] == "Austin"
# The existing unchecked-checkbox behaviour, in the same section as
# hidden booleans that must not be touched.
assert cfg["display"]["show_clock"] is False
assert cfg["display"]["old_flag"] is True
assert cfg["legacy_flag"] is True
assert cfg["entries"][0]["pinned"] is True
def test_a_deleted_row_takes_its_hidden_values_with_it(self, post):
"""Row 0 removed; the browser renumbers row 1 to index 0."""
pairs = []
for k, v in _form_pairs(_render(SCHEMA, STORED)):
m = re.match(r"entries\.(\d+)\.(.*)", k)
if m:
if m.group(1) == "0":
continue
k = f"entries.0.{m.group(2)}"
pairs.append((k, v))
resp, cfg = post(pairs)
assert resp.status_code == 200, resp.get_json()
assert [e["id"] for e in cfg["entries"]] == ["cd_ab12cd34ef56"]
def test_a_new_row_gets_no_invented_hidden_values(self, post):
pairs = _form_pairs(_render(SCHEMA, STORED)) + [
("entries.2.enabled", "true"), ("entries.2.name", "Third")]
resp, cfg = post(pairs)
assert resp.status_code == 200, resp.get_json()
assert cfg["entries"][2]["name"] == "Third"
assert "id" not in cfg["entries"][2]
assert "pinned" not in cfg["entries"][2]
def test_a_json_api_save_still_writes_hidden_fields(self, post):
"""Hidden is a form concern: API callers can still set these."""
config = json.loads(json.dumps(STORED))
config["legacy_flag"] = False
config["display"]["old_mode"] = "modern"
config["entries"][0]["id"] = "renamed"
resp, cfg = post.json(config)
assert resp.status_code == 200, resp.get_json()
assert cfg["legacy_flag"] is False
assert cfg["display"]["old_mode"] == "modern"
assert cfg["entries"][0]["id"] == "renamed"
class TestMissingBooleans:
"""_set_missing_booleans_to_false, directly, at every depth."""
def test_hidden_booleans_are_out_of_scope_everywhere(self):
from web_interface.blueprints.api_v3 import _set_missing_booleans_to_false
cfg = json.loads(json.dumps(STORED))
form_keys = {"city", "entries.0.name", "entries.1.name"}
_set_missing_booleans_to_false(
cfg, SCHEMA["properties"], form_keys,
sections={"city", "display", "entries", "internal", "legacy_flag"})
assert cfg["legacy_flag"] is True
assert cfg["display"]["old_flag"] is True
assert cfg["internal"]["token_seen"] is True
assert cfg["entries"][0]["pinned"] is True
# Visible booleans in the same places are still cleared.
assert cfg["display"]["show_clock"] is False
assert cfg["entries"][0]["enabled"] is False
def test_hidden_array_item_property_lookup(self):
from web_interface.blueprints.api_v3 import _hidden_array_item_property
assert _hidden_array_item_property(SCHEMA, "entries.0.id")
assert _hidden_array_item_property(SCHEMA, "entries.12.layout.legacy_x")
assert _hidden_array_item_property(SCHEMA, "entries.0.name") is None
assert _hidden_array_item_property(SCHEMA, "entries.0.layout.x") is None
# Only array rows qualify: plain hidden keys are never posted.
assert _hidden_array_item_property(SCHEMA, "legacy_key") is None
assert _hidden_array_item_property(SCHEMA, "display.old_flag") is None
# --------------------------------------------------------------------------
# Real plugin schemas (snippets copied from ledmatrix-plugins)
# --------------------------------------------------------------------------
# plugins/countdown/config_schema.json, trimmed to the countdowns table.
COUNTDOWN_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"countdowns": {
"type": "array",
"x-widget": "array-table",
"x-columns": ["enabled", "name", "target_date", "target_time", "mode"],
"default": [],
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "title": "ID",
"description": "Unique identifier (auto-generated)",
"x-display": "hidden"},
"enabled": {"type": "boolean", "title": "Enabled", "default": True},
"name": {"type": "string", "title": "Name",
"minLength": 1, "maxLength": 30},
"target_date": {"type": "string", "title": "Target Date",
"x-widget": "date-picker",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"},
"target_time": {"type": "string", "title": "Target Time",
"x-widget": "time-picker", "default": "00:00"},
"mode": {"type": "string", "title": "Mode",
"enum": ["until", "since"], "default": "until"},
"display_order": {"x-advanced": True, "type": "integer",
"title": "Order", "default": 0, "minimum": 0},
},
"required": ["name", "target_date"],
},
},
"show_expired": {"x-advanced": True, "type": "boolean",
"title": "Show Expired", "default": False},
},
"required": ["enabled"],
}
COUNTDOWN_STORED = {
"enabled": True,
"show_expired": True,
"countdowns": [
{"id": "cd_0123456789ab", "enabled": True, "name": "Vacation",
"target_date": "2026-12-20", "target_time": "00:00", "mode": "until",
"display_order": 0},
{"id": "42", "enabled": True, "name": "Anniversary",
"target_date": "2020-06-01", "target_time": "00:00", "mode": "since",
"display_order": 1},
],
}
# plugins/ledmatrix-weather/config_schema.json, trimmed.
WEATHER_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"location_city": {"type": "string", "default": "Dallas", "title": "City"},
"update_interval": {"x-advanced": True, "type": "integer", "default": 1800,
"minimum": 300},
"api_key": {"x-advanced": True, "x-display": "hidden", "type": "string",
"title": "API Key (deprecated)"},
"radar_range_miles": {"type": "integer", "default": 75, "minimum": 10,
"maximum": 500, "title": "Radar Range (miles)"},
"radar_zoom": {"x-advanced": True, "x-display": "hidden", "type": "integer",
"default": 6, "minimum": 4, "maximum": 8,
"title": "Radar Zoom Level (deprecated)"},
"radar_show_nowcast": {"type": "boolean", "default": True,
"title": "Show Forecast Frames"},
},
}
WEATHER_STORED = {"enabled": True, "location_city": "Tampa", "update_interval": 900,
"api_key": "legacy-owm-key", "radar_range_miles": 75,
"radar_zoom": 7, "radar_show_nowcast": True}
def test_countdown_ids_survive_a_form_save(tmp_path):
html = _render(COUNTDOWN_SCHEMA, COUNTDOWN_STORED, "countdown")
assert ">ID<" not in html
assert "countdowns.0.id" not in _visible_names(html)
_post, _json, restore = _make_post(tmp_path, COUNTDOWN_SCHEMA,
COUNTDOWN_STORED, "countdown")
try:
# Edit a visible cell too.
pairs = [(k, "Beach" if k == "countdowns.0.name" else v)
for k, v in _form_pairs(html)]
resp, cfg = _post(pairs)
finally:
restore()
assert resp.status_code == 200, resp.get_json()
assert [c["id"] for c in cfg["countdowns"]] == ["cd_0123456789ab", "42"]
assert cfg["countdowns"][0]["name"] == "Beach"
def test_weather_deprecated_keys_are_hidden_and_kept(tmp_path):
html = _render(WEATHER_SCHEMA, WEATHER_STORED, "weather")
assert "api_key" not in html and "radar_zoom" not in html
assert "Radar Zoom Level" not in html and "API Key" not in html
assert re.findall(r"Advanced Settings \((\d+)\)", html) == ["1"]
assert "radar_range_miles" in _visible_names(html)
_post, _json, restore = _make_post(tmp_path, WEATHER_SCHEMA,
WEATHER_STORED, "weather")
try:
pairs = [(k, v) for k, v in _form_pairs(html)
if k != "radar_show_nowcast"]
resp, cfg = _post(pairs)
finally:
restore()
assert resp.status_code == 200, resp.get_json()
assert cfg["api_key"] == "legacy-owm-key"
assert cfg["radar_zoom"] == 7
assert cfg["radar_show_nowcast"] is False
@@ -1039,6 +1039,51 @@ def _set_nested_value(config, key_path, value):
_RENDERED_SECTION_FIELD = '__rendered_section'
def _is_hidden_prop(prop_schema):
"""Whether a schema property is declared ``"x-display": "hidden"``.
Mirrors ``prop_is_hidden`` in ``plugin_config.html``: an object whose every
child is hidden counts as hidden too. A hidden property stays declared (so
stored configs carrying it keep validating) but the form draws no control
for it, which puts it outside everything the save infers from the form.
"""
if not isinstance(prop_schema, dict):
return False
if prop_schema.get('x-display') == 'hidden':
return True
children = prop_schema.get('properties')
if isinstance(children, dict) and children:
return all(_is_hidden_prop(child) for child in children.values())
return False
def _hidden_array_item_property(schema, key_path):
"""The hidden schema property a posted array-row key names, else None.
Only paths through an array index (``countdowns.0.id``) qualify: that is
the one place the rendered form posts a hidden value, because a posted row
replaces the stored item wholesale. The value is JSON-encoded there so its
exact type survives the round trip.
"""
parts = key_path.split('.')
digit_at = next((i for i, p in enumerate(parts) if p.isdigit()), None)
if digit_at is None or digit_at == 0 or digit_at == len(parts) - 1:
return None
array_prop = _get_schema_property(schema, '.'.join(parts[:digit_at]))
if not _schema_type_is(array_prop, 'array'):
return None
items = array_prop.get('items')
if not isinstance(items, dict) or not isinstance(items.get('properties'), dict):
return None
rest = '.'.join(parts[digit_at + 1:])
item_schema = {'properties': items['properties']}
prop = _get_schema_property(item_schema, rest)
if prop is None:
# Deeper array inside the item (rare): keep walking.
return _hidden_array_item_property(item_schema, rest)
return prop if _is_hidden_prop(prop) else None
def _boolean_is_in_scope(full_path, prefix, sections, submitted_parents):
"""Whether a missing checkbox at ``full_path`` may be forced to False.
@@ -1112,6 +1157,12 @@ def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefi
if not isinstance(prop_schema, dict):
continue
# A hidden property (or an object of nothing but hidden ones) was never
# drawn, so its absence from the form is not an unchecked box. Leave it
# -- and everything under it -- exactly as stored.
if _is_hidden_prop(prop_schema):
continue
full_path = f"{prefix}.{prop_name}" if prefix else prop_name
prop_type = prop_schema.get('type')
+16 -2
View File
@@ -8,7 +8,7 @@ from web_interface.blueprints.api_v3 import (
_CALENDAR_LIST_MAX_PAGES, _RENDERED_SECTION_FIELD, _SKIP_FIELD, _coerce_to_bool,
_do_transactional_uninstall, _enhance_schema_with_core_properties,
_filter_config_by_schema, _get_plugin_version, _get_schema_property,
_installed_plugin_ids, _is_plugin_update_available,
_hidden_array_item_property, _installed_plugin_ids, _is_plugin_update_available,
_parse_form_value_with_schema, _prune_credential_backups,
_run_calendar_registration, _schema_allows_null, _schema_type_is,
_set_missing_booleans_to_false,
@@ -1848,7 +1848,21 @@ def save_plugin_config():
# (to avoid overwriting the combined array with a single value)
if key not in indexed_base_paths:
# Parse value using schema to determine correct type
parsed_value = _parse_form_value_with_schema(value, key, schema)
parsed_value = _SKIP_FIELD
decoded = False
# A hidden property inside an array row is carried
# through the form JSON-encoded (a posted row replaces
# the stored item, so it must be posted at all). Decode
# it exactly: the generic parse would turn an id "1"
# into the integer 1 and fail validation.
if _hidden_array_item_property(schema, key) is not None:
try:
parsed_value = json.loads(value)
decoded = True
except (TypeError, ValueError):
pass
if not decoded:
parsed_value = _parse_form_value_with_schema(value, key, schema)
# Debug logging for array fields
if schema:
prop = _get_schema_property(schema, key)
@@ -111,6 +111,37 @@
// ─── Helpers ────────────────────────────────────────────────────────────
/**
* "x-display": "hidden" — the property stays in the schema but gets no
* control. An object whose every child is hidden counts as hidden too.
* Mirrors prop_is_hidden in plugin_config.html and _is_hidden_prop on the
* server.
*/
function isHiddenProp(schema) {
if (!schema || typeof schema !== 'object') return false;
if (schema['x-display'] === 'hidden') return true;
const children = schema.properties;
if (children && typeof children === 'object') {
const values = Object.values(children);
return values.length > 0 && values.every(isHiddenProp);
}
return false;
}
/**
* A hidden input carrying a hidden property's stored value, JSON-encoded so
* the save restores its exact type. A posted row replaces the stored item,
* so a value that isn't posted is lost.
*/
function hiddenValueInput(name, dataKey, value) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = JSON.stringify(value);
input.dataset.hiddenProp = dataKey;
return input;
}
function safeSetHTML(target, html) {
target.textContent = '';
// createContextualFragment parses html relative to the document context
@@ -343,6 +374,16 @@
if (propType === 'object' && propSchema.properties) {
const nestedVal = (item && item[propName]) || {};
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
if (isHiddenProp(subSchema)) {
// No data-nested-prop, so the row editor never shows it.
const stored = Object.entries(nestedVal).find(([name]) => name === subName);
if (stored && stored[1] !== undefined && stored[1] !== null) {
cell.appendChild(hiddenValueInput(
`${fullKey}.${index}.${propName}.${subName}`,
`${propName}.${subName}`, stored[1]));
}
return;
}
const subType = Array.isArray(subSchema.type)
? subSchema.type.find(t => t !== 'null') || 'string'
: (subSchema.type || 'string');
@@ -386,7 +427,16 @@
row.className = 'array-table-row';
row.setAttribute('data-index', index);
// "x-display": "hidden" item properties: never a column (even if
// x-columns names one), never in the advanced cell or row editor.
const hiddenKeys = new Set(
Object.entries(fullItemProperties)
.filter(([, propSchema]) => isHiddenProp(propSchema))
.map(([propName]) => propName)
);
// Visible column cells
displayColumns = displayColumns.filter(colName => !hiddenKeys.has(colName));
displayColumns.forEach(colName => {
const colDef = itemProperties[colName] || {};
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
@@ -396,10 +446,11 @@
row.appendChild(createCell(fullKey, index, colName, colDef, colValue, pluginId));
});
// Determine non-displayed properties (these go into the advanced cell + edit modal)
// Determine non-displayed properties (these go into the advanced cell + edit modal).
// Hidden ones ("x-display": "hidden") get no column and no editor field.
const nonDisplayed = {};
Object.keys(fullItemProperties).forEach(k => {
if (!displayColumns.includes(k) && k !== 'id') {
if (!displayColumns.includes(k) && k !== 'id' && !hiddenKeys.has(k)) {
nonDisplayed[k] = fullItemProperties[k];
}
});
@@ -430,6 +481,14 @@
actionsCell.appendChild(editBtn);
}
// Hidden properties: carry stored values only. A new row has none, so
// nothing is invented (countdown fills in its own id).
Object.entries(item).forEach(([propName, stored]) => {
if (hiddenKeys.has(propName) && stored !== undefined && stored !== null) {
actionsCell.appendChild(hiddenValueInput(`${fullKey}.${index}.${propName}`, propName, stored));
}
});
row.appendChild(actionsCell);
// Hidden advanced data cell
@@ -494,7 +553,8 @@
grid.className = 'grid grid-cols-2 gap-3';
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
const subType = Array.isArray(subSchema.type) ? subSchema.type.find(t => t !== 'null') || 'string' : (subSchema.type || 'string');
if (isHiddenProp(subSchema)) return;
const subType =Array.isArray(subSchema.type) ? subSchema.type.find(t => t !== 'null') || 'string' : (subSchema.type || 'string');
const subLabel = subSchema.title || subName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const subDesc = subSchema.description || '';
const nestedPath = `${propName}.${subName}`;
@@ -2409,6 +2409,19 @@ function getSchemaProperty(schema, path) {
return null;
}
// "x-display": "hidden" (or an object of nothing but hidden children).
// Mirrors prop_is_hidden in plugin_config.html.
function isHiddenSchemaProp(schema) {
if (!schema || typeof schema !== 'object') return false;
if (schema['x-display'] === 'hidden') return true;
const children = schema.properties;
if (children && typeof children === 'object') {
const values = Object.values(children);
return values.length > 0 && values.every(isHiddenSchemaProp);
}
return false;
}
// Helper function to render a single item in an array of objects
function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) {
const item = itemValue || {};
@@ -2422,6 +2435,9 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde
const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties);
propertyOrder.forEach(propKey => {
if (!itemProperties[propKey]) return;
// "x-display": "hidden": no control. The stored value survives through
// data-item-data, which updateArrayObjectData overlays edits onto.
if (isHiddenSchemaProp(itemProperties[propKey])) return;
const propSchema = itemProperties[propKey];
const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default;
@@ -5478,6 +5494,7 @@ if (typeof window !== 'undefined') {
itemHtml = `<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${newIndex}">`;
Object.keys(itemsSchema.properties || {}).forEach(propKey => {
const propSchema = itemsSchema.properties[propKey];
if (propSchema && propSchema['x-display'] === 'hidden') return;
const propValue = newItem[propKey] !== undefined ? newItem[propKey] : propSchema.default;
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
itemHtml += `<div class="mb-3"><label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
@@ -6,7 +6,29 @@
{# ===== MACROS FOR FORM FIELD GENERATION ===== #}
{# Render a single form field based on schema type #}
{# "x-display": "hidden" keeps a property declared -- so stored configs that
still carry it keep validating -- without drawing a control for it
(deprecated keys, internal ids). An object whose every child is hidden
counts as hidden too, so it doesn't render as an empty section. Outputs
"1" when hidden, nothing otherwise; test it with `|trim`.
Nothing hidden is posted for a plain or nested field: the save deep-merges
the form over the stored section, so the stored value stays. Array rows are
the exception (a posted list replaces the stored one), see array-table. #}
{% macro prop_is_hidden(prop) -%}
{%- if prop is mapping -%}
{%- if prop.get('x-display') == 'hidden' -%}1
{%- elif prop.get('properties') is mapping and prop.get('properties') -%}
{%- set _hid = namespace(all=true) -%}
{%- for _child in prop.get('properties').values() -%}
{%- if not prop_is_hidden(_child)|trim -%}{%- set _hid.all = false -%}{%- endif -%}
{%- endfor -%}
{%- if _hid.all -%}1{%- endif -%}
{%- endif -%}
{%- endif -%}
{%- endmacro %}
{% macro render_field(key, prop, value, prefix='', plugin_id='') %}
{% if not prop_is_hidden(prop)|trim %}
{% set full_key = (prefix ~ '.' ~ key) if prefix else key %}
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
@@ -660,14 +682,20 @@
{% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %}
{# Use x-columns if specified, otherwise auto-detect first 4 simple properties #}
{# Hidden item properties never become columns (see prop_is_hidden). #}
{% set x_columns = prop.get('x-columns') %}
{% if x_columns %}
{% set display_columns = x_columns %}
{% set display_columns = [] %}
{% for col_name in x_columns %}
{% if not prop_is_hidden(item_properties.get(col_name))|trim %}
{% set _ = display_columns.append(col_name) %}
{% endif %}
{% endfor %}
{% else %}
{% set display_columns = [] %}
{% for col_name in item_properties.keys() %}
{% set col_def = item_properties[col_name] %}
{% if col_def.get('type') not in ['object', 'array'] and display_columns|length < 4 %}
{% if col_def.get('type') not in ['object', 'array'] and display_columns|length < 4 and not prop_is_hidden(col_def)|trim %}
{% set _ = display_columns.append(col_name) %}
{% endif %}
{% endfor %}
@@ -814,8 +842,16 @@
{# Actions cell: delete + optional edit button for advanced props #}
{% set has_advanced = namespace(value=false) %}
{% for k in item_properties.keys() %}{% if k not in display_columns and k != 'id' %}{% set has_advanced.value = true %}{% endif %}{% endfor %}
{% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' and not prop_is_hidden(v)|trim %}{% set has_advanced.value = true %}{% endif %}{% endfor %}
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
{# Hidden item properties (e.g. countdown's auto-generated id) get
no control, but a posted row replaces the stored item wholesale,
so carry the stored value through. JSON-encoded so the save can
restore its exact type (an id "1" must stay a string); absent
values are not invented. #}
{% for k, v in item_properties.items() %}{% if prop_is_hidden(v)|trim and k in item and item[k] is not none %}
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ k }}" data-hidden-prop="{{ k }}" value='{{ item[k]|tojson }}'>
{% endif %}{% endfor %}
<button type="button"
onclick="removeArrayTableRow(this)"
aria-label="Remove row {{ item_index + 1 }}"
@@ -836,13 +872,23 @@
{# Hidden cell: flat hidden inputs for non-displayed props (layout, style, etc.) #}
{% if has_advanced.value %}
{% set adv_schema = namespace(d={}) %}
{% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' %}{% set _ = adv_schema.d.update({k: v}) %}{% endif %}{% endfor %}
{% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' and not prop_is_hidden(v)|trim %}{% set _ = adv_schema.d.update({k: v}) %}{% endif %}{% endfor %}
<td style="display:none" class="array-table-advanced-data"
data-prop-schema='{{ adv_schema.d|tojson }}'>
{% for prop_name, prop_schema in adv_schema.d.items() %}
{% set prop_type = prop_schema.get('type', 'string') %}
{% if prop_type == 'object' and prop_schema.get('properties') %}
{% for sub_name, sub_schema in prop_schema.get('properties', {}).items() %}
{% set _sub_obj = item.get(prop_name) if item.get(prop_name) is mapping else {} %}
{% if prop_is_hidden(sub_schema)|trim %}
{# Kept out of the row editor (no data-nested-prop); stored value only. #}
{% if sub_name in _sub_obj and _sub_obj[sub_name] is not none %}
<input type="hidden"
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}.{{ sub_name }}"
data-hidden-prop="{{ prop_name }}.{{ sub_name }}"
value='{{ _sub_obj[sub_name]|tojson }}'>
{% endif %}
{% else %}
{% set sub_val = item.get(prop_name, {}).get(sub_name) %}
{% set sub_default = sub_schema.get('default') %}
{% set final_val = sub_val if sub_val is not none else sub_default %}
@@ -852,6 +898,7 @@
data-prop-type="{{ sub_schema.get('type', 'string') }}"
data-prop-schema='{{ sub_schema|tojson }}'
value="{{ final_val if final_val is not none else '' }}">
{% endif %}
{% endfor %}
{% else %}
{% set prop_val = item.get(prop_name) %}
@@ -1104,6 +1151,7 @@
{% endif %}
</div>
{% endif %}
{% endif %}{# not prop_is_hidden #}
{% endmacro %}
{# Render a nested/collapsible section for object types #}
@@ -1142,7 +1190,7 @@
<div id="{{ section_id }}" class="nested-content bg-gray-50 px-4 py-4 space-y-3 hidden" style="display: none;">
{% set property_order = prop['x-propertyOrder'] if 'x-propertyOrder' in prop else prop.properties.keys()|list %}
{% for nested_key in property_order %}
{% if nested_key in prop.properties %}
{% if nested_key in prop.properties and not prop_is_hidden(prop.properties[nested_key])|trim %}
{% set nested_prop = prop.properties[nested_key] %}
{% set nested_val = nested_value[nested_key] if nested_key in nested_value else none %}
{# Tagged so a widget that takes over part of a block can
@@ -1295,7 +1343,10 @@
them. Schemas without the flag render exactly as before. #}
{% set tiers = namespace(basic=[], advanced=[]) %}
{% for key in property_order %}
{% if key in schema.properties and key != 'enabled' %}
{# A hidden key is neither drawn nor reported in
__rendered_section, so the save never reads its
absence as an unchecked box. #}
{% if key in schema.properties and key != 'enabled' and not prop_is_hidden(schema.properties[key])|trim %}
{% set prop = schema.properties[key] %}
{% set is_object = prop.type is defined and 'object' in prop.type %}
{% if prop.get('x-advanced') and not is_object %}