diff --git a/test/test_web_api.py b/test/test_web_api.py index 769d8d79..8f059a08 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -643,13 +643,13 @@ class TestDottedKeyNormalization: content_type='application/json', ) - if response.status_code == 200: - 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'] + 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.""" @@ -704,8 +704,9 @@ class TestDottedKeyNormalization: content_type='application/json', ) - if response.status_code == 200: - 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 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/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 6feecfde..e75aee32 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -2378,8 +2378,11 @@ function dotToNested(obj, schema) { } // Set the final key (remaining parts joined — may itself be dotted) - const finalKey = parts.slice(i).join('.'); - current[finalKey] = obj[key]; + // 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; @@ -2404,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' @@ -2451,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, '.'); @@ -2491,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') { @@ -2508,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) : []; @@ -2522,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); @@ -2539,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') { @@ -2554,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); } } @@ -2577,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; @@ -2589,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') { @@ -2602,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); @@ -2623,11 +2592,43 @@ function handlePluginConfigSubmit(e) { } }); } - + // Convert dot notation to nested object - const config = dotToNested(flatConfig, schema); - - 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 @@ -4473,34 +4474,8 @@ function syncFormToJson() { const form = document.getElementById('plugin-config-form'); if (!form) return; - const formData = new FormData(form); - const flatConfig = {}; - - // Get schema for type conversion const schema = currentPluginConfigState.schema; - - for (let [key, value] of formData.entries()) { - if (key === 'enabled') continue; // Skip enabled, managed separately - - // Use schema-aware property lookup for type conversion - const prop = schema ? getSchemaProperty(schema, key) : null; - - // Type conversion based on schema - if (prop?.type === 'array') { - flatConfig[key] = value.split(',').map(item => item.trim()).filter(item => item.length > 0); - } else if (prop?.type === 'integer' || key === 'display_duration') { - flatConfig[key] = parseInt(value) || 0; - } else if (prop?.type === 'number') { - flatConfig[key] = parseFloat(value) || 0; - } else if (prop?.type === 'boolean') { - flatConfig[key] = value === 'true' || value === true; - } else { - flatConfig[key] = value; - } - } - - // Convert flat dot-notation keys to nested object using schema-aware matching - const config = dotToNested(flatConfig, schema); + const config = normalizeFormDataForConfig(form, schema); // Deep merge with existing config to preserve nested structures function deepMerge(target, source) {