fix: handle dotted schema keys in plugin settings save (#295)

* fix: handle dotted schema keys in plugin settings save (issue #254)

The soccer plugin uses dotted keys like "eng.1" for league identifiers.
PR #260 fixed backend helpers but the JS frontend still corrupted these
keys by naively splitting on dots. This fixes both the JS and remaining
Python code paths:

- JS getSchemaProperty(): greedy longest-match for dotted property names
- JS dotToNested(): schema-aware key grouping to preserve "eng.1" as one key
- Python fix_array_structures(): remove broken prefix re-navigation in recursion
- Python ensure_array_defaults(): same prefix navigation fix

Closes #254

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings for dotted-key handling

- ensure_array_defaults: replace None nodes with {} so recursion
  proceeds into nested objects (was skipping when key existed as None)
- dotToNested: add tail-matching that checks the full remaining dotted
  tail against the current schema level before greedy intermediate
  matching, preventing leaf dotted keys from being split
- syncFormToJson: replace naive key.split('.') reconstruction with
  dotToNested(flatConfig, schema) and schema-aware getSchemaProperty()
  so the JSON tab save path produces the same correct nesting as the
  form submit path
- Add regression tests for dotted-key array normalization and None
  array default replacement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address second round of review findings

- Tests: replace conditional `if response.status_code == 200` guards
  with unconditional `assert response.status_code == 200` so failures
  are not silently swallowed
- dotToNested: guard finalKey write with `if (i < parts.length)` to
  prevent empty-string key pollution when tail-matching consumed all
  parts
- Extract normalizeFormDataForConfig() helper from handlePluginConfigSubmit
  and call it from both handlePluginConfigSubmit and syncFormToJson so
  the JSON tab sync uses the same robust FormData processing (including
  _data JSON inputs, bracket-notation checkboxes, array-of-objects,
  file-upload widgets, checkbox DOM detection, and unchecked boolean
  handling via collectBooleanFields)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-03-27 11:12:31 -04:00
committed by GitHub
parent 2c2fca2219
commit 6eccb74415
3 changed files with 340 additions and 316 deletions

View File

@@ -583,3 +583,130 @@ class TestAPIErrorHandling:
response = client.get('/api/v3/display/on-demand/start') response = client.get('/api/v3/display/on-demand/start')
assert response.status_code in [200, 405] # Depends on implementation assert response.status_code in [200, 405] # Depends on implementation
class TestDottedKeyNormalization:
"""Regression tests for fix_array_structures / ensure_array_defaults with dotted schema keys."""
def test_save_plugin_config_dotted_key_arrays(self, client, mock_config_manager):
"""Nested dotted-key objects with numeric-keyed dicts are converted to arrays."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
mock_config_manager.load_config.return_value = {}
schema_mgr = MagicMock()
schema = {
'type': 'object',
'properties': {
'leagues': {
'type': 'object',
'properties': {
'eng.1': {
'type': 'object',
'properties': {
'enabled': {'type': 'boolean', 'default': True},
'favorite_teams': {
'type': 'array',
'items': {'type': 'string'},
'default': [],
},
},
},
},
},
},
}
schema_mgr.load_schema.return_value = schema
schema_mgr.generate_default_config.return_value = {
'leagues': {'eng.1': {'enabled': True, 'favorite_teams': []}},
}
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
schema_mgr.validate_config_against_schema.return_value = []
api_v3.schema_manager = schema_mgr
request_data = {
'plugin_id': 'soccer-scoreboard',
'config': {
'leagues': {
'eng.1': {
'enabled': True,
'favorite_teams': ['Arsenal', 'Chelsea'],
},
},
},
}
response = client.post(
'/api/v3/plugins/config',
data=json.dumps(request_data),
content_type='application/json',
)
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
saved = mock_config_manager.save_config_atomic.call_args[0][0]
soccer_cfg = saved.get('soccer-scoreboard', {})
leagues = soccer_cfg.get('leagues', {})
assert 'eng.1' in leagues, f"Expected 'eng.1' key, got: {list(leagues.keys())}"
assert isinstance(leagues['eng.1'].get('favorite_teams'), list)
assert leagues['eng.1']['favorite_teams'] == ['Arsenal', 'Chelsea']
def test_save_plugin_config_none_array_gets_default(self, client, mock_config_manager):
"""None array fields under dotted-key parents are replaced with defaults."""
from web_interface.blueprints.api_v3 import api_v3
api_v3.config_manager = mock_config_manager
mock_config_manager.load_config.return_value = {}
schema_mgr = MagicMock()
schema = {
'type': 'object',
'properties': {
'leagues': {
'type': 'object',
'properties': {
'eng.1': {
'type': 'object',
'properties': {
'favorite_teams': {
'type': 'array',
'items': {'type': 'string'},
'default': [],
},
},
},
},
},
},
}
schema_mgr.load_schema.return_value = schema
schema_mgr.generate_default_config.return_value = {
'leagues': {'eng.1': {'favorite_teams': []}},
}
schema_mgr.merge_with_defaults.side_effect = lambda config, defaults: {**defaults, **config}
schema_mgr.validate_config_against_schema.return_value = []
api_v3.schema_manager = schema_mgr
request_data = {
'plugin_id': 'soccer-scoreboard',
'config': {
'leagues': {
'eng.1': {
'favorite_teams': None,
},
},
},
}
response = client.post(
'/api/v3/plugins/config',
data=json.dumps(request_data),
content_type='application/json',
)
assert response.status_code == 200, f"Expected 200, got {response.status_code}: {response.data}"
saved = mock_config_manager.save_config_atomic.call_args[0][0]
soccer_cfg = saved.get('soccer-scoreboard', {})
teams = soccer_cfg.get('leagues', {}).get('eng.1', {}).get('favorite_teams')
assert isinstance(teams, list), f"Expected list, got: {type(teams)}"
assert teams == [], f"Expected empty default list, got: {teams}"

View File

@@ -4024,225 +4024,100 @@ def save_plugin_config():
# Post-process: Fix array fields that might have been incorrectly structured # Post-process: Fix array fields that might have been incorrectly structured
# This handles cases where array fields are stored as dicts (e.g., from indexed form fields) # This handles cases where array fields are stored as dicts (e.g., from indexed form fields)
def fix_array_structures(config_dict, schema_props, prefix=''): def fix_array_structures(config_dict, schema_props):
"""Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)""" """Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues).
config_dict is always the dict at the current nesting level."""
for prop_key, prop_schema in schema_props.items(): for prop_key, prop_schema in schema_props.items():
prop_type = prop_schema.get('type') prop_type = prop_schema.get('type')
if prop_type == 'array': if prop_type == 'array':
# Navigate to the field location if prop_key in config_dict:
if prefix: current_value = config_dict[prop_key]
parent_parts = prefix.split('.') # If it's a dict with numeric string keys, convert to array
parent = config_dict if isinstance(current_value, dict) and not isinstance(current_value, list):
for part in parent_parts: try:
if isinstance(parent, dict) and part in parent: keys = list(current_value.keys())
parent = parent[part] if keys and all(str(k).isdigit() for k in keys):
else: sorted_keys = sorted(keys, key=lambda x: int(str(x)))
parent = None array_value = [current_value[k] for k in sorted_keys]
break # Convert array elements to correct types based on schema
items_schema = prop_schema.get('items', {})
if parent is not None and isinstance(parent, dict) and prop_key in parent: item_type = items_schema.get('type')
current_value = parent[prop_key] if item_type in ('number', 'integer'):
# If it's a dict with numeric string keys, convert to array converted_array = []
if isinstance(current_value, dict) and not isinstance(current_value, list): for v in array_value:
try: if isinstance(v, str):
# Check if all keys are numeric strings (array indices) try:
keys = [k for k in current_value.keys()] if item_type == 'integer':
if all(k.isdigit() for k in keys): converted_array.append(int(v))
# Convert to sorted array by index else:
sorted_keys = sorted(keys, key=int) converted_array.append(float(v))
array_value = [current_value[k] for k in sorted_keys] except (ValueError, TypeError):
# Convert array elements to correct types based on schema
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in array_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v)
else:
converted_array.append(v) converted_array.append(v)
array_value = converted_array else:
parent[prop_key] = array_value
current_value = array_value # Update for length check below
except (ValueError, KeyError, TypeError):
# Conversion failed, check if we should use default
pass
# If it's an array, ensure correct types and check minItems
if isinstance(current_value, list):
# First, ensure array elements are correct types
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in current_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v) converted_array.append(v)
else: array_value = converted_array
config_dict[prop_key] = array_value
current_value = array_value # Update for length check below
except (ValueError, KeyError, TypeError) as e:
logger.debug(f"Failed to convert {prop_key} to array: {e}")
# If it's an array, ensure correct types and check minItems
if isinstance(current_value, list):
# First, ensure array elements are correct types
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in current_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v) converted_array.append(v)
parent[prop_key] = converted_array else:
current_value = converted_array converted_array.append(v)
config_dict[prop_key] = converted_array
current_value = converted_array
# Then check minItems # Then check minItems
min_items = prop_schema.get('minItems') min_items = prop_schema.get('minItems')
if min_items is not None and len(current_value) < min_items: if min_items is not None and len(current_value) < min_items:
# Use default if available, otherwise keep as-is (validation will catch it) default = prop_schema.get('default')
default = prop_schema.get('default') if default and isinstance(default, list) and len(default) >= min_items:
if default and isinstance(default, list) and len(default) >= min_items: config_dict[prop_key] = default
parent[prop_key] = default
else:
# Top-level field
if prop_key in config_dict:
current_value = config_dict[prop_key]
# If it's a dict with numeric string keys, convert to array
if isinstance(current_value, dict) and not isinstance(current_value, list):
try:
keys = list(current_value.keys())
if keys and all(str(k).isdigit() for k in keys):
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
array_value = [current_value[k] for k in sorted_keys]
# Convert array elements to correct types based on schema
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in array_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v)
else:
converted_array.append(v)
array_value = converted_array
config_dict[prop_key] = array_value
current_value = array_value # Update for length check below
except (ValueError, KeyError, TypeError) as e:
logger.debug(f"Failed to convert {prop_key} to array: {e}")
pass
# If it's an array, ensure correct types and check minItems
if isinstance(current_value, list):
# First, ensure array elements are correct types
items_schema = prop_schema.get('items', {})
item_type = items_schema.get('type')
if item_type in ('number', 'integer'):
converted_array = []
for v in current_value:
if isinstance(v, str):
try:
if item_type == 'integer':
converted_array.append(int(v))
else:
converted_array.append(float(v))
except (ValueError, TypeError):
converted_array.append(v)
else:
converted_array.append(v)
config_dict[prop_key] = converted_array
current_value = converted_array
# Then check minItems
min_items = prop_schema.get('minItems')
if min_items is not None and len(current_value) < min_items:
default = prop_schema.get('default')
if default and isinstance(default, list) and len(default) >= min_items:
config_dict[prop_key] = default
# Recurse into nested objects # Recurse into nested objects
elif prop_type == 'object' and 'properties' in prop_schema: elif prop_type == 'object' and 'properties' in prop_schema:
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key nested_dict = config_dict.get(prop_key)
if prefix:
parent_parts = prefix.split('.')
parent = config_dict
for part in parent_parts:
if isinstance(parent, dict) and part in parent:
parent = parent[part]
else:
parent = None
break
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
else:
nested_dict = config_dict.get(prop_key)
if isinstance(nested_dict, dict): if isinstance(nested_dict, dict):
fix_array_structures(nested_dict, prop_schema['properties'], nested_prefix) fix_array_structures(nested_dict, prop_schema['properties'])
# Also ensure array fields that are None get converted to empty arrays # Also ensure array fields that are None get converted to empty arrays
def ensure_array_defaults(config_dict, schema_props, prefix=''): def ensure_array_defaults(config_dict, schema_props):
"""Recursively ensure array fields have defaults if None""" """Recursively ensure array fields have defaults if None.
config_dict is always the dict at the current nesting level."""
for prop_key, prop_schema in schema_props.items(): for prop_key, prop_schema in schema_props.items():
prop_type = prop_schema.get('type') prop_type = prop_schema.get('type')
if prop_type == 'array': if prop_type == 'array':
if prefix: if prop_key not in config_dict or config_dict[prop_key] is None:
parent_parts = prefix.split('.') default = prop_schema.get('default', [])
parent = config_dict config_dict[prop_key] = default if default else []
for part in parent_parts:
if isinstance(parent, dict) and part in parent:
parent = parent[part]
else:
parent = None
break
if parent is not None and isinstance(parent, dict):
if prop_key not in parent or parent[prop_key] is None:
default = prop_schema.get('default', [])
parent[prop_key] = default if default else []
else:
if prop_key not in config_dict or config_dict[prop_key] is None:
default = prop_schema.get('default', [])
config_dict[prop_key] = default if default else []
elif prop_type == 'object' and 'properties' in prop_schema: elif prop_type == 'object' and 'properties' in prop_schema:
nested_prefix = f"{prefix}.{prop_key}" if prefix else prop_key nested_dict = config_dict.get(prop_key)
if prefix:
parent_parts = prefix.split('.')
parent = config_dict
for part in parent_parts:
if isinstance(parent, dict) and part in parent:
parent = parent[part]
else:
parent = None
break
nested_dict = parent.get(prop_key) if parent is not None and isinstance(parent, dict) else None
else:
nested_dict = config_dict.get(prop_key)
if nested_dict is None: if nested_dict is None:
if prefix: config_dict[prop_key] = {}
parent_parts = prefix.split('.') nested_dict = config_dict[prop_key]
parent = config_dict
for part in parent_parts:
if part not in parent:
parent[part] = {}
parent = parent[part]
if prop_key not in parent:
parent[prop_key] = {}
nested_dict = parent[prop_key]
else:
if prop_key not in config_dict:
config_dict[prop_key] = {}
nested_dict = config_dict[prop_key]
if isinstance(nested_dict, dict): if isinstance(nested_dict, dict):
ensure_array_defaults(nested_dict, prop_schema['properties'], nested_prefix) ensure_array_defaults(nested_dict, prop_schema['properties'])
if schema and 'properties' in schema: if schema and 'properties' in schema:
# First, fix any dict structures that should be arrays # First, fix any dict structures that should be arrays

View File

@@ -2265,29 +2265,39 @@ window.showPluginConfigModal = function(pluginId, config) {
} }
// Helper function to get the full property object from schema // Helper function to get the full property object from schema
// Uses greedy longest-match to handle schema keys containing dots (e.g., "eng.1")
function getSchemaProperty(schema, path) { function getSchemaProperty(schema, path) {
if (!schema || !schema.properties) return null; if (!schema || !schema.properties) return null;
const parts = path.split('.'); const parts = path.split('.');
let current = schema.properties; let current = schema.properties;
let i = 0;
for (let i = 0; i < parts.length; i++) {
const part = parts[i]; while (i < parts.length) {
if (current && current[part]) { let matched = false;
if (i === parts.length - 1) { // Try progressively longer candidates, longest first
// Last part - return the property for (let j = parts.length; j > i; j--) {
return current[part]; const candidate = parts.slice(i, j).join('.');
} else if (current[part].properties) { if (current && current[candidate]) {
// Navigate into nested object if (j === parts.length) {
current = current[part].properties; // Consumed all remaining parts — done
} else { return current[candidate];
return null; }
if (current[candidate].properties) {
current = current[candidate].properties;
i = j;
matched = true;
break;
} else {
return null; // Can't navigate deeper
}
} }
} else { }
if (!matched) {
return null; return null;
} }
} }
return null; return null;
} }
@@ -2311,23 +2321,70 @@ function escapeCssSelector(str) {
} }
// Helper function to convert dot notation to nested object // Helper function to convert dot notation to nested object
function dotToNested(obj) { // Uses schema-aware greedy matching to preserve dotted keys (e.g., "eng.1")
function dotToNested(obj, schema) {
const result = {}; const result = {};
for (const key in obj) { for (const key in obj) {
const parts = key.split('.'); const parts = key.split('.');
let current = result; let current = result;
let currentSchema = (schema && schema.properties) ? schema.properties : null;
for (let i = 0; i < parts.length - 1; i++) { let i = 0;
if (!current[parts[i]]) {
current[parts[i]] = {}; while (i < parts.length - 1) {
let matched = false;
if (currentSchema) {
// First, check if the full remaining tail is a leaf property
// (e.g., "eng.1" as a complete dotted key with no sub-properties)
const tailCandidate = parts.slice(i).join('.');
if (tailCandidate in currentSchema) {
current[tailCandidate] = obj[key];
matched = true;
i = parts.length; // consumed all parts
break;
}
// Try progressively longer candidates (longest first) to greedily
// match dotted property names like "eng.1"
for (let j = parts.length - 1; j > i; j--) {
const candidate = parts.slice(i, j).join('.');
if (candidate in currentSchema) {
if (!current[candidate]) {
current[candidate] = {};
}
current = current[candidate];
const schemaProp = currentSchema[candidate];
currentSchema = (schemaProp && schemaProp.properties) ? schemaProp.properties : null;
i = j;
matched = true;
break;
}
}
}
if (!matched) {
// No schema match or no schema — use single segment
const part = parts[i];
if (!current[part]) {
current[part] = {};
}
current = current[part];
if (currentSchema) {
const schemaProp = currentSchema[part];
currentSchema = (schemaProp && schemaProp.properties) ? schemaProp.properties : null;
} else {
currentSchema = null;
}
i++;
} }
current = current[parts[i]];
} }
current[parts[parts.length - 1]] = obj[key]; // Set the final key (remaining parts joined — may itself be dotted)
// Skip if tail-matching already consumed all parts and wrote the value
if (i < parts.length) {
const finalKey = parts.slice(i).join('.');
current[finalKey] = obj[key];
}
} }
return result; return result;
} }
@@ -2350,42 +2407,20 @@ function collectBooleanFields(schema, prefix = '') {
return boolFields; return boolFields;
} }
function handlePluginConfigSubmit(e) { /**
e.preventDefault(); * Normalize FormData from a plugin config form into a nested config object.
console.log('Form submitted'); * Handles _data JSON inputs, bracket-notation checkboxes, array-of-objects,
* file-upload widgets, proper checkbox DOM detection, unchecked boolean
if (!currentPluginConfig) { * handling, and schema-aware dotted-key nesting.
showNotification('Plugin configuration not loaded', 'error'); *
return; * @param {HTMLFormElement} form - The form element (needed for checkbox DOM detection)
} * @param {Object|null} schema - The plugin's JSON Schema
* @returns {Object} Nested config object ready for saving
const pluginId = currentPluginConfig.pluginId; */
const schema = currentPluginConfig.schema; function normalizeFormDataForConfig(form, schema) {
const form = e.target;
// Fix invalid hidden fields before submission
// This prevents "invalid form control is not focusable" errors
const allInputs = form.querySelectorAll('input[type="number"]');
allInputs.forEach(input => {
const min = parseFloat(input.getAttribute('min'));
const max = parseFloat(input.getAttribute('max'));
const value = parseFloat(input.value);
if (!isNaN(value)) {
if (!isNaN(min) && value < min) {
input.value = min;
} else if (!isNaN(max) && value > max) {
input.value = max;
}
}
});
const formData = new FormData(form); const formData = new FormData(form);
const flatConfig = {}; const flatConfig = {};
console.log('Schema loaded:', schema ? 'Yes' : 'No');
// Process form data with type conversion (using dot notation for nested fields)
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
// Check if this is a patternProperties or array-of-objects hidden input (contains JSON data) // Check if this is a patternProperties or array-of-objects hidden input (contains JSON data)
// Only match keys ending with '_data' to avoid false positives like 'meta_data_field' // Only match keys ending with '_data' to avoid false positives like 'meta_data_field'
@@ -2397,36 +2432,35 @@ function handlePluginConfigSubmit(e) {
// Only treat as JSON-backed when it's a non-null object (null is typeof 'object' in JavaScript) // Only treat as JSON-backed when it's a non-null object (null is typeof 'object' in JavaScript)
if (jsonValue !== null && typeof jsonValue === 'object') { if (jsonValue !== null && typeof jsonValue === 'object') {
flatConfig[baseKey] = jsonValue; flatConfig[baseKey] = jsonValue;
console.log(`JSON data field ${baseKey}: parsed ${Array.isArray(jsonValue) ? 'array' : 'object'}`, jsonValue);
continue; // Skip normal processing for JSON data fields continue; // Skip normal processing for JSON data fields
} }
} catch (e) { } catch (e) {
// Not valid JSON, continue with normal processing // Not valid JSON, continue with normal processing
} }
} }
// Skip checkbox-group inputs with bracket notation (they're handled by the hidden _data input) // Skip checkbox-group inputs with bracket notation (they're handled by the hidden _data input)
// Pattern: fieldName[] - these are individual checkboxes, actual data is in fieldName_data // Pattern: fieldName[] - these are individual checkboxes, actual data is in fieldName_data
if (key.endsWith('[]')) { if (key.endsWith('[]')) {
continue; continue;
} }
// Skip key_value pair inputs (they're handled by the hidden _data input) // Skip key_value pair inputs (they're handled by the hidden _data input)
if (key.includes('[key_') || key.includes('[value_')) { if (key.includes('[key_') || key.includes('[value_')) {
continue; continue;
} }
// Skip array-of-objects per-item inputs (they're handled by the hidden _data input) // Skip array-of-objects per-item inputs (they're handled by the hidden _data input)
// Pattern: feeds_item_0_name, feeds_item_1_url, etc. // Pattern: feeds_item_0_name, feeds_item_1_url, etc.
if (key.includes('_item_') && /_item_\d+_/.test(key)) { if (key.includes('_item_') && /_item_\d+_/.test(key)) {
continue; continue;
} }
// Try to get schema property - handle both dot notation and underscore notation // Try to get schema property - handle both dot notation and underscore notation
let propSchema = getSchemaPropertyType(schema, key); let propSchema = getSchemaPropertyType(schema, key);
let actualKey = key; let actualKey = key;
let actualValue = value; let actualValue = value;
// If not found with dots, try converting underscores to dots (for nested fields) // If not found with dots, try converting underscores to dots (for nested fields)
if (!propSchema && key.includes('_')) { if (!propSchema && key.includes('_')) {
const dotKey = key.replace(/_/g, '.'); const dotKey = key.replace(/_/g, '.');
@@ -2437,10 +2471,10 @@ function handlePluginConfigSubmit(e) {
actualValue = value; actualValue = value;
} }
} }
if (propSchema) { if (propSchema) {
const propType = propSchema.type; const propType = propSchema.type;
if (propType === 'array') { if (propType === 'array') {
// Check if this is a file upload widget (JSON array) // Check if this is a file upload widget (JSON array)
if (propSchema['x-widget'] === 'file-upload') { if (propSchema['x-widget'] === 'file-upload') {
@@ -2454,11 +2488,10 @@ function handlePluginConfigSubmit(e) {
tempDiv.innerHTML = actualValue; tempDiv.innerHTML = actualValue;
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue; decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
} }
const jsonValue = JSON.parse(decodedValue); const jsonValue = JSON.parse(decodedValue);
if (Array.isArray(jsonValue)) { if (Array.isArray(jsonValue)) {
flatConfig[actualKey] = jsonValue; flatConfig[actualKey] = jsonValue;
console.log(`File upload array field ${actualKey}: parsed JSON array with ${jsonValue.length} items`);
} else { } else {
// Fallback to comma-separated // Fallback to comma-separated
const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : []; const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : [];
@@ -2468,13 +2501,11 @@ function handlePluginConfigSubmit(e) {
// Not JSON, use comma-separated // Not JSON, use comma-separated
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : []; const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
flatConfig[actualKey] = arrayValue; flatConfig[actualKey] = arrayValue;
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
} }
} else { } else {
// Regular array: convert comma-separated string to array // Regular array: convert comma-separated string to array
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : []; const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
flatConfig[actualKey] = arrayValue; flatConfig[actualKey] = arrayValue;
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
} }
} else if (propType === 'integer') { } else if (propType === 'integer') {
flatConfig[actualKey] = parseInt(actualValue, 10); flatConfig[actualKey] = parseInt(actualValue, 10);
@@ -2485,14 +2516,13 @@ function handlePluginConfigSubmit(e) {
// Escape special CSS selector characters in the name // Escape special CSS selector characters in the name
const escapedKey = escapeCssSelector(key); const escapedKey = escapeCssSelector(key);
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
if (formElement) { if (formElement) {
// Element found - use its checked state // Element found - use its checked state
flatConfig[actualKey] = formElement.checked; flatConfig[actualKey] = formElement.checked;
} else { } else {
// Element not found - normalize string booleans and check FormData value // Element not found - normalize string booleans and check FormData value
// Checkboxes send "on" when checked, nothing when unchecked // Checkboxes send "on" when checked, nothing when unchecked
// Normalize string representations of booleans
if (typeof actualValue === 'string') { if (typeof actualValue === 'string') {
const lowerValue = actualValue.toLowerCase().trim(); const lowerValue = actualValue.toLowerCase().trim();
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
@@ -2500,13 +2530,11 @@ function handlePluginConfigSubmit(e) {
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
flatConfig[actualKey] = false; flatConfig[actualKey] = false;
} else { } else {
// Non-empty string that's not a boolean representation - treat as truthy
flatConfig[actualKey] = true; flatConfig[actualKey] = true;
} }
} else if (actualValue === undefined || actualValue === null) { } else if (actualValue === undefined || actualValue === null) {
flatConfig[actualKey] = false; flatConfig[actualKey] = false;
} else { } else {
// Non-string value - coerce to boolean
flatConfig[actualKey] = Boolean(actualValue); flatConfig[actualKey] = Boolean(actualValue);
} }
} }
@@ -2523,10 +2551,9 @@ function handlePluginConfigSubmit(e) {
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = actualValue; tempDiv.innerHTML = actualValue;
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue; decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
const parsed = JSON.parse(decodedValue); const parsed = JSON.parse(decodedValue);
flatConfig[actualKey] = parsed; flatConfig[actualKey] = parsed;
console.log(`No schema for ${actualKey}, but parsed as JSON:`, parsed);
} catch (e) { } catch (e) {
// Not valid JSON, save as string // Not valid JSON, save as string
flatConfig[actualKey] = actualValue; flatConfig[actualKey] = actualValue;
@@ -2535,12 +2562,10 @@ function handlePluginConfigSubmit(e) {
// No schema - try to detect checkbox by finding the element // No schema - try to detect checkbox by finding the element
const escapedKey = escapeCssSelector(key); const escapedKey = escapeCssSelector(key);
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
if (formElement && formElement.type === 'checkbox') { if (formElement && formElement.type === 'checkbox') {
// Found checkbox element - use its checked state
flatConfig[actualKey] = formElement.checked; flatConfig[actualKey] = formElement.checked;
} else { } else {
// Not a checkbox or element not found - normalize string booleans
if (typeof actualValue === 'string') { if (typeof actualValue === 'string') {
const lowerValue = actualValue.toLowerCase().trim(); const lowerValue = actualValue.toLowerCase().trim();
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
@@ -2548,18 +2573,16 @@ function handlePluginConfigSubmit(e) {
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
flatConfig[actualKey] = false; flatConfig[actualKey] = false;
} else { } else {
// Non-empty string that's not a boolean representation - keep as string
flatConfig[actualKey] = actualValue; flatConfig[actualKey] = actualValue;
} }
} else { } else {
// Non-string value - use as-is
flatConfig[actualKey] = actualValue; flatConfig[actualKey] = actualValue;
} }
} }
} }
} }
} }
// Handle unchecked checkboxes (not in FormData) - including nested ones // Handle unchecked checkboxes (not in FormData) - including nested ones
if (schema && schema.properties) { if (schema && schema.properties) {
const allBoolFields = collectBooleanFields(schema); const allBoolFields = collectBooleanFields(schema);
@@ -2569,11 +2592,43 @@ function handlePluginConfigSubmit(e) {
} }
}); });
} }
// Convert dot notation to nested object // Convert dot notation to nested object
const config = dotToNested(flatConfig); return dotToNested(flatConfig, schema);
}
console.log('Flat config:', flatConfig);
function handlePluginConfigSubmit(e) {
e.preventDefault();
console.log('Form submitted');
if (!currentPluginConfig) {
showNotification('Plugin configuration not loaded', 'error');
return;
}
const pluginId = currentPluginConfig.pluginId;
const schema = currentPluginConfig.schema;
const form = e.target;
// Fix invalid hidden fields before submission
// This prevents "invalid form control is not focusable" errors
const allInputs = form.querySelectorAll('input[type="number"]');
allInputs.forEach(input => {
const min = parseFloat(input.getAttribute('min'));
const max = parseFloat(input.getAttribute('max'));
const value = parseFloat(input.value);
if (!isNaN(value)) {
if (!isNaN(min) && value < min) {
input.value = min;
} else if (!isNaN(max) && value > max) {
input.value = max;
}
}
});
const config = normalizeFormDataForConfig(form, schema);
console.log('Nested config to save:', config); console.log('Nested config to save:', config);
// Save the configuration // Save the configuration
@@ -4418,42 +4473,9 @@ function switchPluginConfigView(view) {
function syncFormToJson() { function syncFormToJson() {
const form = document.getElementById('plugin-config-form'); const form = document.getElementById('plugin-config-form');
if (!form) return; if (!form) return;
const formData = new FormData(form);
const config = {};
// Get schema for type conversion
const schema = currentPluginConfigState.schema; const schema = currentPluginConfigState.schema;
const config = normalizeFormDataForConfig(form, schema);
for (let [key, value] of formData.entries()) {
if (key === 'enabled') continue; // Skip enabled, managed separately
// Handle nested keys (dot notation)
const keys = key.split('.');
let current = config;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {};
}
current = current[keys[i]];
}
const finalKey = keys[keys.length - 1];
const prop = schema?.properties?.[finalKey] || (keys.length > 1 ? null : schema?.properties?.[key]);
// Type conversion based on schema
if (prop?.type === 'array') {
current[finalKey] = value.split(',').map(item => item.trim()).filter(item => item.length > 0);
} else if (prop?.type === 'integer' || key === 'display_duration') {
current[finalKey] = parseInt(value) || 0;
} else if (prop?.type === 'number') {
current[finalKey] = parseFloat(value) || 0;
} else if (prop?.type === 'boolean') {
current[finalKey] = value === 'true' || value === true;
} else {
current[finalKey] = value;
}
}
// Deep merge with existing config to preserve nested structures // Deep merge with existing config to preserve nested structures
function deepMerge(target, source) { function deepMerge(target, source) {