mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
Feature/widget registry system (#190)
* chore: Update basketball-scoreboard submodule for odds font fix
* feat(widgets): Add widget registry system for plugin configuration forms
- Create core widget registry system (registry.js, base-widget.js)
- Extract existing widgets to separate modules:
- file-upload.js: Image upload with drag-and-drop, preview, delete, scheduling
- checkbox-group.js: Multi-select checkboxes for array fields
- custom-feeds.js: Table-based RSS feed editor with logo uploads
- Implement plugin widget loading system (plugin-loader.js)
- Add comprehensive documentation (widget-guide.md, README.md)
- Include example custom widget (example-color-picker.js)
- Maintain backwards compatibility with existing plugins
- All widget handlers available globally for existing functionality
This enables:
- Reusable UI components for plugin configuration forms
- Third-party plugins to create custom widgets without modifying LEDMatrix
- Modular widget architecture for future enhancements
Existing plugins (odds-ticker, static-image, news) continue to work without changes.
* fix(widgets): Security and correctness fixes for widget system
- base-widget.js: Fix escapeHtml to always escape (coerce to string first)
- base-widget.js: Add sanitizeId helper for safe DOM ID usage
- base-widget.js: Use DOM APIs in showError instead of innerHTML
- checkbox-group.js: Normalize types in setValue for consistent comparison
- custom-feeds.js: Implement setValue with full row creation logic
- example-color-picker.js: Validate hex colors before using in style attributes
- file-upload.js: Replace innerHTML with DOM creation to prevent XSS
- file-upload.js: Preserve open schedule editors when updating image list
- file-upload.js: Normalize types when filtering deleted files
- file-upload.js: Sanitize imageId in openImageSchedule and all schedule handlers
- file-upload.js: Fix max-files check order and use allowed_types from config
- README.md: Add security guidance for ID sanitization in examples
* fix(widgets): Additional security and error handling improvements
- scripts/update_plugin_repos.py: Add explicit UTF-8 encoding and proper error handling for file operations
- scripts/update_plugin_repos.py: Fix git fetch/pull error handling with returncode checks and specific exception types
- base-widget.js: Guard notify method against undefined/null type parameter
- file-upload.js: Remove inline handlers from schedule template, use addEventListener with data attributes
- file-upload.js: Update hideUploadProgress to show dynamic file types from config instead of hardcoded list
- README.md: Update Color Picker example to use sanitized fieldId throughout
* fix(widgets): Update Slider example to use sanitized fieldId
- Add sanitizeId helper to Slider example render, getValue, and setValue methods
- Use sanitizedFieldId for all DOM IDs and query selectors
- Maintain consistency with Color Picker example pattern
* fix(plugins_manager): Move configurePlugin and togglePlugin to top of file
- Move configurePlugin and togglePlugin definitions to top level (after uninstallPlugin)
- Ensures these critical functions are available immediately when script loads
- Fixes 'Critical functions not available after 20 attempts' error
- Functions are now defined before any HTML rendering checks
* fix(plugins_manager): Fix checkbox state saving using querySelector
- Add escapeCssSelector helper function for safe CSS selector usage
- Replace form.elements[actualKey] with form.querySelector for boolean fields
- Properly handle checkbox checked state using element.checked property
- Fix both schema-based and schema-less boolean field processing
- Ensures checkboxes with dot notation names (nested fields) work correctly
Fixes issue where checkbox states were not properly saved when field names
use dot notation (e.g., 'display.scroll_enabled'). The form.elements
collection doesn't reliably handle dot notation in bracket notation access.
* fix(base.html): Fix form element lookup for dot notation field names
- Add escapeCssSelector helper function (both as method and standalone)
- Replace form.elements[key] with form.querySelector for element type detection
- Fixes element lookup failures when field names use dot notation
- Ensures checkbox and multi-select skipping logic works correctly
- Applies fix to both Alpine.js method and standalone function
This complements the fix in plugins_manager.js to ensure all form
element lookups handle nested field names (e.g., 'display.scroll_enabled')
reliably across the entire web interface.
* fix(plugins_manager): Add race condition protection to togglePlugin
- Initialize window._pluginToggleRequests map for per-plugin request tokens
- Generate unique token for each toggle request to track in-flight requests
- Disable checkbox and wrapper UI during request to prevent overlapping toggles
- Add visual feedback with opacity and pointer-events-none classes
- Verify token matches before applying response updates (both success and error)
- Ignore out-of-order responses to preserve latest user intent
- Clear token and re-enable UI after request completes
Prevents race conditions when users rapidly toggle plugins, ensuring
only the latest toggle request's response affects the UI state.
* refactor(escapeCssSelector): Use CSS.escape() for better selector safety
- Prefer CSS.escape() when available for proper CSS selector escaping
- Handles edge cases: unicode characters, leading digits, and spec compliance
- Keep regex-based fallback for older browsers without CSS.escape support
- Update all three instances: plugins_manager.js and both in base.html
CSS.escape() is the standard API for escaping CSS selectors and provides
more robust handling than custom regex, especially for unicode and edge cases.
* fix(plugins_manager): Fix syntax error - missing closing brace for file-upload if block
- Add missing closing brace before else-if for checkbox-group widget
- Fixes 'Unexpected token else' error at line 3138
- The if block for file-upload widget (line 3034) was missing its closing brace
- Now properly structured: if (file-upload) { ... } else if (checkbox-group) { ... }
* fix(plugins_manager): Fix indentation in file-upload widget if block
- Properly indent all code inside the file-upload if block
- Fix template string closing brace indentation
- Ensures proper structure: if (file-upload) { ... } else if (checkbox-group) { ... }
- Resolves syntax error at line 3138
* fix(plugins_manager): Skip checkbox-group [] inputs to prevent config leakage
- Add skip logic for keys ending with '[]' in handlePluginConfigSubmit
- Prevents checkbox-group bracket notation inputs from leaking into config
- Checkbox-group widgets emit name="...[]" checkboxes plus a _data JSON field
- The _data field is already processed correctly, so [] inputs are redundant
- Prevents schema validation failures and extra config keys
The checkbox-group widget creates:
1. Individual checkboxes with name="fullKey[]" (now skipped)
2. Hidden input with name="fullKey_data" containing JSON array (processed)
3. Sentinel hidden input with name="fullKey[]" and empty value (now skipped)
* fix(plugins_manager): Normalize string booleans when checkbox input is missing
- Fix boolean field processing to properly normalize string booleans in fallback path
- Prevents "false"/"0" from being coerced to true when checkbox element is missing
- Handles common string boolean representations: 'true', 'false', '1', '0', 'on', 'off'
- Applies to both schema-based (lines 2386-2400) and schema-less (lines 2423-2433) paths
When a checkbox element cannot be found, the fallback logic now:
1. Checks if value is a string and normalizes known boolean representations
2. Treats undefined/null as false
3. Coerces other types to boolean using Boolean()
This ensures string values like "false" or "0" are correctly converted to false
instead of being treated as truthy non-empty strings.
* fix(base.html): Improve escapeCssSelector fallback to match CSS.escape behavior
- Handle leading digits by converting to hex escape (e.g., '1' -> '\0031 ')
- Handle leading whitespace by converting to hex escape (e.g., ' ' -> '\0020 ')
- Escape internal spaces as '\ ' (preserving space in hex escapes)
- Ensures trailing space after hex escapes per CSS spec
- Applies to both Alpine.js method and standalone function
The fallback now better matches CSS.escape() behavior for older browsers:
1. Escapes leading digits (0-9) as hex escapes with trailing space
2. Escapes leading whitespace as hex escapes with trailing space
3. Escapes all special characters as before
4. Escapes internal spaces while preserving hex escape format
This prevents selector injection issues with field names starting with digits
or whitespace, matching the standard CSS.escape() API behavior.
---------
Co-authored-by: Chuck <chuck@example.com>
This commit is contained in:
@@ -161,6 +161,199 @@ window.uninstallPlugin = window.uninstallPlugin || function(pluginId) {
|
||||
});
|
||||
};
|
||||
|
||||
// Define configurePlugin early to ensure it's always available
|
||||
window.configurePlugin = window.configurePlugin || async function(pluginId) {
|
||||
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] configurePlugin called for', pluginId);
|
||||
|
||||
// Switch to the plugin's configuration tab instead of opening a modal
|
||||
// This matches the behavior of clicking the plugin tab at the top
|
||||
function getAppComponent() {
|
||||
if (window.Alpine) {
|
||||
const appElement = document.querySelector('[x-data="app()"]');
|
||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
||||
return appElement._x_dataStack[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const appComponent = getAppComponent();
|
||||
if (appComponent) {
|
||||
// Set the active tab to the plugin ID
|
||||
appComponent.activeTab = pluginId;
|
||||
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] Switched to plugin tab:', pluginId);
|
||||
|
||||
// Scroll to top of page to ensure the tab is visible
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
console.error('Alpine.js app instance not found');
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize per-plugin toggle request token map for race condition protection
|
||||
if (!window._pluginToggleRequests) {
|
||||
window._pluginToggleRequests = {};
|
||||
}
|
||||
|
||||
// Define togglePlugin early to ensure it's always available
|
||||
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
|
||||
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] togglePlugin called for', pluginId, 'enabled:', enabled);
|
||||
|
||||
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
||||
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
||||
const action = enabled ? 'enabling' : 'disabling';
|
||||
|
||||
// Generate unique token for this toggle request to prevent race conditions
|
||||
const requestToken = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
window._pluginToggleRequests[pluginId] = requestToken;
|
||||
|
||||
// Update UI immediately for better UX
|
||||
const toggleCheckbox = document.getElementById(`toggle-${pluginId}`);
|
||||
const toggleLabel = document.getElementById(`toggle-label-${pluginId}`);
|
||||
const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2');
|
||||
const toggleTrack = wrapperDiv?.querySelector('.relative.w-14');
|
||||
const toggleHandle = toggleTrack?.querySelector('.absolute');
|
||||
|
||||
// Disable checkbox and add disabled class to prevent overlapping requests
|
||||
if (toggleCheckbox) {
|
||||
toggleCheckbox.checked = enabled;
|
||||
toggleCheckbox.disabled = true;
|
||||
toggleCheckbox.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
|
||||
// Disable wrapper to provide visual feedback
|
||||
if (wrapperDiv) {
|
||||
wrapperDiv.classList.add('opacity-50', 'pointer-events-none');
|
||||
}
|
||||
|
||||
// Update wrapper background and border
|
||||
if (wrapperDiv) {
|
||||
if (enabled) {
|
||||
wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300');
|
||||
wrapperDiv.classList.add('bg-green-50', 'border-green-500');
|
||||
} else {
|
||||
wrapperDiv.classList.remove('bg-green-50', 'border-green-500');
|
||||
wrapperDiv.classList.add('bg-gray-50', 'border-gray-300');
|
||||
}
|
||||
}
|
||||
|
||||
// Update toggle track
|
||||
if (toggleTrack) {
|
||||
if (enabled) {
|
||||
toggleTrack.classList.remove('bg-gray-300');
|
||||
toggleTrack.classList.add('bg-green-500');
|
||||
} else {
|
||||
toggleTrack.classList.remove('bg-green-500');
|
||||
toggleTrack.classList.add('bg-gray-300');
|
||||
}
|
||||
}
|
||||
|
||||
// Update toggle handle
|
||||
if (toggleHandle) {
|
||||
if (enabled) {
|
||||
toggleHandle.classList.add('translate-x-full', 'border-green-500');
|
||||
toggleHandle.classList.remove('border-gray-400');
|
||||
toggleHandle.innerHTML = '<i class="fas fa-check text-green-600 text-xs"></i>';
|
||||
} else {
|
||||
toggleHandle.classList.remove('translate-x-full', 'border-green-500');
|
||||
toggleHandle.classList.add('border-gray-400');
|
||||
toggleHandle.innerHTML = '<i class="fas fa-times text-gray-400 text-xs"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
// Update label with icon and text
|
||||
if (toggleLabel) {
|
||||
if (enabled) {
|
||||
toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5';
|
||||
toggleLabel.innerHTML = '<i class="fas fa-toggle-on text-green-600"></i><span>Enabled</span>';
|
||||
} else {
|
||||
toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5';
|
||||
toggleLabel.innerHTML = '<i class="fas fa-toggle-off text-gray-400"></i><span>Disabled</span>';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info');
|
||||
}
|
||||
|
||||
fetch('/api/v3/plugins/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plugin_id: pluginId, enabled: enabled })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Verify this response is for the latest request (prevent race conditions)
|
||||
if (window._pluginToggleRequests[pluginId] !== requestToken) {
|
||||
console.log(`[togglePlugin] Ignoring out-of-order response for ${pluginId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(data.message, data.status);
|
||||
}
|
||||
if (data.status === 'success') {
|
||||
// Update local state
|
||||
if (plugin) {
|
||||
plugin.enabled = enabled;
|
||||
}
|
||||
// Refresh the list to ensure consistency
|
||||
if (typeof loadInstalledPlugins === 'function') {
|
||||
loadInstalledPlugins();
|
||||
}
|
||||
} else {
|
||||
// Revert the toggle if API call failed
|
||||
if (plugin) {
|
||||
plugin.enabled = !enabled;
|
||||
}
|
||||
if (typeof loadInstalledPlugins === 'function') {
|
||||
loadInstalledPlugins();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear token and re-enable UI
|
||||
delete window._pluginToggleRequests[pluginId];
|
||||
if (toggleCheckbox) {
|
||||
toggleCheckbox.disabled = false;
|
||||
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
if (wrapperDiv) {
|
||||
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Verify this error is for the latest request (prevent race conditions)
|
||||
if (window._pluginToggleRequests[pluginId] !== requestToken) {
|
||||
console.log(`[togglePlugin] Ignoring out-of-order error for ${pluginId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error toggling plugin: ' + error.message, 'error');
|
||||
}
|
||||
// Revert the toggle if API call failed
|
||||
if (plugin) {
|
||||
plugin.enabled = !enabled;
|
||||
}
|
||||
if (typeof loadInstalledPlugins === 'function') {
|
||||
loadInstalledPlugins();
|
||||
}
|
||||
|
||||
// Clear token and re-enable UI
|
||||
delete window._pluginToggleRequests[pluginId];
|
||||
if (toggleCheckbox) {
|
||||
toggleCheckbox.disabled = false;
|
||||
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
if (wrapperDiv) {
|
||||
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Cleanup orphaned modals from previous executions to prevent duplicates when moving to body
|
||||
try {
|
||||
const existingModals = document.querySelectorAll('#plugin-config-modal');
|
||||
@@ -306,146 +499,8 @@ window.__pluginDomReady = window.__pluginDomReady || false;
|
||||
console.log('[PLUGINS SCRIPT] Global event delegation set up');
|
||||
})();
|
||||
|
||||
window.configurePlugin = window.configurePlugin || async function(pluginId) {
|
||||
console.log('[DEBUG] ===== configurePlugin called =====');
|
||||
console.log('[DEBUG] Plugin ID:', pluginId);
|
||||
|
||||
// Switch to the plugin's configuration tab instead of opening a modal
|
||||
// This matches the behavior of clicking the plugin tab at the top
|
||||
function getAppComponent() {
|
||||
if (window.Alpine) {
|
||||
const appElement = document.querySelector('[x-data="app()"]');
|
||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
||||
return appElement._x_dataStack[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const appComponent = getAppComponent();
|
||||
if (appComponent) {
|
||||
// Set the active tab to the plugin ID
|
||||
appComponent.activeTab = pluginId;
|
||||
console.log('[DEBUG] Switched to plugin tab:', pluginId);
|
||||
|
||||
// Scroll to top of page to ensure the tab is visible
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
console.error('Alpine.js app instance not found');
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
|
||||
console.log('[DEBUG] ===== togglePlugin called =====');
|
||||
console.log('[DEBUG] Plugin ID:', pluginId, 'Enabled:', enabled);
|
||||
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
||||
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
||||
const action = enabled ? 'enabling' : 'disabling';
|
||||
|
||||
// Update UI immediately for better UX
|
||||
const toggleCheckbox = document.getElementById(`toggle-${pluginId}`);
|
||||
const toggleLabel = document.getElementById(`toggle-label-${pluginId}`);
|
||||
const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2');
|
||||
const toggleTrack = wrapperDiv?.querySelector('.relative.w-14');
|
||||
const toggleHandle = toggleTrack?.querySelector('.absolute');
|
||||
|
||||
if (toggleCheckbox) toggleCheckbox.checked = enabled;
|
||||
|
||||
// Update wrapper background and border
|
||||
if (wrapperDiv) {
|
||||
if (enabled) {
|
||||
wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300');
|
||||
wrapperDiv.classList.add('bg-green-50', 'border-green-500');
|
||||
} else {
|
||||
wrapperDiv.classList.remove('bg-green-50', 'border-green-500');
|
||||
wrapperDiv.classList.add('bg-gray-50', 'border-gray-300');
|
||||
}
|
||||
}
|
||||
|
||||
// Update toggle track
|
||||
if (toggleTrack) {
|
||||
if (enabled) {
|
||||
toggleTrack.classList.remove('bg-gray-300');
|
||||
toggleTrack.classList.add('bg-green-500');
|
||||
} else {
|
||||
toggleTrack.classList.remove('bg-green-500');
|
||||
toggleTrack.classList.add('bg-gray-300');
|
||||
}
|
||||
}
|
||||
|
||||
// Update toggle handle
|
||||
if (toggleHandle) {
|
||||
if (enabled) {
|
||||
toggleHandle.classList.add('translate-x-full', 'border-green-500');
|
||||
toggleHandle.classList.remove('border-gray-400');
|
||||
toggleHandle.innerHTML = '<i class="fas fa-check text-green-600 text-xs"></i>';
|
||||
} else {
|
||||
toggleHandle.classList.remove('translate-x-full', 'border-green-500');
|
||||
toggleHandle.classList.add('border-gray-400');
|
||||
toggleHandle.innerHTML = '<i class="fas fa-times text-gray-400 text-xs"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
// Update label with icon and text
|
||||
if (toggleLabel) {
|
||||
if (enabled) {
|
||||
toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5';
|
||||
toggleLabel.innerHTML = '<i class="fas fa-toggle-on text-green-600"></i><span>Enabled</span>';
|
||||
} else {
|
||||
toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5';
|
||||
toggleLabel.innerHTML = '<i class="fas fa-toggle-off text-gray-400"></i><span>Disabled</span>';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info');
|
||||
}
|
||||
|
||||
fetch('/api/v3/plugins/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plugin_id: pluginId, enabled: enabled })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(data.message, data.status);
|
||||
}
|
||||
if (data.status === 'success') {
|
||||
// Update local state
|
||||
if (plugin) {
|
||||
plugin.enabled = enabled;
|
||||
}
|
||||
// Refresh the list to ensure consistency
|
||||
if (typeof loadInstalledPlugins === 'function') {
|
||||
loadInstalledPlugins();
|
||||
}
|
||||
} else {
|
||||
// Revert the toggle if API call failed
|
||||
if (plugin) {
|
||||
plugin.enabled = !enabled;
|
||||
}
|
||||
if (typeof loadInstalledPlugins === 'function') {
|
||||
loadInstalledPlugins();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error toggling plugin: ' + error.message, 'error');
|
||||
}
|
||||
// Revert the toggle if API call failed
|
||||
if (plugin) {
|
||||
plugin.enabled = !enabled;
|
||||
}
|
||||
if (typeof loadInstalledPlugins === 'function') {
|
||||
loadInstalledPlugins();
|
||||
}
|
||||
});
|
||||
};
|
||||
// Note: configurePlugin and togglePlugin are now defined at the top of the file (after uninstallPlugin)
|
||||
// to ensure they're available immediately when the script loads
|
||||
|
||||
// Verify functions are defined (debug only)
|
||||
if (_PLUGIN_DEBUG_EARLY) {
|
||||
@@ -2145,6 +2200,19 @@ function getSchemaPropertyType(schema, path) {
|
||||
return prop; // Return the full property object (was returning just type, but callers expect object)
|
||||
}
|
||||
|
||||
// Helper function to escape CSS selector special characters
|
||||
function escapeCssSelector(str) {
|
||||
if (typeof str !== 'string') {
|
||||
str = String(str);
|
||||
}
|
||||
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
|
||||
if (typeof CSS !== 'undefined' && CSS.escape) {
|
||||
return CSS.escape(str);
|
||||
}
|
||||
// Fallback to regex-based escaping for older browsers
|
||||
return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Helper function to convert dot notation to nested object
|
||||
function dotToNested(obj) {
|
||||
const result = {};
|
||||
@@ -2240,6 +2308,12 @@ function handlePluginConfigSubmit(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -2310,8 +2384,35 @@ function handlePluginConfigSubmit(e) {
|
||||
} else if (propType === 'number') {
|
||||
flatConfig[actualKey] = parseFloat(actualValue);
|
||||
} else if (propType === 'boolean') {
|
||||
const formElement = form.elements[actualKey] || form.elements[key];
|
||||
flatConfig[actualKey] = formElement ? formElement.checked : (actualValue === 'true' || actualValue === true);
|
||||
// Use querySelector to reliably find checkbox by name attribute
|
||||
// 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') {
|
||||
flatConfig[actualKey] = true;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flatConfig[actualKey] = actualValue;
|
||||
}
|
||||
@@ -2334,11 +2435,29 @@ function handlePluginConfigSubmit(e) {
|
||||
flatConfig[actualKey] = actualValue;
|
||||
}
|
||||
} else {
|
||||
const formElement = form.elements[actualKey] || form.elements[key];
|
||||
// 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 {
|
||||
flatConfig[actualKey] = actualValue;
|
||||
// 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') {
|
||||
flatConfig[actualKey] = true;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2947,20 +3066,20 @@ function generateFieldHtml(key, prop, value, prefix = '') {
|
||||
|
||||
// Check for file-upload widget FIRST (to avoid breaking static-image plugin)
|
||||
if (xWidgetValue === 'file-upload' || xWidgetValue2 === 'file-upload') {
|
||||
console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`);
|
||||
const uploadConfig = prop['x-upload-config'] || {};
|
||||
const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image';
|
||||
const maxFiles = uploadConfig.max_files || 10;
|
||||
const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json'
|
||||
const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']);
|
||||
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
||||
const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified
|
||||
const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified
|
||||
|
||||
const currentFiles = Array.isArray(value) ? value : [];
|
||||
const fieldId = fullKey.replace(/\./g, '_');
|
||||
|
||||
html += `
|
||||
console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`);
|
||||
const uploadConfig = prop['x-upload-config'] || {};
|
||||
const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image';
|
||||
const maxFiles = uploadConfig.max_files || 10;
|
||||
const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json'
|
||||
const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']);
|
||||
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
||||
const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified
|
||||
const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified
|
||||
|
||||
const currentFiles = Array.isArray(value) ? value : [];
|
||||
const fieldId = fullKey.replace(/\./g, '_');
|
||||
|
||||
html += `
|
||||
<div id="${fieldId}_upload_widget" class="mt-1">
|
||||
<!-- File Upload Drop Zone -->
|
||||
<div id="${fieldId}_drop_zone"
|
||||
@@ -3044,8 +3163,8 @@ function generateFieldHtml(key, prop, value, prefix = '') {
|
||||
data-upload-endpoint="${customUploadEndpoint || '/api/v3/plugins/assets/upload'}"
|
||||
data-file-type="${fileType}">
|
||||
</div>
|
||||
`;
|
||||
} else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') {
|
||||
`;
|
||||
} else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') {
|
||||
// Checkbox group widget for multi-select arrays with enum items
|
||||
// Use _data hidden input pattern to serialize selected values correctly
|
||||
console.log(`[DEBUG] ✅ Detected checkbox-group widget for ${fullKey} - rendering checkboxes`);
|
||||
|
||||
Reference in New Issue
Block a user