mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
Fix unchecked boolean checkboxes not saving as false (#216)
* fix(web): ensure unchecked boolean checkboxes save as false HTML checkboxes don't submit values when unchecked. The plugin config save endpoint starts from existing config (for partial updates), so an unchecked checkbox's old `true` value persists. Additionally, merge_with_defaults fills in schema defaults for missing fields, causing booleans with `"default": true` to always re-enable. This affected the odds-ticker plugin where NFL/NBA leagues (default: true) could not be disabled via the checkbox UI, while NHL (default: false) appeared to work by coincidence. Changes: - Add _set_missing_booleans_to_false() that walks the schema after form processing and sets any boolean field absent from form data to false - Add value="true" to boolean checkboxes so checked state sends "true" instead of "on" (proper boolean parsing) - Handle "on"/"off" strings in _parse_form_value_with_schema for backwards compatibility with checkboxes lacking value="true" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(web): guard on/off coercion to boolean schema types, handle arrays - Only coerce "on"/"off" strings to booleans when the schema type is boolean; "true"/"false" remain unconditional - Extend _set_missing_booleans_to_false to recurse into arrays of objects (e.g. custom_feeds.0.enabled) by discovering item indices from submitted form keys and recursing per-index Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(web): preserve array structures when setting missing booleans _set_nested_value uses dict-style access for all path segments, which corrupts lists when paths contain numeric array indices (e.g. "feeds.custom_feeds.0.enabled"). Refactored _set_missing_booleans_to_false to: - Accept an optional config_node parameter for direct array item access - When inside an array item, set booleans directly on the item dict - Navigate to array lists manually, preserving their list type - Ensure array items exist as dicts before recursing This prevents array-of-object configs (like custom_feeds) from being converted to nested dicts with numeric string keys. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3093,6 +3093,12 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
return True
|
||||
if stripped.lower() == 'false':
|
||||
return False
|
||||
# "on"/"off" come from HTML checkboxes — only coerce when schema says boolean
|
||||
if prop and prop.get('type') == 'boolean':
|
||||
if stripped.lower() == 'on':
|
||||
return True
|
||||
if stripped.lower() == 'off':
|
||||
return False
|
||||
|
||||
# Handle arrays based on schema
|
||||
if prop and prop.get('type') == 'array':
|
||||
@@ -3194,6 +3200,115 @@ def _set_nested_value(config, key_path, value):
|
||||
current[parts[-1]] = value
|
||||
|
||||
|
||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
||||
"""Walk schema and set missing boolean form fields to False.
|
||||
|
||||
HTML checkboxes don't submit values when unchecked. When saving plugin config,
|
||||
the backend starts from existing config (to support partial form updates), which
|
||||
means an unchecked checkbox's old ``True`` value persists. This function detects
|
||||
boolean schema properties not present in the form submission and explicitly sets
|
||||
them to ``False``.
|
||||
|
||||
The top-level ``enabled`` field is excluded because it has its own preservation
|
||||
logic in the save endpoint.
|
||||
|
||||
Handles boolean fields inside nested objects and inside arrays of objects
|
||||
(e.g. ``feeds.custom_feeds.0.enabled``).
|
||||
|
||||
Args:
|
||||
config: The root plugin config dict (used for pure-dict paths)
|
||||
schema_props: Schema ``properties`` dict at the current nesting level
|
||||
form_keys: Set of form field names that were submitted
|
||||
prefix: Dot-notation prefix for the current nesting level
|
||||
config_node: The current config subtree when inside an array item (avoids
|
||||
using _set_nested_value which corrupts lists)
|
||||
"""
|
||||
# Determine which config node to operate on
|
||||
node = config_node if config_node is not None else config
|
||||
|
||||
for prop_name, prop_schema in schema_props.items():
|
||||
if not isinstance(prop_schema, dict):
|
||||
continue
|
||||
|
||||
full_path = f"{prefix}.{prop_name}" if prefix else prop_name
|
||||
prop_type = prop_schema.get('type')
|
||||
|
||||
if prop_type == 'boolean' and full_path != 'enabled':
|
||||
# If this boolean wasn't submitted in the form, it's an unchecked checkbox
|
||||
if full_path not in form_keys:
|
||||
if config_node is not None:
|
||||
# Inside an array item — set directly on the item dict
|
||||
node[prop_name] = False
|
||||
else:
|
||||
# Pure dict path — use helper
|
||||
_set_nested_value(config, full_path, False)
|
||||
|
||||
elif prop_type == 'object' and 'properties' in prop_schema:
|
||||
# Recurse into nested objects
|
||||
if config_node is not None:
|
||||
# Inside an array item — ensure nested dict exists in item
|
||||
if prop_name not in node or not isinstance(node[prop_name], dict):
|
||||
node[prop_name] = {}
|
||||
_set_missing_booleans_to_false(
|
||||
config, prop_schema['properties'], form_keys, full_path,
|
||||
config_node=node[prop_name]
|
||||
)
|
||||
else:
|
||||
_set_missing_booleans_to_false(
|
||||
config, prop_schema['properties'], form_keys, full_path
|
||||
)
|
||||
|
||||
elif prop_type == 'array':
|
||||
# Handle arrays of objects that may contain boolean fields
|
||||
# Form keys use indexed notation: "path.0.field", "path.1.field"
|
||||
items_schema = prop_schema.get('items', {})
|
||||
if isinstance(items_schema, dict) and items_schema.get('type') == 'object' and 'properties' in items_schema:
|
||||
array_prefix = f"{full_path}."
|
||||
# Collect unique item indices from submitted form keys
|
||||
indices = set()
|
||||
for k in form_keys:
|
||||
if k.startswith(array_prefix):
|
||||
# Extract index: "path.0.field" -> "0"
|
||||
rest = k[len(array_prefix):]
|
||||
idx = rest.split('.', 1)[0]
|
||||
if idx.isdigit():
|
||||
indices.add(int(idx))
|
||||
|
||||
if not indices:
|
||||
continue
|
||||
|
||||
# Navigate to the array in the config (create if missing)
|
||||
if config_node is not None:
|
||||
if prop_name not in node or not isinstance(node[prop_name], list):
|
||||
node[prop_name] = []
|
||||
array_list = node[prop_name]
|
||||
else:
|
||||
# Navigate from root config through dict keys to get the list
|
||||
parts = full_path.split('.')
|
||||
current = config
|
||||
for part in parts[:-1]:
|
||||
if part not in current or not isinstance(current[part], dict):
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
arr_key = parts[-1]
|
||||
if arr_key not in current or not isinstance(current[arr_key], list):
|
||||
current[arr_key] = []
|
||||
array_list = current[arr_key]
|
||||
|
||||
# Recurse into each array item so its missing booleans get set to False
|
||||
for idx in indices:
|
||||
# Ensure list is long enough and item is a dict
|
||||
while len(array_list) <= idx:
|
||||
array_list.append({})
|
||||
if not isinstance(array_list[idx], dict):
|
||||
array_list[idx] = {}
|
||||
item_prefix = f"{full_path}.{idx}"
|
||||
_set_missing_booleans_to_false(
|
||||
config, items_schema['properties'], form_keys, item_prefix,
|
||||
config_node=array_list[idx]
|
||||
)
|
||||
|
||||
|
||||
def _enhance_schema_with_core_properties(schema):
|
||||
"""
|
||||
Enhance schema with core plugin properties (enabled, display_duration, live_priority).
|
||||
@@ -3679,6 +3794,13 @@ def save_plugin_config():
|
||||
feeds_config['custom_feeds'] = [custom_feeds_dict[k] for k in sorted_keys]
|
||||
logger.info(f"Force-converted feeds.custom_feeds from dict to array: {len(feeds_config['custom_feeds'])} items")
|
||||
|
||||
# Fix unchecked boolean checkboxes: HTML checkboxes don't submit values
|
||||
# when unchecked, so the existing config value (potentially True) persists.
|
||||
# Walk the schema and set any boolean fields missing from form data to False.
|
||||
if schema and 'properties' in schema:
|
||||
form_keys = set(request.form.keys())
|
||||
_set_missing_booleans_to_false(plugin_config, schema['properties'], form_keys)
|
||||
|
||||
# Get schema manager instance (for JSON requests)
|
||||
schema_mgr = api_v3.schema_manager
|
||||
if not schema_mgr:
|
||||
|
||||
@@ -112,11 +112,12 @@
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
{# Default checkbox #}
|
||||
{# Default checkbox - value="true" ensures checked sends "true" not "on" #}
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox"
|
||||
id="{{ field_id }}"
|
||||
name="{{ full_key }}"
|
||||
value="true"
|
||||
{% if value %}checked{% endif %}
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<span class="ml-2 text-sm text-gray-600">Enabled</span>
|
||||
|
||||
Reference in New Issue
Block a user