* fix(web): handle dotted keys in schema/config path helpers Schema property names containing dots (e.g. "eng.1" for Premier League in soccer-scoreboard) were being incorrectly split on the dot separator in two path-navigation helpers: - _get_schema_property: split "leagues.eng.1.favorite_teams" into 4 segments and looked for "eng" in leagues.properties, which doesn't exist (the key is literally "eng.1"). Returned None, so the field type was unknown and values were not parsed correctly. - _set_nested_value: split the same path into 4 segments and created config["leagues"]["eng"]["1"]["favorite_teams"] instead of the correct config["leagues"]["eng.1"]["favorite_teams"]. Both functions now use a greedy longest-match approach: at each level they try progressively longer dot-joined candidates first (e.g. "eng.1" before "eng"), so dotted property names are handled transparently. Fixes favorite_teams (and other per-league fields) not saving via the soccer-scoreboard plugin config UI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: remove debug artifacts from merged branches - Replace print() with logger.warning() for three error handlers in api_v3.py that bypassed the structured logging infrastructure - Simplify dead if/else in loadInstalledPlugins() — both branches did the same window.installedPlugins assignment; collapse to single line - Remove console.log registration line from schedule-picker widget that fired unconditionally on every page load Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.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