/** * Alpine.js Schema Renderer Component * * Renders forms dynamically from JSON Schema with RJSF format support. * Supports uiSchema for widget hints, labels, help text, and field ordering. * * Usage: *
*/ document.addEventListener('alpine:init', () => { Alpine.data('schemaRenderer', (options = {}) => ({ schema: null, uiSchema: {}, data: {}, mode: options.mode || 'view', // 'view' | 'edit' endpoint: options.endpoint || '', loading: false, error: null, fieldErrors: {}, // Server-side field-level errors async init() { // Listen for field error events from parent modal window.addEventListener('set-field-errors', (e) => { // Apply to all forms (used by add modal which has only one form) this.fieldErrors = e.detail || {}; }); // Listen for field error events targeted to a specific item (for update modal) window.addEventListener('set-field-errors-for-item', (e) => { // Only update if this form matches the UUID const itemData = options.data || {}; if (itemData.uuid === e.detail?.uuid) { this.fieldErrors = e.detail.fieldErrors || {}; } }); if (options.schemaUrl) { try { this.loading = true; const res = await fetch(options.schemaUrl); if (!res.ok) { throw new Error(`Failed to load schema: ${res.statusText}`); } const rjsf = await res.json(); // RJSF format: {schema, uiSchema, formData, groups} if (!rjsf.schema) { throw new Error('Invalid RJSF format: missing schema property'); } this.schema = rjsf.schema; this.uiSchema = rjsf.uiSchema || {}; this.data = options.data ? JSON.parse(JSON.stringify(options.data)) : (rjsf.formData || {}); } catch (err) { this.error = err.message; console.error('Error loading schema:', err); } finally { this.loading = false; } } else if (options.rjsf) { // Direct RJSF object passed if (!options.rjsf.schema) { throw new Error('Invalid RJSF format: missing schema property'); } this.schema = options.rjsf.schema; this.uiSchema = options.rjsf.uiSchema || {}; this.data = options.data ? JSON.parse(JSON.stringify(options.data)) : (options.rjsf.formData || {}); } // Initialize data from formData or options if (!this.data || Object.keys(this.data).length === 0) { this.data = {}; } // Ensure all schema fields are properly initialized if (this.schema && this.schema.properties) { for (const [key, propSchema] of Object.entries(this.schema.properties)) { const widget = this.getWidget(key, propSchema); if (widget === 'interval') { // Ensure interval fields are objects with start/end if (!this.data[key] || typeof this.data[key] !== 'object') { this.data[key] = { start: null, end: null }; } else { // Ensure start and end exist if (this.data[key].start === undefined) this.data[key].start = null; if (this.data[key].end === undefined) this.data[key].end = null; } } else if (widget === 'timeseries-table') { // Ensure timeseries fields are arrays if (!this.data[key] || !Array.isArray(this.data[key])) { this.data[key] = []; } } else if (this.data[key] === undefined) { // ONLY initialize if truly undefined, not just falsy // This preserves empty strings, null, 0, false as valid values if (propSchema.type === 'boolean') { this.data[key] = false; } else if (propSchema.type === 'number' || propSchema.type === 'integer') { this.data[key] = null; } else if (propSchema.enum) { // For select fields, use null to show placeholder this.data[key] = null; } else { this.data[key] = ''; } } // If data[key] exists (even if empty string or null), don't overwrite } } }, getWidget(fieldName, fieldSchema) { // Check uiSchema first (RJSF format) if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:widget']) { return this.uiSchema[fieldName]['ui:widget']; } // Check for interval type (object with start/end properties) if (fieldSchema.type === 'object' && fieldSchema.properties && fieldSchema.properties.start && fieldSchema.properties.end) { return 'interval'; } // Infer from JSON Schema type if (fieldSchema.enum) return 'select'; if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') return 'number'; if (fieldSchema.type === 'boolean') return 'checkbox'; return 'text'; }, getLabel(fieldName, fieldSchema) { // Check uiSchema (RJSF format) if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:label']) { return this.uiSchema[fieldName]['ui:label']; } // Default: format field name return fieldName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); }, getHelp(fieldName) { // Get help text from uiSchema if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:help']) { return this.uiSchema[fieldName]['ui:help']; } return null; }, getPlaceholder(fieldName) { // Get placeholder from uiSchema if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:placeholder']) { return this.uiSchema[fieldName]['ui:placeholder']; } return null; }, getFieldOrder() { // Get ordered list of field names based on ui:order if (!this.schema || !this.schema.properties) return []; const fields = Object.keys(this.schema.properties); // Sort by ui:order if available return fields.sort((a, b) => { const orderA = this.uiSchema[a]?.['ui:order'] || '999'; const orderB = this.uiSchema[b]?.['ui:order'] || '999'; return parseInt(orderA) - parseInt(orderB); }); }, async submit() { if (!this.endpoint) { console.error('No endpoint specified for submission'); return; } this.loading = true; this.error = null; try { const csrftoken = document.querySelector("[name=csrf-token]")?.content || ''; const res = await fetch(this.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken }, body: JSON.stringify(this.data) }); if (!res.ok) { let errorData; try { errorData = await res.json(); } catch { errorData = { error: res.statusText }; } // Handle validation errors (field-level) this.fieldErrors = {}; // Try to parse structured error response let parsedError = errorData; // If error is a JSON string, parse it if (typeof errorData.error === 'string' && errorData.error.startsWith('{')) { parsedError = JSON.parse(errorData.error); } if (parsedError.detail && Array.isArray(parsedError.detail)) { // Pydantic validation errors format: [{loc: ['field'], msg: '...', type: '...'}] for (const err of parsedError.detail) { const field = err.loc && err.loc.length > 0 ? err.loc[err.loc.length - 1] : 'root'; if (!this.fieldErrors[field]) { this.fieldErrors[field] = []; } this.fieldErrors[field].push(err.msg || err.message || 'Validation error'); } throw new Error('Validation failed. Please check the fields below.'); } else { // General error throw new Error(parsedError.error || parsedError.detail || `Request failed: ${res.statusText}`); } } // Clear errors on success this.fieldErrors = {}; const result = await res.json(); return result; } catch (err) { this.error = err.message; throw err; } finally { this.loading = false; } } })); });