[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;
}
}
},
}));
});

View File

@ -4,15 +4,24 @@
* Centralized widget component definitions for dynamic form rendering.
* Each widget receives explicit parameters instead of context object for better traceability.
*/
document.addEventListener('alpine:init', () => {
document.addEventListener("alpine:init", () => {
// Base widget factory with common functionality
const baseWidget = (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
const baseWidget = (
fieldName,
data,
schema,
uiSchema,
fieldErrors,
mode,
debugErrors,
context = null // NEW: context for error namespacing
) => ({
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context, // Store context for use in templates
// Field schema access
get fieldSchema() {
@ -22,165 +31,432 @@ document.addEventListener('alpine:init', () => {
// Common metadata
get label() {
// Check uiSchema first (RJSF format)
if (this.uiSchema?.[this.fieldName]?.['ui:label']) {
return this.uiSchema[this.fieldName]['ui:label'];
if (this.uiSchema?.[this.fieldName]?.["ui:label"]) {
return this.uiSchema[this.fieldName]["ui:label"];
}
// Fall back to schema title
if (this.fieldSchema.title) {
return this.fieldSchema.title;
}
// Default: format field name
return this.fieldName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return this.fieldName
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
get helpText() {
return this.fieldSchema.description || '';
return this.fieldSchema.description || "";
},
// Field-level unit extraction from uiSchema (RJSF format)
get unit() {
return this.uiSchema?.[this.fieldName]?.['ui:unit'] || null;
},
// Error handling
get hasError() {
return !!this.fieldErrors?.[this.fieldName];
},
get errors() {
return this.fieldErrors?.[this.fieldName] || [];
return this.uiSchema?.[this.fieldName]?.["ui:unit"] || null;
},
// Mode checks
get isViewMode() { return this.mode === 'view'; },
get isEditMode() { return this.mode === 'edit'; },
get isViewMode() {
return this.mode === "view";
},
get isEditMode() {
return this.mode === "edit";
},
});
// Text widget
Alpine.data('textWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"textWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Textarea widget
Alpine.data('textareaWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"textareaWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Number widget with unit support
Alpine.data('numberWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"numberWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName]; },
set value(v) {
this.data[this.fieldName] = v === '' || v === null ? null : parseFloat(v);
},
get hasValue() {
return this.value !== null && this.value !== undefined && this.value !== '';
},
// Format value with unit for view mode
get displayValue() {
if (!this.hasValue) return '—';
return this.unit ? `${this.value} ${this.unit}` : String(this.value);
},
}));
get value() {
return this.data[this.fieldName];
},
set value(v) {
this.data[this.fieldName] =
v === "" || v === null ? null : parseFloat(v);
},
get hasValue() {
return (
this.value !== null && this.value !== undefined && this.value !== ""
);
},
// Format value with unit for view mode
get displayValue() {
if (!this.hasValue) return "—";
return this.unit ? `${this.value} ${this.unit}` : String(this.value);
},
}),
);
// Select widget
Alpine.data('selectWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"selectWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
get options() { return this.fieldSchema.enum || []; },
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get options() {
return this.fieldSchema.enum || [];
},
}),
);
// Checkbox widget
Alpine.data('checkboxWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"checkboxWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get checked() { return !!this.data[this.fieldName]; },
set checked(v) { this.data[this.fieldName] = v; },
}));
get checked() {
return !!this.data[this.fieldName];
},
set checked(v) {
this.data[this.fieldName] = v;
},
}),
);
// Interval widget with unit support
Alpine.data('intervalWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"intervalWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get start() {
return this.data[this.fieldName]?.start ?? null;
},
set start(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].start = v === '' || v === null ? null : parseFloat(v);
},
get end() {
return this.data[this.fieldName]?.end ?? null;
},
set end(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].end = v === '' || v === null ? null : parseFloat(v);
},
// Format interval with unit for view mode
get displayValue() {
const s = this.start, e = this.end;
const unitStr = this.unit ? ` ${this.unit}` : '';
get start() {
return this.data[this.fieldName]?.start ?? null;
},
set start(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].start =
v === "" || v === null ? null : parseFloat(v);
},
get end() {
return this.data[this.fieldName]?.end ?? null;
},
set end(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].end =
v === "" || v === null ? null : parseFloat(v);
},
// Format interval with unit for view mode
get displayValue() {
const s = this.start,
e = this.end;
const unitStr = this.unit ? ` ${this.unit}` : "";
if (s !== null && e !== null) return `${s} ${e}${unitStr}`;
if (s !== null) return `${s}${unitStr}`;
if (e !== null) return `${e}${unitStr}`;
return '—';
},
if (s !== null && e !== null) return `${s} ${e}${unitStr}`;
if (s !== null) return `${s}${unitStr}`;
if (e !== null) return `${e}${unitStr}`;
return "—";
},
get isSameValue() {
return this.start !== null && this.start === this.end;
},
get isSameValue() {
return this.start !== null && this.start === this.end;
},
// Validation: start must be <= end
get hasValidationError() {
if (this.isViewMode) return false;
const s = this.start;
const e = this.end;
// Only validate if both values are provided
if (s !== null && e !== null && typeof s === 'number' && typeof e === 'number') {
return s > e;
}
return false;
},
// Override hasError to include validation error
get hasError() {
return this.hasValidationError || !!this.fieldErrors?.[this.fieldName];
},
// Override errors to include validation error message
get errors() {
const serverErrors = this.fieldErrors?.[this.fieldName] || [];
const validationErrors = this.hasValidationError
? ['Start value must be less than or equal to end value']
: [];
return [...validationErrors, ...serverErrors];
},
}));
// Validation: start must be <= end (client-side)
get hasValidationError() {
if (this.isViewMode) return false;
const s = this.start;
const e = this.end;
// Only validate if both values are provided
if (
s !== null &&
e !== null &&
typeof s === "number" &&
typeof e === "number"
) {
return s > e;
}
return false;
},
}),
);
// PubMed link widget
Alpine.data('pubmedWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"pubmedWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
get pubmedUrl() {
return this.value ? `https://pubmed.ncbi.nlm.nih.gov/${this.value}` : null;
},
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get pubmedUrl() {
return this.value
? `https://pubmed.ncbi.nlm.nih.gov/${this.value}`
: null;
},
}),
);
// Compound link widget
Alpine.data('compoundWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"compoundWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// TimeSeries table widget
Alpine.data(
"timeseriesTableWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
chartInstance: null,
// Getter/setter for measurements array
get measurements() {
return this.data[this.fieldName] || [];
},
set measurements(v) {
this.data[this.fieldName] = v;
},
// Get description from sibling field
get description() {
return this.data?.description || "";
},
// Get method from sibling field
get method() {
return this.data?.method || "";
},
// Computed property for chart options
get chartOptions() {
return {
measurements: this.measurements,
xAxisLabel: this.data?.x_axis_label || "Time",
yAxisLabel: this.data?.y_axis_label || "Value",
xAxisUnit: this.data?.x_axis_unit || "",
yAxisUnit: this.data?.y_axis_unit || "",
};
},
// Add new measurement
addMeasurement() {
if (!this.data[this.fieldName]) {
this.data[this.fieldName] = [];
}
this.data[this.fieldName].push({
timestamp: null,
value: null,
error: null,
note: "",
});
},
// Remove measurement by index
removeMeasurement(index) {
if (
this.data[this.fieldName] &&
Array.isArray(this.data[this.fieldName])
) {
this.data[this.fieldName].splice(index, 1);
}
},
// Update specific measurement field
updateMeasurement(index, field, value) {
if (this.data[this.fieldName] && this.data[this.fieldName][index]) {
if (field === "timestamp" || field === "value" || field === "error") {
// Parse all numeric fields (timestamp is days as float)
this.data[this.fieldName][index][field] =
value === "" || value === null ? null : parseFloat(value);
} else {
// Store other fields as-is
this.data[this.fieldName][index][field] = value;
}
}
},
// Format timestamp for display (timestamp is numeric days as float)
formatTimestamp(timestamp) {
return timestamp ?? "";
},
// Sort by timestamp (numeric days)
sortByTimestamp() {
if (
this.data[this.fieldName] &&
Array.isArray(this.data[this.fieldName])
) {
this.data[this.fieldName].sort((a, b) => {
const tsA = a.timestamp ?? Infinity;
const tsB = b.timestamp ?? Infinity;
return tsA - tsB;
});
}
},
// Chart lifecycle methods (delegates to TimeSeriesChart utility)
initChart() {
if (!this.isViewMode || !window.Chart || !window.TimeSeriesChart)
return;
const canvas = this.$refs?.chartCanvas;
if (!canvas) return;
this.destroyChart();
if (this.measurements.length === 0) return;
this.chartInstance = window.TimeSeriesChart.create(
canvas,
this.chartOptions,
);
},
updateChart() {
if (!this.chartInstance || !this.isViewMode) return;
window.TimeSeriesChart.update(
this.chartInstance,
this.measurements,
this.chartOptions,
);
},
destroyChart() {
if (this.chartInstance) {
window.TimeSeriesChart.destroy(this.chartInstance);
this.chartInstance = null;
}
},
// Alpine lifecycle hooks
init() {
if (this.isViewMode && window.Chart) {
// Use $nextTick to ensure DOM is ready
this.$nextTick(() => {
this.initChart();
});
// Watch measurements array for changes and update chart
this.$watch("data." + this.fieldName, () => {
if (this.chartInstance) {
this.updateChart();
}
});
}
},
}),
);
});

View File

@ -32,14 +32,14 @@ window.AdditionalInformationApi = {
sanitizePayload(value) {
if (Array.isArray(value)) {
return value
.map(item => this.sanitizePayload(item))
.filter(item => item !== '');
.map((item) => this.sanitizePayload(item))
.filter((item) => item !== "");
}
if (value && typeof value === 'object') {
if (value && typeof value === "object") {
const cleaned = {};
for (const [key, item] of Object.entries(value)) {
if (item === '') continue;
if (item === "") continue;
cleaned[key] = this.sanitizePayload(item);
}
return cleaned;
@ -53,7 +53,7 @@ window.AdditionalInformationApi = {
* @returns {string} CSRF token
*/
getCsrfToken() {
return document.querySelector('[name=csrf-token]')?.content || '';
return document.querySelector("[name=csrf-token]")?.content || "";
},
/**
@ -62,10 +62,10 @@ window.AdditionalInformationApi = {
*/
_buildHeaders(includeContentType = true) {
const headers = {
'X-CSRFToken': this.getCsrfToken()
"X-CSRFToken": this.getCsrfToken(),
};
if (includeContentType) {
headers['Content-Type'] = 'application/json';
headers["Content-Type"] = "application/json";
}
return headers;
},
@ -85,26 +85,34 @@ window.AdditionalInformationApi = {
// Try to parse the error if it's a JSON string
let parsedError = errorData;
if (typeof errorData.error === 'string' && errorData.error.startsWith('{')) {
const errorStr = errorData.detail || errorData.error;
if (typeof errorStr === "string" && errorStr.startsWith("{")) {
try {
parsedError = JSON.parse(errorData.error);
parsedError = JSON.parse(errorStr);
} catch {
// Not JSON, use as-is
}
}
// If it's a structured validation error, throw with field errors
if (parsedError.type === 'validation_error' && parsedError.field_errors) {
if (parsedError.type === "validation_error" && parsedError.field_errors) {
this._log(`${action} VALIDATION ERROR`, parsedError);
const error = new Error(parsedError.message || 'Validation failed');
const error = new Error(parsedError.message || "Validation failed");
error.fieldErrors = parsedError.field_errors;
error.isValidationError = true;
throw error;
}
// General error
const errorMsg = parsedError.message || parsedError.error || parsedError.detail || `${action} failed: ${response.statusText}`;
this._log(`${action} ERROR`, { status: response.status, error: errorMsg });
const errorMsg =
parsedError.message ||
parsedError.error ||
parsedError.detail ||
`${action} failed: ${response.statusText}`;
this._log(`${action} ERROR`, {
status: response.status,
error: errorMsg,
});
throw new Error(errorMsg);
}
@ -118,9 +126,9 @@ window.AdditionalInformationApi = {
* @returns {Promise<Object>} Object with schema definitions
*/
async loadSchemas() {
this._log('loadSchemas', 'Starting...');
const response = await fetch('/api/v1/information/schema/');
return this._handleResponse(response, 'loadSchemas');
this._log("loadSchemas", "Starting...");
const response = await fetch("/api/v1/information/schema/");
return this._handleResponse(response, "loadSchemas");
},
/**
@ -129,9 +137,11 @@ window.AdditionalInformationApi = {
* @returns {Promise<Array>} Array of additional information items
*/
async loadItems(scenarioUuid) {
this._log('loadItems', { scenarioUuid });
const response = await fetch(`/api/v1/scenario/${scenarioUuid}/information/`);
return this._handleResponse(response, 'loadItems');
this._log("loadItems", { scenarioUuid });
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/`,
);
return this._handleResponse(response, "loadItems");
},
/**
@ -140,11 +150,11 @@ window.AdditionalInformationApi = {
* @returns {Promise<{schemas: Object, items: Array}>}
*/
async loadSchemasAndItems(scenarioUuid) {
this._log('loadSchemasAndItems', { scenarioUuid });
this._log("loadSchemasAndItems", { scenarioUuid });
const [schemas, items] = await Promise.all([
this.loadSchemas(),
this.loadItems(scenarioUuid)
this.loadItems(scenarioUuid),
]);
return { schemas, items };
@ -159,7 +169,7 @@ window.AdditionalInformationApi = {
*/
async createItem(scenarioUuid, modelName, data) {
const sanitizedData = this.sanitizePayload(data);
this._log('createItem', { scenarioUuid, modelName, data: sanitizedData });
this._log("createItem", { scenarioUuid, modelName, data: sanitizedData });
// Normalize model name to lowercase
const normalizedName = modelName.toLowerCase();
@ -167,13 +177,13 @@ window.AdditionalInformationApi = {
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/${normalizedName}/`,
{
method: 'POST',
method: "POST",
headers: this._buildHeaders(),
body: JSON.stringify(sanitizedData)
}
body: JSON.stringify(sanitizedData),
},
);
return this._handleResponse(response, 'createItem');
return this._handleResponse(response, "createItem");
},
/**
@ -183,17 +193,17 @@ window.AdditionalInformationApi = {
* @returns {Promise<{status: string}>}
*/
async deleteItem(scenarioUuid, itemUuid) {
this._log('deleteItem', { scenarioUuid, itemUuid });
this._log("deleteItem", { scenarioUuid, itemUuid });
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/item/${itemUuid}/`,
{
method: 'DELETE',
headers: this._buildHeaders(false)
}
method: "DELETE",
headers: this._buildHeaders(false),
},
);
return this._handleResponse(response, 'deleteItem');
return this._handleResponse(response, "deleteItem");
},
/**
@ -205,7 +215,10 @@ window.AdditionalInformationApi = {
*/
async updateItem(scenarioUuid, item) {
const sanitizedData = this.sanitizePayload(item.data);
this._log('updateItem', { scenarioUuid, item: { ...item, data: sanitizedData } });
this._log("updateItem", {
scenarioUuid,
item: { ...item, data: sanitizedData },
});
const { uuid, type } = item;
@ -213,20 +226,23 @@ window.AdditionalInformationApi = {
const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/item/${uuid}/`,
{
method: 'PATCH',
method: "PATCH",
headers: this._buildHeaders(),
body: JSON.stringify(sanitizedData)
}
body: JSON.stringify(sanitizedData),
},
);
if (response.status === 405) {
// PATCH not supported, fall back to delete+recreate
this._log('updateItem', 'PATCH not supported, falling back to delete+recreate');
this._log(
"updateItem",
"PATCH not supported, falling back to delete+recreate",
);
await this.deleteItem(scenarioUuid, uuid);
return await this.createItem(scenarioUuid, type, sanitizedData);
}
return this._handleResponse(response, 'updateItem');
return this._handleResponse(response, "updateItem");
},
/**
@ -236,38 +252,50 @@ window.AdditionalInformationApi = {
* @returns {Promise<Array>} Array of results with success status
*/
async updateItems(scenarioUuid, items) {
this._log('updateItems', { scenarioUuid, itemCount: items.length });
this._log("updateItems", { scenarioUuid, itemCount: items.length });
const results = [];
for (const item of items) {
try {
const result = await this.updateItem(scenarioUuid, item);
results.push({ success: true, oldUuid: item.uuid, newUuid: result.uuid });
results.push({
success: true,
oldUuid: item.uuid,
newUuid: result.uuid,
});
} catch (error) {
results.push({
success: false,
oldUuid: item.uuid,
error: error.message,
fieldErrors: error.fieldErrors,
isValidationError: error.isValidationError
isValidationError: error.isValidationError,
});
}
}
const failed = results.filter(r => !r.success);
const failed = results.filter((r) => !r.success);
if (failed.length > 0) {
// If all failures are validation errors, throw a validation error
const validationErrors = failed.filter(f => f.isValidationError);
if (validationErrors.length === failed.length && failed.length === 1) {
// Single validation error - preserve field errors for display
const error = new Error(failed[0].error);
error.fieldErrors = failed[0].fieldErrors;
// If all failures are validation errors, return all validation errors for display
const validationErrors = failed.filter((f) => f.isValidationError);
if (validationErrors.length === failed.length) {
// All failures are validation errors - return all field errors by item UUID
const allFieldErrors = {};
validationErrors.forEach((ve) => {
allFieldErrors[ve.oldUuid] = ve.fieldErrors || {};
});
const error = new Error(
`${failed.length} item(s) have validation errors. Please correct them.`,
);
error.fieldErrors = allFieldErrors; // Map of UUID -> field errors
error.isValidationError = true;
error.itemUuid = failed[0].oldUuid;
error.isMultipleErrors = true; // Flag indicating multiple items have errors
throw error;
}
// Multiple failures or mixed errors - show count
throw new Error(`Failed to update ${failed.length} item(s). Please check the form for errors.`);
throw new Error(
`Failed to update ${failed.length} item(s). Please check the form for errors.`,
);
}
return results;
@ -285,16 +313,13 @@ window.AdditionalInformationApi = {
* @returns {Promise<{uuid, url, name, description, review_status, package}>}
*/
async createScenario(packageUuid, payload) {
this._log('createScenario', { packageUuid, payload });
const response = await fetch(
`/api/v1/package/${packageUuid}/scenario/`,
{
method: 'POST',
headers: this._buildHeaders(),
body: JSON.stringify(payload)
}
);
return this._handleResponse(response, 'createScenario');
this._log("createScenario", { packageUuid, payload });
const response = await fetch(`/api/v1/package/${packageUuid}/scenario/`, {
method: "POST",
headers: this._buildHeaders(),
body: JSON.stringify(payload),
});
return this._handleResponse(response, "createScenario");
},
/**
@ -302,9 +327,9 @@ window.AdditionalInformationApi = {
* @returns {Promise<{groups: string[]}>}
*/
async loadGroups() {
this._log('loadGroups', 'Starting...');
const response = await fetch('/api/v1/information/groups/');
return this._handleResponse(response, 'loadGroups');
this._log("loadGroups", "Starting...");
const response = await fetch("/api/v1/information/groups/");
return this._handleResponse(response, "loadGroups");
},
/**
@ -313,7 +338,7 @@ window.AdditionalInformationApi = {
* @returns {Promise<Object>} Object with subcategories as keys and arrays of model info
*/
async loadGroupModels(groupName) {
this._log('loadGroupModels', { groupName });
this._log("loadGroupModels", { groupName });
const response = await fetch(`/api/v1/information/groups/${groupName}/`);
return this._handleResponse(response, `loadGroupModels-${groupName}`);
},
@ -323,8 +348,8 @@ window.AdditionalInformationApi = {
* @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment']
* @returns {Promise<Object>} Object with group names as keys
*/
async loadGroupsWithModels(groupNames = ['soil', 'sludge', 'sediment']) {
this._log('loadGroupsWithModels', { groupNames });
async loadGroupsWithModels(groupNames = ["soil", "sludge", "sediment"]) {
this._log("loadGroupsWithModels", { groupNames });
const results = {};
const promises = groupNames.map(async (groupName) => {
@ -347,9 +372,9 @@ window.AdditionalInformationApi = {
* @returns {Object} Object with group names as keys and filtered schemas as values
*/
organizeSchemasByGroup(schemas, groupModelsData) {
this._log('organizeSchemasByGroup', {
this._log("organizeSchemasByGroup", {
schemaCount: Object.keys(schemas).length,
groupCount: Object.keys(groupModelsData).length
groupCount: Object.keys(groupModelsData).length,
});
const organized = {};
@ -377,18 +402,18 @@ window.AdditionalInformationApi = {
* @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment']
* @returns {Promise<{schemas, groupSchemas, groupModels}>}
*/
async loadSchemasWithGroups(groupNames = ['soil', 'sludge', 'sediment']) {
this._log('loadSchemasWithGroups', { groupNames });
async loadSchemasWithGroups(groupNames = ["soil", "sludge", "sediment"]) {
this._log("loadSchemasWithGroups", { groupNames });
// Load schemas and all groups in parallel
const [schemas, groupModels] = await Promise.all([
this.loadSchemas(),
this.loadGroupsWithModels(groupNames)
this.loadGroupsWithModels(groupNames),
]);
// Organize schemas by group
const groupSchemas = this.organizeSchemasByGroup(schemas, groupModels);
return { schemas, groupSchemas, groupModels };
}
},
};

View File

@ -0,0 +1,351 @@
/**
* TimeSeriesChart Utility
*
* Provides chart rendering capabilities for time series data with error bounds.
* Uses Chart.js to create interactive and static visualizations.
*
* Usage:
* const chart = window.TimeSeriesChart.create(canvas, {
* measurements: [...],
* xAxisLabel: "Time",
* yAxisLabel: "Concentration",
* xAxisUnit: "days",
* yAxisUnit: "mg/L"
* });
*
* window.TimeSeriesChart.update(chart, newMeasurements, options);
* window.TimeSeriesChart.destroy(chart);
*/
window.TimeSeriesChart = {
// === PUBLIC API ===
/**
* Create an interactive time series chart
*
* @param {HTMLCanvasElement} canvas - Canvas element to render chart on
* @param {Object} options - Chart configuration options
* @param {Array} options.measurements - Array of measurement objects with timestamp, value, error, note
* @param {string} options.xAxisLabel - Label for x-axis (default: "Time")
* @param {string} options.yAxisLabel - Label for y-axis (default: "Value")
* @param {string} options.xAxisUnit - Unit for x-axis (default: "")
* @param {string} options.yAxisUnit - Unit for y-axis (default: "")
* @returns {Chart|null} Chart.js instance or null if creation failed
*/
create(canvas, options = {}) {
if (!this._validateCanvas(canvas)) return null;
if (!window.Chart) {
console.warn("Chart.js is not loaded");
return null;
}
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const chartData = this._transformData(options.measurements || [], options);
if (chartData.datasets.length === 0) {
return null; // No data to display
}
const config = this._buildConfig(chartData, options);
return new Chart(ctx, config);
},
/**
* Update an existing chart with new data
*
* @param {Chart} chartInstance - Chart.js instance to update
* @param {Array} measurements - New measurements array
* @param {Object} options - Chart configuration options
*/
update(chartInstance, measurements, options = {}) {
if (!chartInstance) return;
const chartData = this._transformData(measurements || [], options);
chartInstance.data.datasets = chartData.datasets;
chartInstance.options.scales.x.title.text = chartData.xAxisLabel;
chartInstance.options.scales.y.title.text = chartData.yAxisLabel;
chartInstance.update("none");
},
/**
* Destroy chart instance and cleanup
*
* @param {Chart} chartInstance - Chart.js instance to destroy
*/
destroy(chartInstance) {
if (chartInstance && typeof chartInstance.destroy === "function") {
chartInstance.destroy();
}
},
// === PRIVATE HELPERS ===
/**
* Transform measurements into Chart.js datasets
* @private
*/
_transformData(measurements, options) {
const preparedData = this._prepareData(measurements);
if (preparedData.length === 0) {
return { datasets: [], xAxisLabel: "Time", yAxisLabel: "Value" };
}
const xAxisLabel = options.xAxisLabel || "Time";
const yAxisLabel = options.yAxisLabel || "Value";
const xAxisUnit = options.xAxisUnit || "";
const yAxisUnit = options.yAxisUnit || "";
const datasets = [];
// Error bounds datasets FIRST (if errors exist) - renders as background
const errorDatasets = this._buildErrorDatasets(preparedData);
if (errorDatasets.length > 0) {
datasets.push(...errorDatasets);
}
// Main line dataset LAST - renders on top
datasets.push(this._buildMainDataset(preparedData, yAxisLabel));
return {
datasets: datasets,
xAxisLabel: this._formatAxisLabel(xAxisLabel, xAxisUnit),
yAxisLabel: this._formatAxisLabel(yAxisLabel, yAxisUnit),
};
},
/**
* Prepare and validate measurements data
* @private
*/
_prepareData(measurements) {
return measurements
.filter(
(m) => m.timestamp != null && m.value != null,
)
.map((m) => {
// Normalize timestamp - handle both numeric and date strings
let timestamp;
if (typeof m.timestamp === "number") {
timestamp = m.timestamp;
} else {
timestamp = new Date(m.timestamp).getTime();
}
return {
...m,
timestamp: timestamp,
};
})
.sort((a, b) => a.timestamp - b.timestamp);
},
/**
* Build main line dataset
* @private
*/
_buildMainDataset(validMeasurements, yAxisLabel) {
return {
label: yAxisLabel,
data: validMeasurements.map((m) => ({
x: m.timestamp,
y: m.value,
error: m.error || null,
})),
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
tension: 0.1,
pointRadius: 0, // Hide individual points
pointHoverRadius: 6,
fill: false,
};
},
/**
* Build error bound datasets (upper and lower)
* @private
*/
_buildErrorDatasets(validMeasurements) {
const hasErrors = validMeasurements.some(
(m) => m.error !== null && m.error !== undefined && m.error > 0,
);
if (!hasErrors) return [];
const measurementsWithErrors = validMeasurements.filter(
(m) => m.error !== null && m.error !== undefined && m.error > 0,
);
return [
// Lower error bound - FIRST (bottom layer)
{
label: "Error (lower)",
data: measurementsWithErrors.map((m) => ({
x: m.timestamp,
y: m.value - m.error,
})),
borderColor: "rgba(59, 130, 246, 0.3)",
backgroundColor: "rgba(59, 130, 246, 0.15)",
pointRadius: 0,
fill: false,
tension: 0.1,
},
// Upper error bound - SECOND (fill back to lower)
{
label: "Error (upper)",
data: measurementsWithErrors.map((m) => ({
x: m.timestamp,
y: m.value + m.error,
})),
borderColor: "rgba(59, 130, 246, 0.3)",
backgroundColor: "rgba(59, 130, 246, 0.15)",
pointRadius: 0,
fill: "-1", // Fill back to previous dataset (lower bound)
tension: 0.1,
},
];
},
/**
* Build complete Chart.js configuration
* @private
*/
_buildConfig(chartData, options) {
return {
type: "line",
data: { datasets: chartData.datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: false,
},
tooltip: this._buildTooltipConfig(
options.xAxisUnit || "",
options.yAxisUnit || "",
),
},
scales: this._buildScalesConfig(
chartData.xAxisLabel,
chartData.yAxisLabel,
),
},
};
},
/**
* Build tooltip configuration with custom callbacks
* @private
*/
_buildTooltipConfig(xAxisUnit, yAxisUnit) {
return {
enabled: true,
callbacks: {
title: (contexts) => {
// Show timestamp
const context = contexts[0];
if (!context) return "Measurement";
const timestamp = context.parsed.x;
return xAxisUnit
? `Time: ${timestamp} ${xAxisUnit}`
: `Time: ${timestamp}`;
},
label: (context) => {
// Show value with unit
try {
const value = context.parsed.y;
if (value === null || value === undefined) {
return `${context.dataset.label || "Value"}: N/A`;
}
const valueStr = yAxisUnit
? `${value} ${yAxisUnit}`
: String(value);
return `${context.dataset.label || "Value"}: ${valueStr}`;
} catch (e) {
console.error("Tooltip label error:", e);
return `${context.dataset.label || "Value"}: ${context.parsed.y ?? "N/A"}`;
}
},
afterLabel: (context) => {
// Show error information
try {
const point = context.raw;
// Main line is now the last dataset (after error bounds if they exist)
const isMainDataset = context.dataset.label &&
!context.dataset.label.startsWith("Error");
if (!point || !isMainDataset) return null;
const lines = [];
// Show error if available
if (
point.error !== null &&
point.error !== undefined &&
point.error > 0
) {
const errorStr = yAxisUnit
? `±${point.error.toFixed(4)} ${yAxisUnit}`
: `±${point.error.toFixed(4)}`;
lines.push(`Error: ${errorStr}`);
}
return lines.length > 0 ? lines : null;
} catch (e) {
console.error("Tooltip afterLabel error:", e);
return null;
}
},
},
};
},
/**
* Build scales configuration
* @private
*/
_buildScalesConfig(xAxisLabel, yAxisLabel) {
return {
x: {
type: "linear",
title: {
display: true,
text: xAxisLabel || "Time",
},
},
y: {
title: {
display: true,
text: yAxisLabel || "Value",
},
},
};
},
/**
* Format axis label with unit
* @private
*/
_formatAxisLabel(label, unit) {
return unit ? `${label} (${unit})` : label;
},
/**
* Validate canvas element
* @private
*/
_validateCanvas(canvas) {
if (!canvas || !(canvas instanceof HTMLCanvasElement)) {
console.warn("Invalid canvas element provided to TimeSeriesChart");
return false;
}
return true;
},
};