mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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:
@@ -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')
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user