[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>
This commit is contained in:
2026-02-04 01:01:06 +13:00
committed by jebus
parent d80dfb5ee3
commit dc18b73e08
23 changed files with 1772 additions and 411 deletions

View File

@ -12,33 +12,84 @@
* endpoint: '/api/v1/scenario/{uuid}/information/temperature/'
* })">
*/
document.addEventListener('alpine:init', () => {
Alpine.data('schemaRenderer', (options = {}) => ({
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 || '',
mode: options.mode || "view", // 'view' | 'edit'
endpoint: options.endpoint || "",
loading: false,
error: null,
fieldErrors: {}, // Server-side field-level errors
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() {
// 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;
@ -50,31 +101,31 @@ document.addEventListener('alpine:init', () => {
// RJSF format: {schema, uiSchema, formData, groups}
if (!rjsf.schema) {
throw new Error('Invalid RJSF format: missing schema property');
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 || {});
: rjsf.formData || {};
} catch (err) {
this.error = err.message;
console.error('Error loading schema:', err);
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');
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 || {});
: options.rjsf.formData || {};
}
// Initialize data from formData or options
@ -84,19 +135,22 @@ document.addEventListener('alpine:init', () => {
// Ensure all schema fields are properly initialized
if (this.schema && this.schema.properties) {
for (const [key, propSchema] of Object.entries(this.schema.properties)) {
for (const [key, propSchema] of Object.entries(
this.schema.properties,
)) {
const widget = this.getWidget(key, propSchema);
if (widget === 'interval') {
if (widget === "interval") {
// Ensure interval fields are objects with start/end
if (!this.data[key] || typeof this.data[key] !== 'object') {
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].start === undefined)
this.data[key].start = null;
if (this.data[key].end === undefined) this.data[key].end = null;
}
} else if (widget === 'timeseries-table') {
} else if (widget === "timeseries-table") {
// Ensure timeseries fields are arrays
if (!this.data[key] || !Array.isArray(this.data[key])) {
this.data[key] = [];
@ -104,86 +158,141 @@ document.addEventListener('alpine:init', () => {
} 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') {
if (propSchema.type === "boolean") {
this.data[key] = false;
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
} 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] = '';
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) {
// Check uiSchema first (RJSF format)
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:widget']) {
return this.uiSchema[fieldName]['ui:widget'];
}
// Defensive check: ensure fieldSchema is provided
if (!fieldSchema) return "text";
// Check for interval type (object with start/end properties)
if (fieldSchema.type === 'object' &&
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';
}
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';
// 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'];
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;
return fieldName
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
getFieldOrder() {
// Get ordered list of field names based on ui:order
if (!this.schema || !this.schema.properties) return [];
try {
// Get ordered list of field names based on ui:order
if (!this.schema || !this.schema.properties) return [];
const fields = Object.keys(this.schema.properties);
// 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);
});
// 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');
console.error("No endpoint specified for submission");
return;
}
@ -191,14 +300,15 @@ document.addEventListener('alpine:init', () => {
this.error = null;
try {
const csrftoken = document.querySelector("[name=csrf-token]")?.content || '';
const csrftoken =
document.querySelector("[name=csrf-token]")?.content || "";
const res = await fetch(this.endpoint, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
"Content-Type": "application/json",
"X-CSRFToken": csrftoken,
},
body: JSON.stringify(this.data)
body: JSON.stringify(this.data),
});
if (!res.ok) {
@ -210,35 +320,50 @@ document.addEventListener('alpine:init', () => {
}
// Handle validation errors (field-level)
this.fieldErrors = {};
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('{')) {
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 (!this.fieldErrors[field]) {
this.fieldErrors[field] = [];
const field =
err.loc && err.loc.length > 0
? err.loc[err.loc.length - 1]
: "root";
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
this.fieldErrors[field].push(err.msg || err.message || 'Validation error');
fieldErrors[field].push(
err.msg || err.message || "Validation error",
);
}
throw new Error('Validation failed. Please check the fields below.');
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}`);
throw new Error(
parsedError.error ||
parsedError.detail ||
`Request failed: ${res.statusText}`,
);
}
}
// Clear errors on success
this.fieldErrors = {};
Alpine.store('validationErrors').clearErrors();
const result = await res.json();
return result;
@ -248,6 +373,6 @@ document.addEventListener('alpine:init', () => {
} finally {
this.loading = false;
}
}
},
}));
});