forked from enviPath/enviPy
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>
379 lines
12 KiB
JavaScript
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;
|
|
}
|
|
},
|
|
}));
|
|
});
|