Files
enviPy-bayer/static/js/alpine/components/schema-form.js
Tobias O dc18b73e08 [Feature] Adds timeseries display (#313)
Adds a way to input/display timeseries data to the additional information

Reviewed-on: enviPath/enviPy#313
Reviewed-by: jebus <lorsbach@envipath.com>
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-02-04 01:01:06 +13:00

379 lines
12 KiB
JavaScript

/**
* 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:
* <div x-data="schemaRenderer({
* rjsf: { schema: {...}, uiSchema: {...}, formData: {...}, groups: [...] },
* data: { interval: { start: 20, end: 25 } },
* mode: 'view', // 'view' | 'edit'
* endpoint: '/api/v1/scenario/{uuid}/information/temperature/'
* })">
*/
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;
}
},
}));
});