* fix(web): resolve file upload config lookup for server-rendered forms
The file upload widget's getUploadConfig() function failed to map
server-rendered field IDs (e.g., "static-image-images") back to schema
property keys ("images"), causing upload config (plugin_id, endpoint,
allowed_types) to be lost. This could prevent image uploads from
working correctly in the static-image plugin and others.
Changes:
- Add data-* attributes to the Jinja2 file-upload template so upload
config is embedded directly on the file input element
- Update getUploadConfig() in both file-upload.js and plugins_manager.js
to read config from data attributes first, falling back to schema lookup
- Remove duplicate handleFiles/handleFileDrop/handleFileSelect from
plugins_manager.js that overwrote the more robust file-upload.js versions
- Bump cache-busting version strings so browsers fetch updated JS
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): harden file upload functions against CodeRabbit patterns
- Add response.ok guard before response.json() in handleFiles,
deleteUploadedFile, and handleCredentialsUpload to prevent
SyntaxError on non-JSON error responses (PR #271 finding)
- Remove duplicate getUploadConfig() from plugins_manager.js;
file-upload.js now owns this function exclusively
- Replace innerHTML with textContent/DOM methods in
handleCredentialsUpload to prevent XSS (PR #271 finding)
- Fix redundant if-check in getUploadConfig data-attribute reader
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): address CodeRabbit findings on file upload widget
- Add data-multiple="true" discriminator on array file inputs so
handleFileDrop routes multi-file drops to handleFiles() not
handleSingleFileUpload()
- Duplicate upload config data attributes onto drop zone wrapper so
getUploadConfig() survives progress-helper DOM re-renders that
remove the file input element
- Clear file input in finally block after credentials upload to allow
re-selecting the same file on retry
- Branch deleteUploadedFile on fileType: JSON deletes remove the DOM
element directly instead of routing through updateImageList() which
renders image-specific cards (thumbnails, scheduling controls)
Addresses CodeRabbit findings on PR #279:
- Major: drag-and-drop hits single-file path for array uploaders
- Major: config lookup fails after first upload (DOM node removed)
- Minor: same-file retry silently no-ops
- Major: JSON deletes re-render list as images
Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): address CodeRabbit round-2 findings on file upload widget
- Extract getConfigSourceElement() helper so handleFileDrop,
handleSingleFileUpload, and getUploadConfig all share the same
fallback logic: file input → drop zone wrapper
- Remove pluginId gate from getUploadConfig Strategy 1 — fields with
uploadEndpoint or fileType but no pluginId now return config instead
of falling through to generic defaults
- Fix JSON delete identifier mismatch: use file.id || file.category_name
(matching the renderer at line 3202) instead of f.file_id; remove
regex sanitization on DOM id lookup (renderer doesn't sanitize)
Addresses CodeRabbit round-2 findings on PR #279:
- Major: single-file uploads bypass drop-zone config fallback
- Major: getUploadConfig gated on data-plugin-id only
- Major: JSON delete file identifier mismatch vs renderer
Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): align delete handler file identifier with renderer logic
Remove f.file_id from JSON file delete filter to match the renderer's
identifier logic (file.id || file.category_name || idx). Prevents
deleted entries from persisting in the hidden input on next save.
Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: 5ymb01 <noreply@github.com>
LEDMatrix Widget Development Guide
Overview
The LEDMatrix Widget Registry system allows plugins to use reusable UI components (widgets) for configuration forms. This system enables:
- Reusable Components: Use existing widgets (file upload, checkboxes, etc.) without custom code
- Custom Widgets: Create plugin-specific widgets without modifying the LEDMatrix codebase
- Backwards Compatibility: Existing plugins continue to work without changes
Available Core Widgets
1. File Upload Widget (file-upload)
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
Schema Configuration:
{
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 10,
"max_size_mb": 5,
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"]
}
}
Features:
- Drag and drop file upload
- Image preview with thumbnails
- Delete functionality
- Schedule images to show at specific times
- Progress indicators during upload
2. Checkbox Group Widget (checkbox-group)
Multi-select checkboxes for array fields with enum items.
Schema Configuration:
{
"type": "array",
"x-widget": "checkbox-group",
"items": {
"type": "string",
"enum": ["option1", "option2", "option3"]
},
"x-options": {
"labels": {
"option1": "Option 1 Label",
"option2": "Option 2 Label"
}
}
}
Features:
- Multiple selection from enum list
- Custom labels for each option
- Automatic JSON array serialization
3. Custom Feeds Widget (custom-feeds)
Table-based RSS feed editor with logo uploads.
Schema Configuration:
{
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"url": { "type": "string", "format": "uri" },
"enabled": { "type": "boolean" },
"logo": { "type": "object" }
}
},
"maxItems": 50
}
Features:
- Add/remove feed rows
- Logo upload per feed
- Enable/disable individual feeds
- Automatic row re-indexing
Using Existing Widgets
To use an existing widget in your plugin's config_schema.json, simply add the x-widget property to your field definition:
{
"properties": {
"my_images": {
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 5
}
}
}
}
The widget will be automatically rendered when the plugin configuration form is loaded.
Creating Custom Widgets
Step 1: Create Widget File
Create a JavaScript file in your plugin directory (e.g., widgets/my-widget.js):
// Ensure LEDMatrixWidgets registry is available
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('LEDMatrixWidgets registry not found');
return;
}
// Register your widget
window.LEDMatrixWidgets.register('my-custom-widget', {
name: 'My Custom Widget',
version: '1.0.0',
/**
* Render the widget HTML
* @param {HTMLElement} container - Container element to render into
* @param {Object} config - Widget configuration from schema
* @param {*} value - Current value
* @param {Object} options - Additional options (fieldId, pluginId, etc.)
*/
render: function(container, config, value, options) {
const fieldId = options.fieldId || container.id;
// Sanitize fieldId for safe use in DOM IDs and selectors
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const safeFieldId = sanitizeId(fieldId);
const html = `
<div class="my-custom-widget">
<input type="text"
id="${safeFieldId}_input"
value="${this.escapeHtml(value || '')}"
class="w-full px-3 py-2 border border-gray-300 rounded">
</div>
`;
container.innerHTML = html;
// Attach event listeners
const input = container.querySelector(`#${safeFieldId}_input`);
if (input) {
input.addEventListener('change', (e) => {
this.handlers.onChange(fieldId, e.target.value);
});
}
},
/**
* Get current value from widget
* @param {string} fieldId - Field ID
* @returns {*} Current value
*/
getValue: function(fieldId) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const safeFieldId = sanitizeId(fieldId);
const input = document.querySelector(`#${safeFieldId}_input`);
return input ? input.value : null;
},
/**
* Set value programmatically
* @param {string} fieldId - Field ID
* @param {*} value - Value to set
*/
setValue: function(fieldId, value) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const safeFieldId = sanitizeId(fieldId);
const input = document.querySelector(`#${safeFieldId}_input`);
if (input) {
input.value = value || '';
}
},
/**
* Event handlers
*/
handlers: {
onChange: function(fieldId, value) {
// Trigger form change event
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
},
/**
* Helper: Escape HTML to prevent XSS
*/
escapeHtml: function(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
/**
* Helper: Sanitize identifier for use in DOM IDs and CSS selectors
*/
sanitizeId: function(id) {
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
});
Step 2: Reference Widget in Schema
In your plugin's config_schema.json:
{
"properties": {
"my_field": {
"type": "string",
"description": "My custom field",
"x-widget": "my-custom-widget",
"default": ""
}
}
}
Step 3: Widget Loading
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
- Check if widget is registered in the core registry
- If not found, attempt to load from plugin directory:
/static/plugin-widgets/[plugin-id]/[widget-name].js - Render the widget using the registered
renderfunction
Widget API Reference
Widget Definition Object
{
name: string, // Human-readable widget name
version: string, // Widget version
render: function, // Required: Render function
getValue: function, // Optional: Get current value
setValue: function, // Optional: Set value programmatically
handlers: object // Optional: Event handlers
}
Render Function
render(container, config, value, options)
Parameters:
container(HTMLElement): Container element to render intoconfig(Object): Widget configuration from schema (x-widget-configor schema properties)value(*): Current field valueoptions(Object): Additional optionsfieldId(string): Field IDpluginId(string): Plugin IDfullKey(string): Full field key path
Get Value Function
getValue(fieldId)
Returns: Current widget value
Set Value Function
setValue(fieldId, value)
Parameters:
fieldId(string): Field IDvalue(*): Value to set
Event Handlers
Widgets can define custom event handlers in the handlers object:
handlers: {
onChange: function(fieldId, value) {
// Handle value change
},
onFocus: function(fieldId) {
// Handle focus
}
}
Best Practices
Security
- Always escape HTML: Use
escapeHtml()ortextContentto prevent XSS - Validate inputs: Validate user input before processing
- Sanitize values: Clean values before storing
- Sanitize identifiers: Always sanitize identifiers (like
fieldId) used as element IDs and in CSS selectors to prevent selector injection/XSS:- Use
sanitizeId()helper function (available in BaseWidget) or create your own - Allow only safe characters:
[A-Za-z0-9_-] - Replace or remove invalid characters before using in:
getElementById(),querySelector(),querySelectorAll()- Setting
idattributes - Building CSS selectors
- Never interpolate raw
fieldIdinto HTML strings or selectors without sanitization - Example:
const safeId = fieldId.replace(/[^a-zA-Z0-9_-]/g, '_');
- Use
Performance
- Lazy loading: Load widget scripts only when needed
- Event delegation: Use event delegation for dynamic content
- Debounce: Debounce frequent events (e.g., input changes)
Accessibility
- Labels: Always associate labels with inputs
- ARIA attributes: Use appropriate ARIA attributes
- Keyboard navigation: Ensure keyboard accessibility
Error Handling
- Graceful degradation: Handle missing dependencies
- User feedback: Show clear error messages
- Logging: Log errors for debugging
Examples
Example 1: Color Picker Widget
window.LEDMatrixWidgets.register('color-picker', {
name: 'Color Picker',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = options.fieldId;
// Sanitize fieldId for safe use in DOM IDs and selectors
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
container.innerHTML = `
<div class="flex items-center space-x-2">
<input type="color"
id="${sanitizedFieldId}_color"
value="${value || '#000000'}"
class="h-10 w-20">
<input type="text"
id="${sanitizedFieldId}_hex"
value="${value || '#000000'}"
pattern="^#[0-9A-Fa-f]{6}$"
class="px-2 py-1 border rounded">
</div>
`;
const colorInput = container.querySelector(`#${sanitizedFieldId}_color`);
const hexInput = container.querySelector(`#${sanitizedFieldId}_hex`);
if (colorInput && hexInput) {
colorInput.addEventListener('change', (e) => {
hexInput.value = e.target.value;
this.handlers.onChange(fieldId, e.target.value);
});
hexInput.addEventListener('change', (e) => {
if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) {
colorInput.value = e.target.value;
this.handlers.onChange(fieldId, e.target.value);
}
});
}
},
getValue: function(fieldId) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const colorInput = document.querySelector(`#${sanitizedFieldId}_color`);
return colorInput ? colorInput.value : null;
},
setValue: function(fieldId, value) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const colorInput = document.querySelector(`#${sanitizedFieldId}_color`);
const hexInput = document.querySelector(`#${sanitizedFieldId}_hex`);
if (colorInput && hexInput) {
colorInput.value = value;
hexInput.value = value;
}
},
handlers: {
onChange: function(fieldId, value) {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
}
});
Example 2: Slider Widget
window.LEDMatrixWidgets.register('slider', {
name: 'Slider Widget',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = options.fieldId;
// Sanitize fieldId for safe use in DOM IDs and selectors
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const min = config.minimum || 0;
const max = config.maximum || 100;
const step = config.step || 1;
const currentValue = value !== undefined ? value : (config.default || min);
container.innerHTML = `
<div class="slider-widget">
<input type="range"
id="${sanitizedFieldId}_slider"
min="${min}"
max="${max}"
step="${step}"
value="${currentValue}"
class="w-full">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>${min}</span>
<span id="${sanitizedFieldId}_value">${currentValue}</span>
<span>${max}</span>
</div>
</div>
`;
const slider = container.querySelector(`#${sanitizedFieldId}_slider`);
const valueDisplay = container.querySelector(`#${sanitizedFieldId}_value`);
if (slider && valueDisplay) {
slider.addEventListener('input', (e) => {
valueDisplay.textContent = e.target.value;
this.handlers.onChange(fieldId, parseFloat(e.target.value));
});
}
},
getValue: function(fieldId) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const slider = document.querySelector(`#${sanitizedFieldId}_slider`);
return slider ? parseFloat(slider.value) : null;
},
setValue: function(fieldId, value) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const slider = document.querySelector(`#${sanitizedFieldId}_slider`);
const valueDisplay = document.querySelector(`#${sanitizedFieldId}_value`);
if (slider) {
slider.value = value;
if (valueDisplay) {
valueDisplay.textContent = value;
}
}
},
handlers: {
onChange: function(fieldId, value) {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
}
});
Troubleshooting
Widget Not Loading
- Check browser console for errors
- Verify widget file path is correct
- Ensure
LEDMatrixWidgets.register()is called - Check that widget name matches schema
x-widgetvalue
Widget Not Rendering
- Verify
renderfunction is defined - Check container element exists
- Ensure widget is registered before form loads
- Check for JavaScript errors in console
Value Not Saving
- Ensure widget triggers
widget-changeevent - Verify form submission includes widget value
- Check
getValuefunction returns correct type - Verify field name matches schema property
Migration from Server-Side Rendering
Currently, widgets are server-side rendered via Jinja2 templates. The registry system provides:
- Backwards Compatibility: Existing server-side rendered widgets continue to work
- Future Enhancement: Client-side rendering support for custom widgets
- Handler Availability: All widget handlers are available globally
Future versions may support full client-side rendering, but server-side rendering remains the primary method for core widgets.
Support
For questions or issues:
- Check existing widget implementations for examples
- Review browser console for errors
- Test with simple widget first before complex implementations