diff --git a/test/test_web_api.py b/test/test_web_api.py index 1226453d..8f059a08 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -583,3 +583,130 @@ def test_method_not_allowed(self, client): response = client.get('/api/v3/display/on-demand/start') 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}" diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 6a5091cd..19a93d51 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -4024,225 +4024,100 @@ def save_plugin_config(): # 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) - def fix_array_structures(config_dict, schema_props, prefix=''): - """Recursively fix array structures (convert dicts with numeric keys to arrays, fix length issues)""" + def fix_array_structures(config_dict, schema_props): + """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(): prop_type = prop_schema.get('type') if prop_type == 'array': - # Navigate to the field location - 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 - - if parent is not None and isinstance(parent, dict) and prop_key in parent: - current_value = parent[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: - # Check if all keys are numeric strings (array indices) - keys = [k for k in current_value.keys()] - if all(k.isdigit() for k in keys): - # Convert to sorted array by index - sorted_keys = sorted(keys, key=int) - 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 - 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) - else: - converted_array.append(v) - parent[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: - # Use default if available, otherwise keep as-is (validation will catch it) - default = prop_schema.get('default') - if default and isinstance(default, list) and len(default) >= min_items: - 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: + 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) - 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): + else: 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) - 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 + 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 elif prop_type == 'object' and 'properties' in prop_schema: - nested_prefix = f"{prefix}.{prop_key}" if prefix else 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) + nested_dict = config_dict.get(prop_key) 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 - def ensure_array_defaults(config_dict, schema_props, prefix=''): - """Recursively ensure array fields have defaults if None""" + def ensure_array_defaults(config_dict, schema_props): + """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(): prop_type = prop_schema.get('type') if prop_type == 'array': - 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 - - 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 [] + 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: - nested_prefix = f"{prefix}.{prop_key}" if prefix else 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) + nested_dict = config_dict.get(prop_key) if nested_dict is None: - if prefix: - parent_parts = prefix.split('.') - 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] + config_dict[prop_key] = {} + nested_dict = config_dict[prop_key] 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: # First, fix any dict structures that should be arrays diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 8a27f43f..e75aee32 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -2265,29 +2265,39 @@ window.showPluginConfigModal = function(pluginId, config) { } // 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) { if (!schema || !schema.properties) return null; - + const parts = path.split('.'); let current = schema.properties; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (current && current[part]) { - if (i === parts.length - 1) { - // Last part - return the property - return current[part]; - } else if (current[part].properties) { - // Navigate into nested object - current = current[part].properties; - } else { - return null; + let i = 0; + + while (i < parts.length) { + let matched = false; + // Try progressively longer candidates, longest first + for (let j = parts.length; j > i; j--) { + const candidate = parts.slice(i, j).join('.'); + if (current && current[candidate]) { + if (j === parts.length) { + // Consumed all remaining parts — done + return current[candidate]; + } + 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; } @@ -2311,23 +2321,70 @@ function escapeCssSelector(str) { } // 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 = {}; - + for (const key in obj) { const parts = key.split('.'); let current = result; - - for (let i = 0; i < parts.length - 1; i++) { - if (!current[parts[i]]) { - current[parts[i]] = {}; + let currentSchema = (schema && schema.properties) ? schema.properties : null; + let i = 0; + + 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; } @@ -2350,42 +2407,20 @@ function collectBooleanFields(schema, prefix = '') { return boolFields; } -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; - } - } - }); - +/** + * Normalize FormData from a plugin config form into a nested config object. + * Handles _data JSON inputs, bracket-notation checkboxes, array-of-objects, + * file-upload widgets, proper checkbox DOM detection, unchecked boolean + * handling, and schema-aware dotted-key nesting. + * + * @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 + */ +function normalizeFormDataForConfig(form, schema) { const formData = new FormData(form); 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()) { // 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' @@ -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) if (jsonValue !== null && typeof jsonValue === 'object') { flatConfig[baseKey] = jsonValue; - console.log(`JSON data field ${baseKey}: parsed ${Array.isArray(jsonValue) ? 'array' : 'object'}`, jsonValue); continue; // Skip normal processing for JSON data fields } } catch (e) { // Not valid JSON, continue with normal processing } } - + // 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 if (key.endsWith('[]')) { continue; } - + // Skip key_value pair inputs (they're handled by the hidden _data input) if (key.includes('[key_') || key.includes('[value_')) { continue; } - + // 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. if (key.includes('_item_') && /_item_\d+_/.test(key)) { continue; } - + // Try to get schema property - handle both dot notation and underscore notation let propSchema = getSchemaPropertyType(schema, key); let actualKey = key; let actualValue = value; - + // If not found with dots, try converting underscores to dots (for nested fields) if (!propSchema && key.includes('_')) { const dotKey = key.replace(/_/g, '.'); @@ -2437,10 +2471,10 @@ function handlePluginConfigSubmit(e) { actualValue = value; } } - + if (propSchema) { const propType = propSchema.type; - + if (propType === 'array') { // Check if this is a file upload widget (JSON array) if (propSchema['x-widget'] === 'file-upload') { @@ -2454,11 +2488,10 @@ function handlePluginConfigSubmit(e) { tempDiv.innerHTML = actualValue; decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue; } - + const jsonValue = JSON.parse(decodedValue); if (Array.isArray(jsonValue)) { flatConfig[actualKey] = jsonValue; - console.log(`File upload array field ${actualKey}: parsed JSON array with ${jsonValue.length} items`); } else { // Fallback to comma-separated const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : []; @@ -2468,13 +2501,11 @@ function handlePluginConfigSubmit(e) { // Not JSON, use comma-separated const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : []; flatConfig[actualKey] = arrayValue; - console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue); } } else { // Regular array: convert comma-separated string to array const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : []; flatConfig[actualKey] = arrayValue; - console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue); } } else if (propType === 'integer') { flatConfig[actualKey] = parseInt(actualValue, 10); @@ -2485,14 +2516,13 @@ function handlePluginConfigSubmit(e) { // Escape special CSS selector characters in the name const escapedKey = escapeCssSelector(key); const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); - + if (formElement) { // Element found - use its checked state flatConfig[actualKey] = formElement.checked; } else { // Element not found - normalize string booleans and check FormData value // Checkboxes send "on" when checked, nothing when unchecked - // Normalize string representations of booleans if (typeof actualValue === 'string') { const lowerValue = actualValue.toLowerCase().trim(); if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { @@ -2500,13 +2530,11 @@ function handlePluginConfigSubmit(e) { } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { flatConfig[actualKey] = false; } else { - // Non-empty string that's not a boolean representation - treat as truthy flatConfig[actualKey] = true; } } else if (actualValue === undefined || actualValue === null) { flatConfig[actualKey] = false; } else { - // Non-string value - coerce to boolean flatConfig[actualKey] = Boolean(actualValue); } } @@ -2523,10 +2551,9 @@ function handlePluginConfigSubmit(e) { const tempDiv = document.createElement('div'); tempDiv.innerHTML = actualValue; decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue; - + const parsed = JSON.parse(decodedValue); flatConfig[actualKey] = parsed; - console.log(`No schema for ${actualKey}, but parsed as JSON:`, parsed); } catch (e) { // Not valid JSON, save as string flatConfig[actualKey] = actualValue; @@ -2535,12 +2562,10 @@ function handlePluginConfigSubmit(e) { // No schema - try to detect checkbox by finding the element const escapedKey = escapeCssSelector(key); const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); - + if (formElement && formElement.type === 'checkbox') { - // Found checkbox element - use its checked state flatConfig[actualKey] = formElement.checked; } else { - // Not a checkbox or element not found - normalize string booleans if (typeof actualValue === 'string') { const lowerValue = actualValue.toLowerCase().trim(); if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { @@ -2548,18 +2573,16 @@ function handlePluginConfigSubmit(e) { } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { flatConfig[actualKey] = false; } else { - // Non-empty string that's not a boolean representation - keep as string flatConfig[actualKey] = actualValue; } } else { - // Non-string value - use as-is flatConfig[actualKey] = actualValue; } } } } } - + // Handle unchecked checkboxes (not in FormData) - including nested ones if (schema && schema.properties) { const allBoolFields = collectBooleanFields(schema); @@ -2569,11 +2592,43 @@ function handlePluginConfigSubmit(e) { } }); } - + // Convert dot notation to nested object - const config = dotToNested(flatConfig); - - console.log('Flat config:', flatConfig); + return dotToNested(flatConfig, schema); +} + +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); // Save the configuration @@ -4418,42 +4473,9 @@ function switchPluginConfigView(view) { function syncFormToJson() { const form = document.getElementById('plugin-config-form'); if (!form) return; - - const formData = new FormData(form); - const config = {}; - - // Get schema for type conversion + const schema = currentPluginConfigState.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; - } - } + const config = normalizeFormDataForConfig(form, schema); // Deep merge with existing config to preserve nested structures function deepMerge(target, source) {