/** * 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", () => { // Global validation error store with context scoping Alpine.store('validationErrors', { errors: {}, // Set errors for a specific context (UUID) or globally (no context) setErrors(errors, context = null) { if (context) { // Namespace all field names with context prefix const namespacedErrors = {}; Object.entries(errors).forEach(([field, messages]) => { const key = `${context}.${field}`; namespacedErrors[key] = messages; }); // Merge into existing errors (preserves other contexts) this.errors = { ...this.errors, ...namespacedErrors }; } else { // No context - merge as-is for backward compatibility this.errors = { ...this.errors, ...errors }; } }, // Clear errors for a specific context or all errors clearErrors(context = null) { if (context) { // Clear only errors for this context const newErrors = {}; const prefix = `${context}.`; Object.keys(this.errors).forEach(key => { if (!key.startsWith(prefix)) { newErrors[key] = this.errors[key]; } }); this.errors = newErrors; } else { // Clear all errors this.errors = {}; } }, // Clear a specific field, optionally within a context clearField(fieldName, context = null) { const key = context ? `${context}.${fieldName}` : fieldName; if (this.errors[key]) { delete this.errors[key]; // Trigger reactivity by creating new object this.errors = { ...this.errors }; } }, // Check if a field has errors, optionally within a context hasError(fieldName, context = null) { const key = context ? `${context}.${fieldName}` : fieldName; return Array.isArray(this.errors[key]) && this.errors[key].length > 0; }, // Get errors for a field, optionally within a context getErrors(fieldName, context = null) { const key = context ? `${context}.${fieldName}` : fieldName; return this.errors[key] || []; } }); Alpine.data("schemaRenderer", (options = {}) => ({ schema: null, uiSchema: {}, data: {}, mode: options.mode || "view", // 'view' | 'edit' endpoint: options.endpoint || "", loading: false, error: null, context: options.context || null, // UUID for items, null for single forms debugErrors: options.debugErrors ?? (typeof window !== "undefined" && window.location?.search?.includes("debugErrors=1")), async init() { 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 } } // UX: Clear field errors when fields change (with context) if (this.mode === "edit" && this.schema?.properties) { Object.keys(this.schema.properties).forEach((key) => { this.$watch( `data.${key}`, () => { Alpine.store('validationErrors').clearField(key, this.context); }, { deep: true }, ); }); } }, getWidget(fieldName, fieldSchema) { // Defensive check: ensure fieldSchema is provided if (!fieldSchema) return "text"; try { // Check uiSchema first (RJSF format) if ( this.uiSchema && 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"; } // Check for measurements array type (timeseries-table widget) if ( fieldSchema.type === "array" && fieldSchema.items?.properties?.timestamp && fieldSchema.items?.properties?.value ) { return "timeseries-table"; } // 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"; } catch (e) { // Fallback to text widget if anything fails console.warn("Error in getWidget:", e); 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()); }, getFieldOrder() { try { // Get ordered list of field names based on ui:order if (!this.schema || !this.schema.properties) return []; // Only include fields that have UI configs const fields = Object.keys(this.schema.properties).filter( (fieldName) => this.uiSchema && this.uiSchema[fieldName], ); // 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); }); } catch (e) { // Return empty array if anything fails to prevent errors console.warn("Error in getFieldOrder:", e); return []; } }, hasTimeseriesField() { try { // Check if any field in the schema is a timeseries-table widget if (!this.schema || !this.schema.properties) { return false; } return Object.keys(this.schema.properties).some((fieldName) => { const fieldSchema = this.schema.properties[fieldName]; if (!fieldSchema) return false; return this.getWidget(fieldName, fieldSchema) === "timeseries-table"; }); } catch (e) { // Return false if anything fails to prevent errors console.warn("Error in hasTimeseriesField:", e); return false; } }, 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) Alpine.store('validationErrors').clearErrors(); // 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: '...'}] const fieldErrors = {}; for (const err of parsedError.detail) { const field = err.loc && err.loc.length > 0 ? err.loc[err.loc.length - 1] : "root"; if (!fieldErrors[field]) { fieldErrors[field] = []; } fieldErrors[field].push( err.msg || err.message || "Validation error", ); } Alpine.store('validationErrors').setErrors(fieldErrors); 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 Alpine.store('validationErrors').clearErrors(); const result = await res.json(); return result; } catch (err) { this.error = err.message; throw err; } finally { this.loading = false; } }, })); });