[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

@ -0,0 +1 @@
"""Tests for epapi utility modules."""

View File

@ -0,0 +1,222 @@
"""
Tests for validation error utilities.
Tests the format_validation_error() and handle_validation_error() functions
that transform Pydantic validation errors into user-friendly messages.
"""
from django.test import TestCase, tag
import json
from pydantic import BaseModel, ValidationError, field_validator
from typing import Literal
from ninja.errors import HttpError
from epapi.utils.validation_errors import format_validation_error, handle_validation_error
@tag("api", "utils")
class ValidationErrorUtilityTests(TestCase):
"""Test validation error utility functions."""
def test_format_missing_field_error(self):
"""Test formatting of missing required field error."""
# Create a model with required field
class TestModel(BaseModel):
required_field: str
# Trigger validation error
try:
TestModel()
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, "This field is required")
def test_format_enum_error(self):
"""Test formatting of enum validation error."""
class TestModel(BaseModel):
status: Literal["active", "inactive"]
try:
TestModel(status="invalid")
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
# Literal errors get formatted as "Please enter ..." with the valid options
self.assertIn("Please enter", formatted)
self.assertIn("active", formatted)
self.assertIn("inactive", formatted)
def test_format_string_type_error(self):
"""Test formatting of string type validation error."""
class TestModel(BaseModel):
name: str
try:
TestModel(name=123)
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, "Please enter a valid string")
def test_format_int_type_error(self):
"""Test formatting of integer type validation error."""
class TestModel(BaseModel):
count: int
try:
TestModel(count="not_a_number")
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, "Please enter a valid int")
def test_format_float_type_error(self):
"""Test formatting of float type validation error."""
class TestModel(BaseModel):
value: float
try:
TestModel(value="not_a_float")
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, "Please enter a valid float")
def test_format_value_error(self):
"""Test formatting of value error from custom validator."""
class TestModel(BaseModel):
age: int
@field_validator("age")
@classmethod
def validate_age(cls, v):
if v < 0:
raise ValueError("Age must be positive")
return v
try:
TestModel(age=-5)
except ValidationError as e:
errors = e.errors()
self.assertEqual(len(errors), 1)
formatted = format_validation_error(errors[0])
self.assertEqual(formatted, "Age must be positive")
def test_handle_validation_error_structure(self):
"""Test that handle_validation_error raises HttpError with correct structure."""
class TestModel(BaseModel):
name: str
count: int
try:
TestModel(name=123, count="invalid")
except ValidationError as e:
# handle_validation_error should raise HttpError
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
self.assertEqual(http_error.status_code, 400)
# Parse the JSON from the error message
error_data = json.loads(http_error.message)
# Check structure
self.assertEqual(error_data["type"], "validation_error")
self.assertIn("field_errors", error_data)
self.assertIn("message", error_data)
self.assertEqual(error_data["message"], "Please correct the errors below")
# Check that both fields have errors
self.assertIn("name", error_data["field_errors"])
self.assertIn("count", error_data["field_errors"])
def test_handle_validation_error_no_pydantic_internals(self):
"""Test that handle_validation_error doesn't expose Pydantic internals."""
class TestModel(BaseModel):
email: str
try:
TestModel(email=123)
except ValidationError as e:
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
error_data = json.loads(http_error.message)
error_str = json.dumps(error_data)
# Ensure no Pydantic internals are exposed
self.assertNotIn("pydantic", error_str.lower())
self.assertNotIn("https://errors.pydantic.dev", error_str)
self.assertNotIn("loc", error_str)
def test_handle_validation_error_user_friendly_messages(self):
"""Test that all error messages are user-friendly."""
class TestModel(BaseModel):
name: str
age: int
status: Literal["active", "inactive"]
try:
TestModel(name=123, status="invalid") # Multiple errors
except ValidationError as e:
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
error_data = json.loads(http_error.message)
# All messages should be user-friendly (contain "Please" or "This field")
for field, messages in error_data["field_errors"].items():
for message in messages:
# User-friendly messages start with "Please" or "This field"
self.assertTrue(
message.startswith("Please") or message.startswith("This field"),
f"Message '{message}' is not user-friendly",
)
def test_handle_validation_error_multiple_errors_same_field(self):
"""Test handling multiple validation errors for the same field."""
class TestModel(BaseModel):
value: int
@field_validator("value")
@classmethod
def validate_range(cls, v):
if v < 0:
raise ValueError("Must be non-negative")
if v > 100:
raise ValueError("Must be at most 100")
return v
# Test with string (type error) - this will fail before the validator runs
try:
TestModel(value="invalid")
except ValidationError as e:
with self.assertRaises(HttpError) as context:
handle_validation_error(e)
http_error = context.exception
error_data = json.loads(http_error.message)
# Should have error for 'value' field
self.assertIn("value", error_data["field_errors"])
self.assertIsInstance(error_data["field_errors"]["value"], list)
self.assertGreater(len(error_data["field_errors"]["value"]), 0)

View File

@ -252,6 +252,51 @@ class AdditionalInformationAPITests(TestCase):
self.assertEqual(patch_response.status_code, 400) self.assertEqual(patch_response.status_code, 400)
def test_patch_validation_errors_are_user_friendly(self):
"""Test that PATCH validation errors are user-friendly and field-specific."""
self.client.force_login(self.user)
# First create an item
create_payload = {"interval": {"start": 20, "end": 25}}
create_response = self.client.post(
f"/api/v1/scenario/{self.scenario.uuid}/information/temperature/",
data=json.dumps(create_payload),
content_type="application/json",
)
item_uuid = create_response.json()["uuid"]
# Update with invalid data - wrong type (string instead of number in interval)
invalid_payload = {"interval": {"start": "not_a_number", "end": 25}}
patch_response = self.client.patch(
f"/api/v1/scenario/{self.scenario.uuid}/information/item/{item_uuid}/",
data=json.dumps(invalid_payload),
content_type="application/json",
)
self.assertEqual(patch_response.status_code, 400)
data = patch_response.json()
# Parse the error response - Django Ninja wraps errors in 'detail'
error_str = data.get("detail") or data.get("error")
self.assertIsNotNone(error_str, "Response should contain error details")
# Parse the JSON error string
error_data = json.loads(error_str)
# Check structure
self.assertEqual(error_data.get("type"), "validation_error")
self.assertIn("field_errors", error_data)
self.assertIn("message", error_data)
# Ensure error messages are user-friendly (no Pydantic URLs or technical jargon)
error_str = json.dumps(error_data)
self.assertNotIn("pydantic", error_str.lower())
self.assertNotIn("https://errors.pydantic.dev", error_str)
self.assertNotIn("loc", error_str) # No technical field like 'loc'
# Check that error message is helpful
self.assertIn("Please", error_data["message"]) # User-friendly language
def test_delete_additional_information(self): def test_delete_additional_information(self):
"""Test DELETE removes additional information.""" """Test DELETE removes additional information."""
self.client.force_login(self.user) self.client.force_login(self.user)

View File

@ -85,6 +85,10 @@ def extract_ui_config_from_model(model_cls: Type[BaseModel]) -> dict[str, Any]:
# Extract config for each field # Extract config for each field
for field_name in field_names: for field_name in field_names:
# Skip if UI config doesn't exist for this field (field may be hidden from UI)
if not hasattr(ui_class, field_name):
continue
ui_config = getattr(ui_class, field_name) ui_config = getattr(ui_class, field_name)
if isinstance(ui_config, UIConfig): if isinstance(ui_config, UIConfig):
@ -139,7 +143,9 @@ def build_schema(model_cls: Type[BaseModel]) -> dict[str, Any]:
schema[f"x-{key}"] = value schema[f"x-{key}"] = value
# Set standard title property from UI metadata for JSON Schema compliance # Set standard title property from UI metadata for JSON Schema compliance
if "label" in ui_metadata: if "title" in ui_metadata:
schema["title"] = ui_metadata["title"]
elif "label" in ui_metadata:
schema["title"] = ui_metadata["label"] schema["title"] = ui_metadata["label"]
return schema return schema

View File

@ -0,0 +1,82 @@
"""Shared utilities for handling Pydantic validation errors."""
import json
from pydantic import ValidationError
from pydantic_core import ErrorDetails
from ninja.errors import HttpError
def format_validation_error(error: ErrorDetails) -> str:
"""Format a Pydantic validation error into a user-friendly message.
Args:
error: A Pydantic error details dictionary containing 'msg', 'type', 'ctx', etc.
Returns:
A user-friendly error message string.
"""
msg = error.get("msg") or "Invalid value"
error_type = error.get("type") or ""
# Handle common validation types with friendly messages
if error_type == "enum":
ctx = error.get("ctx", {})
expected = ctx.get("expected", "") if ctx else ""
return f"Please select a valid option{': ' + expected if expected else ''}"
elif error_type == "literal_error":
# Literal errors (like Literal["active", "inactive"])
return msg.replace("Input should be ", "Please enter ")
elif error_type == "missing":
return "This field is required"
elif error_type == "string_type":
return "Please enter a valid string"
elif error_type == "int_type":
return "Please enter a valid int"
elif error_type == "int_parsing":
return "Please enter a valid int"
elif error_type == "float_type":
return "Please enter a valid float"
elif error_type == "float_parsing":
return "Please enter a valid float"
elif error_type == "value_error":
# Strip "Value error, " prefix from custom validator messages
return msg.replace("Value error, ", "")
else:
# Default: use the message from Pydantic but clean it up
return msg.replace("Input should be ", "Please enter ").replace("Value error, ", "")
def handle_validation_error(e: ValidationError) -> None:
"""Convert a Pydantic ValidationError into a structured HttpError.
This function transforms Pydantic validation errors into a JSON structure
that the frontend expects for displaying field-level errors.
Args:
e: The Pydantic ValidationError to handle.
Raises:
HttpError: Always raises a 400 error with structured JSON containing
type, field_errors, and message fields.
"""
# Transform Pydantic validation errors into user-friendly format
field_errors: dict[str, list[str]] = {}
for error in e.errors():
# Get the field name from location tuple
loc = error.get("loc", ())
field = str(loc[-1]) if loc else "root"
# Format the error message
friendly_msg = format_validation_error(error)
if field not in field_errors:
field_errors[field] = []
field_errors[field].append(friendly_msg)
# Return structured error for frontend parsing
error_response = {
"type": "validation_error",
"field_errors": field_errors,
"message": "Please correct the errors below",
}
raise HttpError(400, json.dumps(error_response))

View File

@ -2,14 +2,13 @@ from ninja import Router, Body
from ninja.errors import HttpError from ninja.errors import HttpError
from uuid import UUID from uuid import UUID
from pydantic import ValidationError from pydantic import ValidationError
from pydantic_core import ErrorDetails
from typing import Dict, Any from typing import Dict, Any
import logging import logging
import json
from envipy_additional_information import registry from envipy_additional_information import registry
from envipy_additional_information.groups import GroupEnum from envipy_additional_information.groups import GroupEnum
from epapi.utils.schema_transformers import build_rjsf_output from epapi.utils.schema_transformers import build_rjsf_output
from epapi.utils.validation_errors import handle_validation_error
from ..dal import get_scenario_for_read, get_scenario_for_write from ..dal import get_scenario_for_read, get_scenario_for_write
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -56,28 +55,6 @@ def list_scenario_info(request, scenario_uuid: UUID):
return result return result
def _format_validation_error(error: ErrorDetails) -> str:
"""Format a Pydantic validation error into a user-friendly message."""
msg = error["msg"] or "Invalid value"
error_type = error["type"] or ""
# Handle common validation types with friendly messages
if error_type == "enum":
expected = error["ctx"]["expected"] if error["ctx"] else ""
return f"Please select a valid option{': ' + expected if expected else ''}"
elif error_type == "missing":
return "This field is required"
elif error_type in ("string_type", "int_type", "float_type"):
type_name = error_type.replace("_type", "")
return f"Please enter a valid {type_name}"
elif error_type == "value_error":
# Use the message as-is for value errors
return msg
else:
# Default: use the message from Pydantic but clean it up
return msg.replace("Input should be ", "Please enter ").replace("Value error, ", "")
@router.post("/scenario/{uuid:scenario_uuid}/information/{model_name}/") @router.post("/scenario/{uuid:scenario_uuid}/information/{model_name}/")
def add_scenario_info( def add_scenario_info(
request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...) request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...)
@ -90,27 +67,7 @@ def add_scenario_info(
try: try:
instance = cls(**payload) # Pydantic validates instance = cls(**payload) # Pydantic validates
except ValidationError as e: except ValidationError as e:
# Transform Pydantic validation errors into user-friendly format handle_validation_error(e)
field_errors: dict[str, list[str]] = {}
for error in e.errors():
# Get the field name from location tuple
loc = error.get("loc", ())
field = str(loc[-1]) if loc else "root"
# Format the error message
friendly_msg = _format_validation_error(error)
if field not in field_errors:
field_errors[field] = []
field_errors[field].append(friendly_msg)
# Return structured error for frontend parsing
error_response = {
"type": "validation_error",
"field_errors": field_errors,
"message": "Please correct the errors below",
}
raise HttpError(400, json.dumps(error_response))
scenario = get_scenario_for_write(request.user, scenario_uuid) scenario = get_scenario_for_write(request.user, scenario_uuid)
@ -147,27 +104,7 @@ def update_scenario_info(
try: try:
instance = cls(**payload) instance = cls(**payload)
except ValidationError as e: except ValidationError as e:
# Transform Pydantic validation errors into user-friendly format handle_validation_error(e)
field_errors: dict[str, list[str]] = {}
for error in e.errors():
# Get the field name from location tuple
loc = error.get("loc", ())
field = str(loc[-1]) if loc else "root"
# Format the error message
friendly_msg = _format_validation_error(error)
if field not in field_errors:
field_errors[field] = []
field_errors[field].append(friendly_msg)
# Return structured error for frontend parsing
error_response = {
"type": "validation_error",
"field_errors": field_errors,
"message": "Please correct the errors below",
}
raise HttpError(400, json.dumps(error_response))
# Use model method for update # Use model method for update
try: try:

View File

@ -12,33 +12,84 @@
* endpoint: '/api/v1/scenario/{uuid}/information/temperature/' * endpoint: '/api/v1/scenario/{uuid}/information/temperature/'
* })"> * })">
*/ */
document.addEventListener('alpine:init', () => { document.addEventListener("alpine:init", () => {
Alpine.data('schemaRenderer', (options = {}) => ({ // 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, schema: null,
uiSchema: {}, uiSchema: {},
data: {}, data: {},
mode: options.mode || 'view', // 'view' | 'edit' mode: options.mode || "view", // 'view' | 'edit'
endpoint: options.endpoint || '', endpoint: options.endpoint || "",
loading: false, loading: false,
error: null, 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() { 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) { if (options.schemaUrl) {
try { try {
this.loading = true; this.loading = true;
@ -50,31 +101,31 @@ document.addEventListener('alpine:init', () => {
// RJSF format: {schema, uiSchema, formData, groups} // RJSF format: {schema, uiSchema, formData, groups}
if (!rjsf.schema) { 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.schema = rjsf.schema;
this.uiSchema = rjsf.uiSchema || {}; this.uiSchema = rjsf.uiSchema || {};
this.data = options.data this.data = options.data
? JSON.parse(JSON.stringify(options.data)) ? JSON.parse(JSON.stringify(options.data))
: (rjsf.formData || {}); : rjsf.formData || {};
} catch (err) { } catch (err) {
this.error = err.message; this.error = err.message;
console.error('Error loading schema:', err); console.error("Error loading schema:", err);
} finally { } finally {
this.loading = false; this.loading = false;
} }
} else if (options.rjsf) { } else if (options.rjsf) {
// Direct RJSF object passed // Direct RJSF object passed
if (!options.rjsf.schema) { 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.schema = options.rjsf.schema;
this.uiSchema = options.rjsf.uiSchema || {}; this.uiSchema = options.rjsf.uiSchema || {};
this.data = options.data this.data = options.data
? JSON.parse(JSON.stringify(options.data)) ? JSON.parse(JSON.stringify(options.data))
: (options.rjsf.formData || {}); : options.rjsf.formData || {};
} }
// Initialize data from formData or options // Initialize data from formData or options
@ -84,19 +135,22 @@ document.addEventListener('alpine:init', () => {
// Ensure all schema fields are properly initialized // Ensure all schema fields are properly initialized
if (this.schema && this.schema.properties) { 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); const widget = this.getWidget(key, propSchema);
if (widget === 'interval') { if (widget === "interval") {
// Ensure interval fields are objects with start/end // 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 }; this.data[key] = { start: null, end: null };
} else { } else {
// Ensure start and end exist // 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; 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 // Ensure timeseries fields are arrays
if (!this.data[key] || !Array.isArray(this.data[key])) { if (!this.data[key] || !Array.isArray(this.data[key])) {
this.data[key] = []; this.data[key] = [];
@ -104,86 +158,141 @@ document.addEventListener('alpine:init', () => {
} else if (this.data[key] === undefined) { } else if (this.data[key] === undefined) {
// ONLY initialize if truly undefined, not just falsy // ONLY initialize if truly undefined, not just falsy
// This preserves empty strings, null, 0, false as valid values // This preserves empty strings, null, 0, false as valid values
if (propSchema.type === 'boolean') { if (propSchema.type === "boolean") {
this.data[key] = false; this.data[key] = false;
} else if (propSchema.type === 'number' || propSchema.type === 'integer') { } else if (
propSchema.type === "number" ||
propSchema.type === "integer"
) {
this.data[key] = null; this.data[key] = null;
} else if (propSchema.enum) { } else if (propSchema.enum) {
// For select fields, use null to show placeholder // For select fields, use null to show placeholder
this.data[key] = null; this.data[key] = null;
} else { } else {
this.data[key] = ''; this.data[key] = "";
} }
} }
// If data[key] exists (even if empty string or null), don't overwrite // 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) { getWidget(fieldName, fieldSchema) {
// Check uiSchema first (RJSF format) // Defensive check: ensure fieldSchema is provided
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:widget']) { if (!fieldSchema) return "text";
return this.uiSchema[fieldName]['ui:widget'];
}
// Check for interval type (object with start/end properties) try {
if (fieldSchema.type === 'object' && // 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 &&
fieldSchema.properties.start && fieldSchema.properties.start &&
fieldSchema.properties.end) { fieldSchema.properties.end
return 'interval'; ) {
} return "interval";
}
// Infer from JSON Schema type // Check for measurements array type (timeseries-table widget)
if (fieldSchema.enum) return 'select'; if (
if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') return 'number'; fieldSchema.type === "array" &&
if (fieldSchema.type === 'boolean') return 'checkbox'; fieldSchema.items?.properties?.timestamp &&
return 'text'; 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) { getLabel(fieldName, fieldSchema) {
// Check uiSchema (RJSF format) // Check uiSchema (RJSF format)
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:label']) { if (this.uiSchema[fieldName] && this.uiSchema[fieldName]["ui:label"]) {
return this.uiSchema[fieldName]['ui:label']; return this.uiSchema[fieldName]["ui:label"];
} }
// Default: format field name // Default: format field name
return fieldName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); 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;
}, },
getFieldOrder() { getFieldOrder() {
// Get ordered list of field names based on ui:order try {
if (!this.schema || !this.schema.properties) return []; // 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 // Sort by ui:order if available
return fields.sort((a, b) => { return fields.sort((a, b) => {
const orderA = this.uiSchema[a]?.['ui:order'] || '999'; const orderA = this.uiSchema[a]?.["ui:order"] || "999";
const orderB = this.uiSchema[b]?.['ui:order'] || '999'; const orderB = this.uiSchema[b]?.["ui:order"] || "999";
return parseInt(orderA) - parseInt(orderB); 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() { async submit() {
if (!this.endpoint) { if (!this.endpoint) {
console.error('No endpoint specified for submission'); console.error("No endpoint specified for submission");
return; return;
} }
@ -191,14 +300,15 @@ document.addEventListener('alpine:init', () => {
this.error = null; this.error = null;
try { try {
const csrftoken = document.querySelector("[name=csrf-token]")?.content || ''; const csrftoken =
document.querySelector("[name=csrf-token]")?.content || "";
const res = await fetch(this.endpoint, { const res = await fetch(this.endpoint, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
'X-CSRFToken': csrftoken "X-CSRFToken": csrftoken,
}, },
body: JSON.stringify(this.data) body: JSON.stringify(this.data),
}); });
if (!res.ok) { if (!res.ok) {
@ -210,35 +320,50 @@ document.addEventListener('alpine:init', () => {
} }
// Handle validation errors (field-level) // Handle validation errors (field-level)
this.fieldErrors = {}; Alpine.store('validationErrors').clearErrors();
// Try to parse structured error response // Try to parse structured error response
let parsedError = errorData; let parsedError = errorData;
// If error is a JSON string, parse it // 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); parsedError = JSON.parse(errorData.error);
} }
if (parsedError.detail && Array.isArray(parsedError.detail)) { if (parsedError.detail && Array.isArray(parsedError.detail)) {
// Pydantic validation errors format: [{loc: ['field'], msg: '...', type: '...'}] // Pydantic validation errors format: [{loc: ['field'], msg: '...', type: '...'}]
const fieldErrors = {};
for (const err of parsedError.detail) { for (const err of parsedError.detail) {
const field = err.loc && err.loc.length > 0 ? err.loc[err.loc.length - 1] : 'root'; const field =
if (!this.fieldErrors[field]) { err.loc && err.loc.length > 0
this.fieldErrors[field] = []; ? 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 { } else {
// General error // 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 // Clear errors on success
this.fieldErrors = {}; Alpine.store('validationErrors').clearErrors();
const result = await res.json(); const result = await res.json();
return result; return result;
@ -248,6 +373,6 @@ document.addEventListener('alpine:init', () => {
} finally { } finally {
this.loading = false; this.loading = false;
} }
} },
})); }));
}); });

View File

@ -4,15 +4,24 @@
* Centralized widget component definitions for dynamic form rendering. * Centralized widget component definitions for dynamic form rendering.
* Each widget receives explicit parameters instead of context object for better traceability. * 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 // Base widget factory with common functionality
const baseWidget = (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ const baseWidget = (
fieldName, fieldName,
data, data,
schema, schema,
uiSchema, uiSchema,
fieldErrors,
mode, 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 // Field schema access
get fieldSchema() { get fieldSchema() {
@ -22,165 +31,432 @@ document.addEventListener('alpine:init', () => {
// Common metadata // Common metadata
get label() { get label() {
// Check uiSchema first (RJSF format) // Check uiSchema first (RJSF format)
if (this.uiSchema?.[this.fieldName]?.['ui:label']) { if (this.uiSchema?.[this.fieldName]?.["ui:label"]) {
return this.uiSchema[this.fieldName]['ui:label']; return this.uiSchema[this.fieldName]["ui:label"];
} }
// Fall back to schema title // Fall back to schema title
if (this.fieldSchema.title) { if (this.fieldSchema.title) {
return this.fieldSchema.title; return this.fieldSchema.title;
} }
// Default: format field name // 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() { get helpText() {
return this.fieldSchema.description || ''; return this.fieldSchema.description || "";
}, },
// Field-level unit extraction from uiSchema (RJSF format) // Field-level unit extraction from uiSchema (RJSF format)
get unit() { get unit() {
return this.uiSchema?.[this.fieldName]?.['ui:unit'] || null; return this.uiSchema?.[this.fieldName]?.["ui:unit"] || null;
},
// Error handling
get hasError() {
return !!this.fieldErrors?.[this.fieldName];
},
get errors() {
return this.fieldErrors?.[this.fieldName] || [];
}, },
// Mode checks // Mode checks
get isViewMode() { return this.mode === 'view'; }, get isViewMode() {
get isEditMode() { return this.mode === 'edit'; }, return this.mode === "view";
},
get isEditMode() {
return this.mode === "edit";
},
}); });
// Text widget // Text widget
Alpine.data('textWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ Alpine.data(
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), "textWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; }, get value() {
set value(v) { this.data[this.fieldName] = v; }, return this.data[this.fieldName] || "";
})); },
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Textarea widget // Textarea widget
Alpine.data('textareaWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ Alpine.data(
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), "textareaWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; }, get value() {
set value(v) { this.data[this.fieldName] = v; }, return this.data[this.fieldName] || "";
})); },
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Number widget with unit support // Number widget with unit support
Alpine.data('numberWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ Alpine.data(
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), "numberWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName]; }, get value() {
set value(v) { return this.data[this.fieldName];
this.data[this.fieldName] = v === '' || v === null ? null : parseFloat(v); },
}, set value(v) {
get hasValue() { this.data[this.fieldName] =
return this.value !== null && this.value !== undefined && this.value !== ''; v === "" || v === null ? null : parseFloat(v);
}, },
// Format value with unit for view mode get hasValue() {
get displayValue() { return (
if (!this.hasValue) return '—'; this.value !== null && this.value !== undefined && this.value !== ""
return this.unit ? `${this.value} ${this.unit}` : String(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 // Select widget
Alpine.data('selectWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ Alpine.data(
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), "selectWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; }, get value() {
set value(v) { this.data[this.fieldName] = v; }, return this.data[this.fieldName] || "";
get options() { return this.fieldSchema.enum || []; }, },
})); set value(v) {
this.data[this.fieldName] = v;
},
get options() {
return this.fieldSchema.enum || [];
},
}),
);
// Checkbox widget // Checkbox widget
Alpine.data('checkboxWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ Alpine.data(
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), "checkboxWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get checked() { return !!this.data[this.fieldName]; }, get checked() {
set checked(v) { this.data[this.fieldName] = v; }, return !!this.data[this.fieldName];
})); },
set checked(v) {
this.data[this.fieldName] = v;
},
}),
);
// Interval widget with unit support // Interval widget with unit support
Alpine.data('intervalWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ Alpine.data(
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), "intervalWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get start() { get start() {
return this.data[this.fieldName]?.start ?? null; return this.data[this.fieldName]?.start ?? null;
}, },
set start(v) { set start(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {}; if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].start = v === '' || v === null ? null : parseFloat(v); this.data[this.fieldName].start =
}, v === "" || v === null ? null : parseFloat(v);
get end() { },
return this.data[this.fieldName]?.end ?? null; get end() {
}, return this.data[this.fieldName]?.end ?? null;
set end(v) { },
if (!this.data[this.fieldName]) this.data[this.fieldName] = {}; set end(v) {
this.data[this.fieldName].end = v === '' || v === null ? null : parseFloat(v); if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
}, this.data[this.fieldName].end =
// Format interval with unit for view mode v === "" || v === null ? null : parseFloat(v);
get displayValue() { },
const s = this.start, e = this.end; // Format interval with unit for view mode
const unitStr = this.unit ? ` ${this.unit}` : ''; 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 && e !== null) return `${s} ${e}${unitStr}`;
if (s !== null) return `${s}${unitStr}`; if (s !== null) return `${s}${unitStr}`;
if (e !== null) return `${e}${unitStr}`; if (e !== null) return `${e}${unitStr}`;
return '—'; return "—";
}, },
get isSameValue() { get isSameValue() {
return this.start !== null && this.start === this.end; return this.start !== null && this.start === this.end;
}, },
// Validation: start must be <= end // Validation: start must be <= end (client-side)
get hasValidationError() { get hasValidationError() {
if (this.isViewMode) return false; if (this.isViewMode) return false;
const s = this.start; const s = this.start;
const e = this.end; const e = this.end;
// Only validate if both values are provided // Only validate if both values are provided
if (s !== null && e !== null && typeof s === 'number' && typeof e === 'number') { if (
return s > e; s !== null &&
} e !== null &&
return false; typeof s === "number" &&
}, typeof e === "number"
) {
// Override hasError to include validation error return s > e;
get hasError() { }
return this.hasValidationError || !!this.fieldErrors?.[this.fieldName]; return false;
}, },
}),
// 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];
},
}));
// PubMed link widget // PubMed link widget
Alpine.data('pubmedWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ Alpine.data(
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), "pubmedWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; }, get value() {
set value(v) { this.data[this.fieldName] = v; }, return this.data[this.fieldName] || "";
get pubmedUrl() { },
return this.value ? `https://pubmed.ncbi.nlm.nih.gov/${this.value}` : null; 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 // Compound link widget
Alpine.data('compoundWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ Alpine.data(
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), "compoundWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; }, get value() {
set value(v) { this.data[this.fieldName] = v; }, 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) { sanitizePayload(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
.map(item => this.sanitizePayload(item)) .map((item) => this.sanitizePayload(item))
.filter(item => item !== ''); .filter((item) => item !== "");
} }
if (value && typeof value === 'object') { if (value && typeof value === "object") {
const cleaned = {}; const cleaned = {};
for (const [key, item] of Object.entries(value)) { for (const [key, item] of Object.entries(value)) {
if (item === '') continue; if (item === "") continue;
cleaned[key] = this.sanitizePayload(item); cleaned[key] = this.sanitizePayload(item);
} }
return cleaned; return cleaned;
@ -53,7 +53,7 @@ window.AdditionalInformationApi = {
* @returns {string} CSRF token * @returns {string} CSRF token
*/ */
getCsrfToken() { getCsrfToken() {
return document.querySelector('[name=csrf-token]')?.content || ''; return document.querySelector("[name=csrf-token]")?.content || "";
}, },
/** /**
@ -62,10 +62,10 @@ window.AdditionalInformationApi = {
*/ */
_buildHeaders(includeContentType = true) { _buildHeaders(includeContentType = true) {
const headers = { const headers = {
'X-CSRFToken': this.getCsrfToken() "X-CSRFToken": this.getCsrfToken(),
}; };
if (includeContentType) { if (includeContentType) {
headers['Content-Type'] = 'application/json'; headers["Content-Type"] = "application/json";
} }
return headers; return headers;
}, },
@ -85,26 +85,34 @@ window.AdditionalInformationApi = {
// Try to parse the error if it's a JSON string // Try to parse the error if it's a JSON string
let parsedError = errorData; let parsedError = errorData;
if (typeof errorData.error === 'string' && errorData.error.startsWith('{')) { const errorStr = errorData.detail || errorData.error;
if (typeof errorStr === "string" && errorStr.startsWith("{")) {
try { try {
parsedError = JSON.parse(errorData.error); parsedError = JSON.parse(errorStr);
} catch { } catch {
// Not JSON, use as-is // Not JSON, use as-is
} }
} }
// If it's a structured validation error, throw with field errors // 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); 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.fieldErrors = parsedError.field_errors;
error.isValidationError = true; error.isValidationError = true;
throw error; throw error;
} }
// General error // General error
const errorMsg = parsedError.message || parsedError.error || parsedError.detail || `${action} failed: ${response.statusText}`; const errorMsg =
this._log(`${action} ERROR`, { status: response.status, error: errorMsg }); parsedError.message ||
parsedError.error ||
parsedError.detail ||
`${action} failed: ${response.statusText}`;
this._log(`${action} ERROR`, {
status: response.status,
error: errorMsg,
});
throw new Error(errorMsg); throw new Error(errorMsg);
} }
@ -118,9 +126,9 @@ window.AdditionalInformationApi = {
* @returns {Promise<Object>} Object with schema definitions * @returns {Promise<Object>} Object with schema definitions
*/ */
async loadSchemas() { async loadSchemas() {
this._log('loadSchemas', 'Starting...'); this._log("loadSchemas", "Starting...");
const response = await fetch('/api/v1/information/schema/'); const response = await fetch("/api/v1/information/schema/");
return this._handleResponse(response, 'loadSchemas'); return this._handleResponse(response, "loadSchemas");
}, },
/** /**
@ -129,9 +137,11 @@ window.AdditionalInformationApi = {
* @returns {Promise<Array>} Array of additional information items * @returns {Promise<Array>} Array of additional information items
*/ */
async loadItems(scenarioUuid) { async loadItems(scenarioUuid) {
this._log('loadItems', { scenarioUuid }); this._log("loadItems", { scenarioUuid });
const response = await fetch(`/api/v1/scenario/${scenarioUuid}/information/`); const response = await fetch(
return this._handleResponse(response, 'loadItems'); `/api/v1/scenario/${scenarioUuid}/information/`,
);
return this._handleResponse(response, "loadItems");
}, },
/** /**
@ -140,11 +150,11 @@ window.AdditionalInformationApi = {
* @returns {Promise<{schemas: Object, items: Array}>} * @returns {Promise<{schemas: Object, items: Array}>}
*/ */
async loadSchemasAndItems(scenarioUuid) { async loadSchemasAndItems(scenarioUuid) {
this._log('loadSchemasAndItems', { scenarioUuid }); this._log("loadSchemasAndItems", { scenarioUuid });
const [schemas, items] = await Promise.all([ const [schemas, items] = await Promise.all([
this.loadSchemas(), this.loadSchemas(),
this.loadItems(scenarioUuid) this.loadItems(scenarioUuid),
]); ]);
return { schemas, items }; return { schemas, items };
@ -159,7 +169,7 @@ window.AdditionalInformationApi = {
*/ */
async createItem(scenarioUuid, modelName, data) { async createItem(scenarioUuid, modelName, data) {
const sanitizedData = this.sanitizePayload(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 // Normalize model name to lowercase
const normalizedName = modelName.toLowerCase(); const normalizedName = modelName.toLowerCase();
@ -167,13 +177,13 @@ window.AdditionalInformationApi = {
const response = await fetch( const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/${normalizedName}/`, `/api/v1/scenario/${scenarioUuid}/information/${normalizedName}/`,
{ {
method: 'POST', method: "POST",
headers: this._buildHeaders(), 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}>} * @returns {Promise<{status: string}>}
*/ */
async deleteItem(scenarioUuid, itemUuid) { async deleteItem(scenarioUuid, itemUuid) {
this._log('deleteItem', { scenarioUuid, itemUuid }); this._log("deleteItem", { scenarioUuid, itemUuid });
const response = await fetch( const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/item/${itemUuid}/`, `/api/v1/scenario/${scenarioUuid}/information/item/${itemUuid}/`,
{ {
method: 'DELETE', method: "DELETE",
headers: this._buildHeaders(false) headers: this._buildHeaders(false),
} },
); );
return this._handleResponse(response, 'deleteItem'); return this._handleResponse(response, "deleteItem");
}, },
/** /**
@ -205,7 +215,10 @@ window.AdditionalInformationApi = {
*/ */
async updateItem(scenarioUuid, item) { async updateItem(scenarioUuid, item) {
const sanitizedData = this.sanitizePayload(item.data); 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; const { uuid, type } = item;
@ -213,20 +226,23 @@ window.AdditionalInformationApi = {
const response = await fetch( const response = await fetch(
`/api/v1/scenario/${scenarioUuid}/information/item/${uuid}/`, `/api/v1/scenario/${scenarioUuid}/information/item/${uuid}/`,
{ {
method: 'PATCH', method: "PATCH",
headers: this._buildHeaders(), headers: this._buildHeaders(),
body: JSON.stringify(sanitizedData) body: JSON.stringify(sanitizedData),
} },
); );
if (response.status === 405) { if (response.status === 405) {
// PATCH not supported, fall back to delete+recreate // 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); await this.deleteItem(scenarioUuid, uuid);
return await this.createItem(scenarioUuid, type, sanitizedData); 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 * @returns {Promise<Array>} Array of results with success status
*/ */
async updateItems(scenarioUuid, items) { async updateItems(scenarioUuid, items) {
this._log('updateItems', { scenarioUuid, itemCount: items.length }); this._log("updateItems", { scenarioUuid, itemCount: items.length });
const results = []; const results = [];
for (const item of items) { for (const item of items) {
try { try {
const result = await this.updateItem(scenarioUuid, item); 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) { } catch (error) {
results.push({ results.push({
success: false, success: false,
oldUuid: item.uuid, oldUuid: item.uuid,
error: error.message, error: error.message,
fieldErrors: error.fieldErrors, 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 (failed.length > 0) {
// If all failures are validation errors, throw a validation error // If all failures are validation errors, return all validation errors for display
const validationErrors = failed.filter(f => f.isValidationError); const validationErrors = failed.filter((f) => f.isValidationError);
if (validationErrors.length === failed.length && failed.length === 1) { if (validationErrors.length === failed.length) {
// Single validation error - preserve field errors for display // All failures are validation errors - return all field errors by item UUID
const error = new Error(failed[0].error); const allFieldErrors = {};
error.fieldErrors = failed[0].fieldErrors; 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.isValidationError = true;
error.itemUuid = failed[0].oldUuid; error.isMultipleErrors = true; // Flag indicating multiple items have errors
throw error; throw error;
} }
// Multiple failures or mixed errors - show count // 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; return results;
@ -285,16 +313,13 @@ window.AdditionalInformationApi = {
* @returns {Promise<{uuid, url, name, description, review_status, package}>} * @returns {Promise<{uuid, url, name, description, review_status, package}>}
*/ */
async createScenario(packageUuid, payload) { async createScenario(packageUuid, payload) {
this._log('createScenario', { packageUuid, payload }); this._log("createScenario", { packageUuid, payload });
const response = await fetch( const response = await fetch(`/api/v1/package/${packageUuid}/scenario/`, {
`/api/v1/package/${packageUuid}/scenario/`, method: "POST",
{ headers: this._buildHeaders(),
method: 'POST', body: JSON.stringify(payload),
headers: this._buildHeaders(), });
body: JSON.stringify(payload) return this._handleResponse(response, "createScenario");
}
);
return this._handleResponse(response, 'createScenario');
}, },
/** /**
@ -302,9 +327,9 @@ window.AdditionalInformationApi = {
* @returns {Promise<{groups: string[]}>} * @returns {Promise<{groups: string[]}>}
*/ */
async loadGroups() { async loadGroups() {
this._log('loadGroups', 'Starting...'); this._log("loadGroups", "Starting...");
const response = await fetch('/api/v1/information/groups/'); const response = await fetch("/api/v1/information/groups/");
return this._handleResponse(response, 'loadGroups'); return this._handleResponse(response, "loadGroups");
}, },
/** /**
@ -313,7 +338,7 @@ window.AdditionalInformationApi = {
* @returns {Promise<Object>} Object with subcategories as keys and arrays of model info * @returns {Promise<Object>} Object with subcategories as keys and arrays of model info
*/ */
async loadGroupModels(groupName) { async loadGroupModels(groupName) {
this._log('loadGroupModels', { groupName }); this._log("loadGroupModels", { groupName });
const response = await fetch(`/api/v1/information/groups/${groupName}/`); const response = await fetch(`/api/v1/information/groups/${groupName}/`);
return this._handleResponse(response, `loadGroupModels-${groupName}`); return this._handleResponse(response, `loadGroupModels-${groupName}`);
}, },
@ -323,8 +348,8 @@ window.AdditionalInformationApi = {
* @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment'] * @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment']
* @returns {Promise<Object>} Object with group names as keys * @returns {Promise<Object>} Object with group names as keys
*/ */
async loadGroupsWithModels(groupNames = ['soil', 'sludge', 'sediment']) { async loadGroupsWithModels(groupNames = ["soil", "sludge", "sediment"]) {
this._log('loadGroupsWithModels', { groupNames }); this._log("loadGroupsWithModels", { groupNames });
const results = {}; const results = {};
const promises = groupNames.map(async (groupName) => { 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 * @returns {Object} Object with group names as keys and filtered schemas as values
*/ */
organizeSchemasByGroup(schemas, groupModelsData) { organizeSchemasByGroup(schemas, groupModelsData) {
this._log('organizeSchemasByGroup', { this._log("organizeSchemasByGroup", {
schemaCount: Object.keys(schemas).length, schemaCount: Object.keys(schemas).length,
groupCount: Object.keys(groupModelsData).length groupCount: Object.keys(groupModelsData).length,
}); });
const organized = {}; const organized = {};
@ -377,18 +402,18 @@ window.AdditionalInformationApi = {
* @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment'] * @param {Array<string>} groupNames - Defaults to ['soil', 'sludge', 'sediment']
* @returns {Promise<{schemas, groupSchemas, groupModels}>} * @returns {Promise<{schemas, groupSchemas, groupModels}>}
*/ */
async loadSchemasWithGroups(groupNames = ['soil', 'sludge', 'sediment']) { async loadSchemasWithGroups(groupNames = ["soil", "sludge", "sediment"]) {
this._log('loadSchemasWithGroups', { groupNames }); this._log("loadSchemasWithGroups", { groupNames });
// Load schemas and all groups in parallel // Load schemas and all groups in parallel
const [schemas, groupModels] = await Promise.all([ const [schemas, groupModels] = await Promise.all([
this.loadSchemas(), this.loadSchemas(),
this.loadGroupsWithModels(groupNames) this.loadGroupsWithModels(groupNames),
]); ]);
// Organize schemas by group // Organize schemas by group
const groupSchemas = this.organizeSchemasByGroup(schemas, groupModels); const groupSchemas = this.organizeSchemasByGroup(schemas, groupModels);
return { schemas, groupSchemas, 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;
},
};

View File

@ -34,7 +34,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'text'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'text'"
> >
<div <div
x-data="textWidget(fieldName, data, schema, uiSchema, fieldErrors, mode)" x-data="textWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
> >
{% include "components/widgets/text_widget.html" %} {% include "components/widgets/text_widget.html" %}
</div> </div>
@ -45,7 +45,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'textarea'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'textarea'"
> >
<div <div
x-data="textareaWidget(fieldName, data, schema, uiSchema, fieldErrors, mode)" x-data="textareaWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
> >
{% include "components/widgets/textarea_widget.html" %} {% include "components/widgets/textarea_widget.html" %}
</div> </div>
@ -56,7 +56,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'number'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'number'"
> >
<div <div
x-data="numberWidget(fieldName, data, schema, uiSchema, fieldErrors, mode)" x-data="numberWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
> >
{% include "components/widgets/number_widget.html" %} {% include "components/widgets/number_widget.html" %}
</div> </div>
@ -67,7 +67,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'select'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'select'"
> >
<div <div
x-data="selectWidget(fieldName, data, schema, uiSchema, fieldErrors, mode)" x-data="selectWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
> >
{% include "components/widgets/select_widget.html" %} {% include "components/widgets/select_widget.html" %}
</div> </div>
@ -78,7 +78,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'checkbox'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'checkbox'"
> >
<div <div
x-data="checkboxWidget(fieldName, data, schema, uiSchema, fieldErrors, mode)" x-data="checkboxWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
> >
{% include "components/widgets/checkbox_widget.html" %} {% include "components/widgets/checkbox_widget.html" %}
</div> </div>
@ -89,7 +89,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'interval'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'interval'"
> >
<div <div
x-data="intervalWidget(fieldName, data, schema, uiSchema, fieldErrors, mode)" x-data="intervalWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
> >
{% include "components/widgets/interval_widget.html" %} {% include "components/widgets/interval_widget.html" %}
</div> </div>
@ -100,7 +100,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'pubmed-link'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'pubmed-link'"
> >
<div <div
x-data="pubmedWidget(fieldName, data, schema, uiSchema, fieldErrors, mode)" x-data="pubmedWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
> >
{% include "components/widgets/pubmed_link_widget.html" %} {% include "components/widgets/pubmed_link_widget.html" %}
</div> </div>
@ -111,11 +111,22 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'"
> >
<div <div
x-data="compoundWidget(fieldName, data, schema, uiSchema, fieldErrors, mode)" x-data="compoundWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
> >
{% include "components/widgets/compound_link_widget.html" %} {% include "components/widgets/compound_link_widget.html" %}
</div> </div>
</template> </template>
<!-- TimeSeries table widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'timeseries-table'"
>
<div
x-data="timeseriesTableWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/timeseries_table_widget.html" %}
</div>
</template>
</div> </div>
</template> </template>

View File

@ -6,7 +6,7 @@
<span <span
class="label-text" class="label-text"
:class="{ :class="{
'text-error': hasError, 'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode 'text-sm text-base-content/60': isViewMode
}" }"
x-text="label" x-text="label"
@ -38,9 +38,12 @@
</template> </template>
<!-- Errors --> <!-- Errors -->
<template x-if="hasError"> <template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label"> <div class="label">
<template x-for="errMsg in errors" :key="errMsg"> <template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span> <span class="label-text-alt text-error" x-text="errMsg"></span>
</template> </template>
</div> </div>

View File

@ -6,7 +6,7 @@
<span <span
class="label-text" class="label-text"
:class="{ :class="{
'text-error': hasError, 'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode 'text-sm text-base-content/60': isViewMode
}" }"
x-text="label" x-text="label"
@ -47,16 +47,19 @@
<input <input
type="url" type="url"
class="input input-bordered w-full" class="input input-bordered w-full"
:class="{ 'input-error': hasError }" :class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="Compound URL" placeholder="Compound URL"
x-model="value" x-model="value"
/> />
</template> </template>
<!-- Errors --> <!-- Errors -->
<template x-if="hasError"> <template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label"> <div class="label">
<template x-for="errMsg in errors" :key="errMsg"> <template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span> <span class="label-text-alt text-error" x-text="errMsg"></span>
</template> </template>
</div> </div>

View File

@ -6,7 +6,7 @@
<span <span
class="label-text" class="label-text"
:class="{ :class="{
'text-error': hasError, 'text-error': hasValidationError || $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode 'text-sm text-base-content/60': isViewMode
}" }"
x-text="label" x-text="label"
@ -41,11 +41,11 @@
<!-- Edit mode: two inputs with shared unit badge --> <!-- Edit mode: two inputs with shared unit badge -->
<template x-if="isEditMode"> <template x-if="isEditMode">
<div class="flex gap-2 items-center"> <div class="flex items-center gap-2">
<input <input
type="number" type="number"
class="input input-bordered flex-1" class="input input-bordered flex-1"
:class="{ 'input-error': hasError }" :class="{ 'input-error': hasValidationError || $store.validationErrors.hasError(fieldName, context) }"
placeholder="Min" placeholder="Min"
x-model="start" x-model="start"
/> />
@ -53,7 +53,7 @@
<input <input
type="number" type="number"
class="input input-bordered flex-1" class="input input-bordered flex-1"
:class="{ 'input-error': hasError }" :class="{ 'input-error': hasValidationError || $store.validationErrors.hasError(fieldName, context) }"
placeholder="Max" placeholder="Max"
x-model="end" x-model="end"
/> />
@ -64,9 +64,22 @@
</template> </template>
<!-- Errors --> <!-- Errors -->
<template x-if="hasError"> <template
x-if="hasValidationError || $store.validationErrors.hasError(fieldName, context)"
>
<div class="label"> <div class="label">
<template x-for="errMsg in errors" :key="errMsg"> <!-- Client-side validation error -->
<template x-if="hasValidationError">
<span class="label-text-alt text-error">
Start value must be less than or equal to end value
</span>
</template>
<!-- Server-side validation errors from store -->
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span> <span class="label-text-alt text-error" x-text="errMsg"></span>
</template> </template>
</div> </div>

View File

@ -6,7 +6,7 @@
<span <span
class="label-text" class="label-text"
:class="{ :class="{
'text-error': hasError, 'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode 'text-sm text-base-content/60': isViewMode
}" }"
x-text="label" x-text="label"
@ -41,7 +41,7 @@
<input <input
type="number" type="number"
:class="unit ? 'input input-bordered join-item flex-1' : 'input input-bordered w-full'" :class="unit ? 'input input-bordered join-item flex-1' : 'input input-bordered w-full'"
class:input-error="hasError" class:input-error="$store.validationErrors.hasError(fieldName, context)"
x-model="value" x-model="value"
/> />
<template x-if="unit"> <template x-if="unit">
@ -54,9 +54,12 @@
</template> </template>
<!-- Errors --> <!-- Errors -->
<template x-if="hasError"> <template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label"> <div class="label">
<template x-for="errMsg in errors" :key="errMsg"> <template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span> <span class="label-text-alt text-error" x-text="errMsg"></span>
</template> </template>
</div> </div>

View File

@ -6,7 +6,7 @@
<span <span
class="label-text" class="label-text"
:class="{ :class="{
'text-error': hasError, 'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode 'text-sm text-base-content/60': isViewMode
}" }"
x-text="label" x-text="label"
@ -47,16 +47,19 @@
<input <input
type="text" type="text"
class="input input-bordered w-full" class="input input-bordered w-full"
:class="{ 'input-error': hasError }" :class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="PubMed ID" placeholder="PubMed ID"
x-model="value" x-model="value"
/> />
</template> </template>
<!-- Errors --> <!-- Errors -->
<template x-if="hasError"> <template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label"> <div class="label">
<template x-for="errMsg in errors" :key="errMsg"> <template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span> <span class="label-text-alt text-error" x-text="errMsg"></span>
</template> </template>
</div> </div>

View File

@ -6,7 +6,7 @@
<span <span
class="label-text" class="label-text"
:class="{ :class="{
'text-error': hasError, 'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode 'text-sm text-base-content/60': isViewMode
}" }"
x-text="label" x-text="label"
@ -41,7 +41,7 @@
<template x-if="isEditMode"> <template x-if="isEditMode">
<select <select
class="select select-bordered w-full" class="select select-bordered w-full"
:class="{ 'select-error': hasError }" :class="{ 'select-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value" x-model="value"
> >
<option value="" :selected="!value">Select...</option> <option value="" :selected="!value">Select...</option>
@ -56,9 +56,12 @@
</template> </template>
<!-- Errors --> <!-- Errors -->
<template x-if="hasError"> <template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label"> <div class="label">
<template x-for="errMsg in errors" :key="errMsg"> <template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span> <span class="label-text-alt text-error" x-text="errMsg"></span>
</template> </template>
</div> </div>

View File

@ -6,7 +6,7 @@
<span <span
class="label-text" class="label-text"
:class="{ :class="{
'text-error': hasError, 'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode 'text-sm text-base-content/60': isViewMode
}" }"
x-text="label" x-text="label"
@ -42,15 +42,18 @@
<input <input
type="text" type="text"
class="input input-bordered w-full" class="input input-bordered w-full"
:class="{ 'input-error': hasError }" :class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value" x-model="value"
/> />
</template> </template>
<!-- Errors --> <!-- Errors -->
<template x-if="hasError"> <template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label"> <div class="label">
<template x-for="errMsg in errors" :key="errMsg"> <template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span> <span class="label-text-alt text-error" x-text="errMsg"></span>
</template> </template>
</div> </div>

View File

@ -6,7 +6,7 @@
<span <span
class="label-text" class="label-text"
:class="{ :class="{
'text-error': hasError, 'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode 'text-sm text-base-content/60': isViewMode
}" }"
x-text="label" x-text="label"
@ -41,15 +41,18 @@
<template x-if="isEditMode"> <template x-if="isEditMode">
<textarea <textarea
class="textarea textarea-bordered w-full" class="textarea textarea-bordered w-full"
:class="{ 'textarea-error': hasError }" :class="{ 'textarea-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value" x-model="value"
></textarea> ></textarea>
</template> </template>
<!-- Errors --> <!-- Errors -->
<template x-if="hasError"> <template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label"> <div class="label">
<template x-for="errMsg in errors" :key="errMsg"> <template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span> <span class="label-text-alt text-error" x-text="errMsg"></span>
</template> </template>
</div> </div>

View File

@ -0,0 +1,234 @@
{# TimeSeries table widget for measurement data #}
<div class="form-control">
<div class="flex flex-col gap-2">
<!-- Label -->
<label class="label">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- Form-level validation errors (root errors) for timeseries -->
<template x-if="$store.validationErrors.hasError('root', context)">
<div class="text-error">
<template
x-for="errMsg in $store.validationErrors.getErrors('root', context)"
:key="errMsg"
>
<span x-text="errMsg"></span>
</template>
</div>
</template>
<!-- View mode: display measurements as chart -->
<template x-if="isViewMode">
<div class="space-y-4">
<!-- Chart container -->
<template x-if="measurements.length > 0">
<div class="w-full">
<div class="h-64 w-full">
<canvas x-ref="chartCanvas"></canvas>
</div>
</div>
</template>
<template x-if="measurements.length === 0">
<div class="text-base-content/60 text-sm italic">No measurements</div>
</template>
<!-- Description and Method metadata -->
<template x-if="description || method">
<div class="space-y-2 text-sm">
<template x-if="description">
<div>
<span class="text-base-content/80 font-semibold"
>Description:</span
>
<p
class="text-base-content/70 mt-1 whitespace-pre-wrap"
x-text="description"
></p>
</div>
</template>
<template x-if="method">
<div>
<span class="text-base-content/80 font-semibold">Method:</span>
<span class="text-base-content/70 ml-2" x-text="method"></span>
</div>
</template>
</div>
</template>
</div>
</template>
<!-- Edit mode: editable table with add/remove controls -->
<template x-if="isEditMode">
<div class="space-y-2">
<!-- Measurements table -->
<div class="overflow-x-auto">
<template x-if="measurements.length > 0">
<table class="table-zebra table-sm table">
<thead>
<tr>
<th>Timestamp</th>
<th>Value</th>
<th>Error</th>
<th>Note</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
<template
x-for="(measurement, index) in measurements"
:key="index"
>
<tr>
<td>
<input
type="number"
class="input input-bordered input-sm w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
:value="formatTimestamp(measurement.timestamp)"
@input="updateMeasurement(index, 'timestamp', $event.target.value)"
/>
</td>
<td>
<input
type="number"
class="input input-bordered input-sm w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="Value"
:value="measurement.value"
@input="updateMeasurement(index, 'value', $event.target.value)"
/>
</td>
<td>
<input
type="number"
class="input input-bordered input-sm w-full"
placeholder="±"
:value="measurement.error"
@input="updateMeasurement(index, 'error', $event.target.value)"
/>
</td>
<td>
<input
type="text"
class="input input-bordered input-sm w-full"
placeholder="Note"
:value="measurement.note"
@input="updateMeasurement(index, 'note', $event.target.value)"
/>
</td>
<td>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
@click="removeMeasurement(index)"
title="Remove measurement"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="measurements.length === 0">
<div class="text-base-content/60 py-2 text-sm italic">
No measurements yet. Click "Add Measurement" to start.
</div>
</template>
</div>
<!-- Action buttons -->
<div class="flex gap-2">
<button
type="button"
class="btn btn-sm btn-primary"
@click="addMeasurement()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Add Measurement
</button>
<template x-if="measurements.length > 1">
<button
type="button"
class="btn btn-sm btn-ghost"
@click="sortByTimestamp()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"
/>
</svg>
Sort by Timestamp
</button>
</template>
</div>
</div>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>

View File

@ -21,6 +21,10 @@
type="text/css" type="text/css"
/> />
{# Chart.js - For timeseries charts #}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="{% static 'js/utils/timeseries-chart.js' %}"></script>
{# Alpine.js - For reactive components #} {# Alpine.js - For reactive components #}
<script <script
defer defer
@ -31,6 +35,7 @@
<script src="{% static 'js/alpine/pagination.js' %}"></script> <script src="{% static 'js/alpine/pagination.js' %}"></script>
<script src="{% static 'js/alpine/pathway.js' %}"></script> <script src="{% static 'js/alpine/pathway.js' %}"></script>
<script src="{% static 'js/alpine/components/schema-form.js' %}"></script> <script src="{% static 'js/alpine/components/schema-form.js' %}"></script>
<script src="{% static 'js/alpine/components/widgets.js' %}"></script>
{# Font Awesome #} {# Font Awesome #}
<link <link

View File

@ -18,7 +18,9 @@
const names = Object.keys(this.schemas); const names = Object.keys(this.schemas);
// Remove duplicates, exclude existing types, and sort alphabetically by display title // Remove duplicates, exclude existing types, and sort alphabetically by display title
const unique = [...new Set(names)]; const unique = [...new Set(names)];
const available = unique.filter(name => !this.existingTypes.includes(name)); const available = unique.filter(name =>
!this.existingTypes.includes(name) || this.schemas[name]?.schema?.['x-repeatable']
);
return available.sort((a, b) => { return available.sort((a, b) => {
const titleA = (this.schemas[a]?.schema?.['x-title'] || a).toLowerCase(); const titleA = (this.schemas[a]?.schema?.['x-title'] || a).toLowerCase();
const titleB = (this.schemas[b]?.schema?.['x-title'] || b).toLowerCase(); const titleB = (this.schemas[b]?.schema?.['x-title'] || b).toLowerCase();
@ -32,6 +34,9 @@
// Reset formData when type changes and increment key to force re-render // Reset formData when type changes and increment key to force re-render
this.formData = null; this.formData = null;
this.formRenderKey++; this.formRenderKey++;
// Clear previous errors
this.error = null;
Alpine.store('validationErrors').clearErrors(); // No context - clears all
}); });
// Load schemas and existing items // Load schemas and existing items
@ -63,6 +68,7 @@
this.selectedType = ''; this.selectedType = '';
this.error = null; this.error = null;
this.formData = null; this.formData = null;
Alpine.store('validationErrors').clearErrors(); // No context - clears all
}, },
setFormData(data) { setFormData(data) {
@ -96,11 +102,12 @@
window.location.reload(); window.location.reload();
} catch (err) { } catch (err) {
if (err.isValidationError && err.fieldErrors) { if (err.isValidationError && err.fieldErrors) {
window.dispatchEvent(new CustomEvent('set-field-errors', { // No context for add modal - simple flat errors
detail: err.fieldErrors Alpine.store('validationErrors').setErrors(err.fieldErrors);
})); this.error = err.message || 'Please correct the errors in the form';
} else {
this.error = err.message || 'An error occurred. Please try again.';
} }
this.error = err.message;
} finally { } finally {
this.isSubmitting = false; this.isSubmitting = false;
} }
@ -155,7 +162,7 @@
<template x-for="name in sortedSchemaNames" :key="name"> <template x-for="name in sortedSchemaNames" :key="name">
<option <option
:value="name" :value="name"
x-text="(schemas[name].schema && schemas[name].schema['x-title']) || name" x-text="(schemas[name].schema && (schemas[name].schema['x-title'] || schemas[name].schema.title)) || name"
></option> ></option>
</template> </template>
</select> </select>
@ -169,6 +176,7 @@
x-data="schemaRenderer({ x-data="schemaRenderer({
rjsf: schemas[selectedType], rjsf: schemas[selectedType],
mode: 'edit' mode: 'edit'
// No context - single form, backward compatible
})" })"
x-init="await init(); $dispatch('form-data-ready', data)" x-init="await init(); $dispatch('form-data-ready', data)"
> >

View File

@ -18,10 +18,10 @@
const scenarioUuid = '{{ scenario.uuid }}'; const scenarioUuid = '{{ scenario.uuid }}';
const { items, schemas } = const { items, schemas } =
await window.AdditionalInformationApi.loadSchemasAndItems(scenarioUuid); await window.AdditionalInformationApi.loadSchemasAndItems(scenarioUuid);
this.items = items;
this.schemas = schemas; this.schemas = schemas;
// Store deep copy of original items for comparison // Store deep copy of original items for comparison
this.originalItems = JSON.parse(JSON.stringify(items)); this.originalItems = JSON.parse(JSON.stringify(items));
this.items = items;
} catch (err) { } catch (err) {
this.error = err.message; this.error = err.message;
} finally { } finally {
@ -33,6 +33,7 @@
this.isSubmitting = false; this.isSubmitting = false;
this.error = null; this.error = null;
this.modifiedUuids.clear(); this.modifiedUuids.clear();
Alpine.store('validationErrors').clearErrors(); // Clear all contexts
}, },
updateItemData(uuid, data) { updateItemData(uuid, data) {
@ -74,18 +75,15 @@
} catch (err) { } catch (err) {
// Handle validation errors with field-level details // Handle validation errors with field-level details
if (err.isValidationError && err.fieldErrors) { if (err.isValidationError && err.fieldErrors) {
this.error = err.message; this.error = err.message || 'Please correct the errors in the form';
// Dispatch event to set field errors in the specific form
if (err.itemUuid) { // Backend returns errors keyed by UUID, each with field-level error arrays
window.dispatchEvent(new CustomEvent('set-field-errors-for-item', { // Set errors for each item with its UUID as context
detail: { Object.entries(err.fieldErrors).forEach(([uuid, fieldErrors]) => {
uuid: err.itemUuid, Alpine.store('validationErrors').setErrors(fieldErrors, uuid);
fieldErrors: err.fieldErrors });
}
}));
}
} else { } else {
this.error = err.message; this.error = err.message || 'An error occurred. Please try again.';
} }
} finally { } finally {
this.isSubmitting = false; this.isSubmitting = false;
@ -133,7 +131,8 @@
x-data="schemaRenderer({ x-data="schemaRenderer({
rjsf: schemas[item.type.toLowerCase()], rjsf: schemas[item.type.toLowerCase()],
data: item.data, data: item.data,
mode: 'edit' mode: 'edit',
context: item.uuid // Pass item UUID as context for error scoping
})" })"
x-init="await init(); $watch('data', (value) => { $dispatch('update-item-data', { uuid: item.uuid, data: value }) }, { deep: true })" x-init="await init(); $watch('data', (value) => { $dispatch('update-item-data', { uuid: item.uuid, data: value }) }, { deep: true })"
> >