diff --git a/epapi/tests/utils/__init__.py b/epapi/tests/utils/__init__.py new file mode 100644 index 00000000..c3b59707 --- /dev/null +++ b/epapi/tests/utils/__init__.py @@ -0,0 +1 @@ +"""Tests for epapi utility modules.""" diff --git a/epapi/tests/utils/test_validation_errors.py b/epapi/tests/utils/test_validation_errors.py new file mode 100644 index 00000000..c2a98be6 --- /dev/null +++ b/epapi/tests/utils/test_validation_errors.py @@ -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) diff --git a/epapi/tests/v1/test_additional_information.py b/epapi/tests/v1/test_additional_information.py index d8c19b25..3439b52e 100644 --- a/epapi/tests/v1/test_additional_information.py +++ b/epapi/tests/v1/test_additional_information.py @@ -252,6 +252,51 @@ class AdditionalInformationAPITests(TestCase): 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): """Test DELETE removes additional information.""" self.client.force_login(self.user) diff --git a/epapi/utils/schema_transformers.py b/epapi/utils/schema_transformers.py index 6ff18bdf..0d3c074d 100644 --- a/epapi/utils/schema_transformers.py +++ b/epapi/utils/schema_transformers.py @@ -85,6 +85,10 @@ def extract_ui_config_from_model(model_cls: Type[BaseModel]) -> dict[str, Any]: # Extract config for each field 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) if isinstance(ui_config, UIConfig): @@ -139,7 +143,9 @@ def build_schema(model_cls: Type[BaseModel]) -> dict[str, Any]: schema[f"x-{key}"] = value # 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"] return schema diff --git a/epapi/utils/validation_errors.py b/epapi/utils/validation_errors.py new file mode 100644 index 00000000..933e59b7 --- /dev/null +++ b/epapi/utils/validation_errors.py @@ -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)) diff --git a/epapi/v1/endpoints/additional_information.py b/epapi/v1/endpoints/additional_information.py index 5fbaf190..44365f3f 100644 --- a/epapi/v1/endpoints/additional_information.py +++ b/epapi/v1/endpoints/additional_information.py @@ -2,14 +2,13 @@ from ninja import Router, Body from ninja.errors import HttpError from uuid import UUID from pydantic import ValidationError -from pydantic_core import ErrorDetails from typing import Dict, Any import logging -import json from envipy_additional_information import registry from envipy_additional_information.groups import GroupEnum 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 logger = logging.getLogger(__name__) @@ -56,28 +55,6 @@ def list_scenario_info(request, scenario_uuid: UUID): 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}/") def add_scenario_info( request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...) @@ -90,27 +67,7 @@ def add_scenario_info( try: instance = cls(**payload) # Pydantic validates except ValidationError as e: - # 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)) + handle_validation_error(e) scenario = get_scenario_for_write(request.user, scenario_uuid) @@ -147,27 +104,7 @@ def update_scenario_info( try: instance = cls(**payload) except ValidationError as e: - # 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)) + handle_validation_error(e) # Use model method for update try: diff --git a/static/js/alpine/components/schema-form.js b/static/js/alpine/components/schema-form.js index 1f6bd937..223981a7 100644 --- a/static/js/alpine/components/schema-form.js +++ b/static/js/alpine/components/schema-form.js @@ -12,33 +12,84 @@ * endpoint: '/api/v1/scenario/{uuid}/information/temperature/' * })"> */ -document.addEventListener('alpine:init', () => { - Alpine.data('schemaRenderer', (options = {}) => ({ +document.addEventListener("alpine:init", () => { + // Global validation error store with context scoping + Alpine.store('validationErrors', { + errors: {}, + + // Set errors for a specific context (UUID) or globally (no context) + setErrors(errors, context = null) { + if (context) { + // Namespace all field names with context prefix + const namespacedErrors = {}; + Object.entries(errors).forEach(([field, messages]) => { + const key = `${context}.${field}`; + namespacedErrors[key] = messages; + }); + // Merge into existing errors (preserves other contexts) + this.errors = { ...this.errors, ...namespacedErrors }; + } else { + // No context - merge as-is for backward compatibility + this.errors = { ...this.errors, ...errors }; + } + }, + + // Clear errors for a specific context or all errors + clearErrors(context = null) { + if (context) { + // Clear only errors for this context + const newErrors = {}; + const prefix = `${context}.`; + Object.keys(this.errors).forEach(key => { + if (!key.startsWith(prefix)) { + newErrors[key] = this.errors[key]; + } + }); + this.errors = newErrors; + } else { + // Clear all errors + this.errors = {}; + } + }, + + // Clear a specific field, optionally within a context + clearField(fieldName, context = null) { + const key = context ? `${context}.${fieldName}` : fieldName; + if (this.errors[key]) { + delete this.errors[key]; + // Trigger reactivity by creating new object + this.errors = { ...this.errors }; + } + }, + + // Check if a field has errors, optionally within a context + hasError(fieldName, context = null) { + const key = context ? `${context}.${fieldName}` : fieldName; + return Array.isArray(this.errors[key]) && this.errors[key].length > 0; + }, + + // Get errors for a field, optionally within a context + getErrors(fieldName, context = null) { + const key = context ? `${context}.${fieldName}` : fieldName; + return this.errors[key] || []; + } + }); + + Alpine.data("schemaRenderer", (options = {}) => ({ schema: null, uiSchema: {}, data: {}, - mode: options.mode || 'view', // 'view' | 'edit' - endpoint: options.endpoint || '', + mode: options.mode || "view", // 'view' | 'edit' + endpoint: options.endpoint || "", loading: false, error: null, - fieldErrors: {}, // Server-side field-level errors + context: options.context || null, // UUID for items, null for single forms + debugErrors: + options.debugErrors ?? + (typeof window !== "undefined" && + window.location?.search?.includes("debugErrors=1")), async init() { - // Listen for field error events from parent modal - window.addEventListener('set-field-errors', (e) => { - // Apply to all forms (used by add modal which has only one form) - this.fieldErrors = e.detail || {}; - }); - - // Listen for field error events targeted to a specific item (for update modal) - window.addEventListener('set-field-errors-for-item', (e) => { - // Only update if this form matches the UUID - const itemData = options.data || {}; - if (itemData.uuid === e.detail?.uuid) { - this.fieldErrors = e.detail.fieldErrors || {}; - } - }); - if (options.schemaUrl) { try { this.loading = true; @@ -50,31 +101,31 @@ document.addEventListener('alpine:init', () => { // RJSF format: {schema, uiSchema, formData, groups} if (!rjsf.schema) { - throw new Error('Invalid RJSF format: missing schema property'); + throw new Error("Invalid RJSF format: missing schema property"); } this.schema = rjsf.schema; this.uiSchema = rjsf.uiSchema || {}; this.data = options.data ? JSON.parse(JSON.stringify(options.data)) - : (rjsf.formData || {}); + : rjsf.formData || {}; } catch (err) { this.error = err.message; - console.error('Error loading schema:', err); + console.error("Error loading schema:", err); } finally { this.loading = false; } } else if (options.rjsf) { // Direct RJSF object passed if (!options.rjsf.schema) { - throw new Error('Invalid RJSF format: missing schema property'); + throw new Error("Invalid RJSF format: missing schema property"); } this.schema = options.rjsf.schema; this.uiSchema = options.rjsf.uiSchema || {}; this.data = options.data ? JSON.parse(JSON.stringify(options.data)) - : (options.rjsf.formData || {}); + : options.rjsf.formData || {}; } // Initialize data from formData or options @@ -84,19 +135,22 @@ document.addEventListener('alpine:init', () => { // Ensure all schema fields are properly initialized if (this.schema && this.schema.properties) { - for (const [key, propSchema] of Object.entries(this.schema.properties)) { + for (const [key, propSchema] of Object.entries( + this.schema.properties, + )) { const widget = this.getWidget(key, propSchema); - if (widget === 'interval') { + if (widget === "interval") { // Ensure interval fields are objects with start/end - if (!this.data[key] || typeof this.data[key] !== 'object') { + if (!this.data[key] || typeof this.data[key] !== "object") { this.data[key] = { start: null, end: null }; } else { // Ensure start and end exist - if (this.data[key].start === undefined) this.data[key].start = null; + if (this.data[key].start === undefined) + this.data[key].start = null; if (this.data[key].end === undefined) this.data[key].end = null; } - } else if (widget === 'timeseries-table') { + } else if (widget === "timeseries-table") { // Ensure timeseries fields are arrays if (!this.data[key] || !Array.isArray(this.data[key])) { this.data[key] = []; @@ -104,86 +158,141 @@ document.addEventListener('alpine:init', () => { } else if (this.data[key] === undefined) { // ONLY initialize if truly undefined, not just falsy // This preserves empty strings, null, 0, false as valid values - if (propSchema.type === 'boolean') { + if (propSchema.type === "boolean") { this.data[key] = false; - } else if (propSchema.type === 'number' || propSchema.type === 'integer') { + } else if ( + propSchema.type === "number" || + propSchema.type === "integer" + ) { this.data[key] = null; } else if (propSchema.enum) { // For select fields, use null to show placeholder this.data[key] = null; } else { - this.data[key] = ''; + this.data[key] = ""; } } // If data[key] exists (even if empty string or null), don't overwrite } } + + // UX: Clear field errors when fields change (with context) + if (this.mode === "edit" && this.schema?.properties) { + Object.keys(this.schema.properties).forEach((key) => { + this.$watch( + `data.${key}`, + () => { + Alpine.store('validationErrors').clearField(key, this.context); + }, + { deep: true }, + ); + }); + } }, getWidget(fieldName, fieldSchema) { - // Check uiSchema first (RJSF format) - if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:widget']) { - return this.uiSchema[fieldName]['ui:widget']; - } + // Defensive check: ensure fieldSchema is provided + if (!fieldSchema) return "text"; - // Check for interval type (object with start/end properties) - if (fieldSchema.type === 'object' && + try { + // Check uiSchema first (RJSF format) + if ( + this.uiSchema && + this.uiSchema[fieldName] && + this.uiSchema[fieldName]["ui:widget"] + ) { + return this.uiSchema[fieldName]["ui:widget"]; + } + + // Check for interval type (object with start/end properties) + if ( + fieldSchema.type === "object" && fieldSchema.properties && fieldSchema.properties.start && - fieldSchema.properties.end) { - return 'interval'; - } + fieldSchema.properties.end + ) { + return "interval"; + } - // Infer from JSON Schema type - if (fieldSchema.enum) return 'select'; - if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') return 'number'; - if (fieldSchema.type === 'boolean') return 'checkbox'; - return 'text'; + // Check for measurements array type (timeseries-table widget) + if ( + fieldSchema.type === "array" && + fieldSchema.items?.properties?.timestamp && + fieldSchema.items?.properties?.value + ) { + return "timeseries-table"; + } + + // Infer from JSON Schema type + if (fieldSchema.enum) return "select"; + if (fieldSchema.type === "number" || fieldSchema.type === "integer") + return "number"; + if (fieldSchema.type === "boolean") return "checkbox"; + return "text"; + } catch (e) { + // Fallback to text widget if anything fails + console.warn("Error in getWidget:", e); + return "text"; + } }, getLabel(fieldName, fieldSchema) { // Check uiSchema (RJSF format) - if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:label']) { - return this.uiSchema[fieldName]['ui:label']; + if (this.uiSchema[fieldName] && this.uiSchema[fieldName]["ui:label"]) { + return this.uiSchema[fieldName]["ui:label"]; } // Default: format field name - return fieldName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - }, - - getHelp(fieldName) { - // Get help text from uiSchema - if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:help']) { - return this.uiSchema[fieldName]['ui:help']; - } - return null; - }, - - getPlaceholder(fieldName) { - // Get placeholder from uiSchema - if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:placeholder']) { - return this.uiSchema[fieldName]['ui:placeholder']; - } - return null; + return fieldName + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); }, getFieldOrder() { - // Get ordered list of field names based on ui:order - if (!this.schema || !this.schema.properties) return []; + try { + // Get ordered list of field names based on ui:order + if (!this.schema || !this.schema.properties) return []; - const fields = Object.keys(this.schema.properties); + // Only include fields that have UI configs + const fields = Object.keys(this.schema.properties).filter( + (fieldName) => this.uiSchema && this.uiSchema[fieldName], + ); - // Sort by ui:order if available - return fields.sort((a, b) => { - const orderA = this.uiSchema[a]?.['ui:order'] || '999'; - const orderB = this.uiSchema[b]?.['ui:order'] || '999'; - return parseInt(orderA) - parseInt(orderB); - }); + // Sort by ui:order if available + return fields.sort((a, b) => { + const orderA = this.uiSchema[a]?.["ui:order"] || "999"; + const orderB = this.uiSchema[b]?.["ui:order"] || "999"; + return parseInt(orderA) - parseInt(orderB); + }); + } catch (e) { + // Return empty array if anything fails to prevent errors + console.warn("Error in getFieldOrder:", e); + return []; + } + }, + + hasTimeseriesField() { + try { + // Check if any field in the schema is a timeseries-table widget + if (!this.schema || !this.schema.properties) { + return false; + } + + return Object.keys(this.schema.properties).some((fieldName) => { + const fieldSchema = this.schema.properties[fieldName]; + if (!fieldSchema) return false; + return this.getWidget(fieldName, fieldSchema) === "timeseries-table"; + }); + } catch (e) { + // Return false if anything fails to prevent errors + console.warn("Error in hasTimeseriesField:", e); + return false; + } }, async submit() { if (!this.endpoint) { - console.error('No endpoint specified for submission'); + console.error("No endpoint specified for submission"); return; } @@ -191,14 +300,15 @@ document.addEventListener('alpine:init', () => { this.error = null; try { - const csrftoken = document.querySelector("[name=csrf-token]")?.content || ''; + const csrftoken = + document.querySelector("[name=csrf-token]")?.content || ""; const res = await fetch(this.endpoint, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrftoken + "Content-Type": "application/json", + "X-CSRFToken": csrftoken, }, - body: JSON.stringify(this.data) + body: JSON.stringify(this.data), }); if (!res.ok) { @@ -210,35 +320,50 @@ document.addEventListener('alpine:init', () => { } // Handle validation errors (field-level) - this.fieldErrors = {}; + Alpine.store('validationErrors').clearErrors(); // Try to parse structured error response let parsedError = errorData; // If error is a JSON string, parse it - if (typeof errorData.error === 'string' && errorData.error.startsWith('{')) { + if ( + typeof errorData.error === "string" && + errorData.error.startsWith("{") + ) { parsedError = JSON.parse(errorData.error); - } if (parsedError.detail && Array.isArray(parsedError.detail)) { // Pydantic validation errors format: [{loc: ['field'], msg: '...', type: '...'}] + const fieldErrors = {}; for (const err of parsedError.detail) { - const field = err.loc && err.loc.length > 0 ? err.loc[err.loc.length - 1] : 'root'; - if (!this.fieldErrors[field]) { - this.fieldErrors[field] = []; + const field = + err.loc && err.loc.length > 0 + ? err.loc[err.loc.length - 1] + : "root"; + if (!fieldErrors[field]) { + fieldErrors[field] = []; } - this.fieldErrors[field].push(err.msg || err.message || 'Validation error'); + fieldErrors[field].push( + err.msg || err.message || "Validation error", + ); } - throw new Error('Validation failed. Please check the fields below.'); + Alpine.store('validationErrors').setErrors(fieldErrors); + throw new Error( + "Validation failed. Please check the fields below.", + ); } else { // General error - throw new Error(parsedError.error || parsedError.detail || `Request failed: ${res.statusText}`); + throw new Error( + parsedError.error || + parsedError.detail || + `Request failed: ${res.statusText}`, + ); } } // Clear errors on success - this.fieldErrors = {}; + Alpine.store('validationErrors').clearErrors(); const result = await res.json(); return result; @@ -248,6 +373,6 @@ document.addEventListener('alpine:init', () => { } finally { this.loading = false; } - } + }, })); }); diff --git a/static/js/alpine/components/widgets.js b/static/js/alpine/components/widgets.js index 35ac6cb8..e4df0cc1 100644 --- a/static/js/alpine/components/widgets.js +++ b/static/js/alpine/components/widgets.js @@ -4,15 +4,24 @@ * Centralized widget component definitions for dynamic form rendering. * Each widget receives explicit parameters instead of context object for better traceability. */ -document.addEventListener('alpine:init', () => { +document.addEventListener("alpine:init", () => { // Base widget factory with common functionality - const baseWidget = (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ + const baseWidget = ( fieldName, data, schema, uiSchema, - fieldErrors, mode, + debugErrors, + context = null // NEW: context for error namespacing + ) => ({ + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, // Store context for use in templates // Field schema access get fieldSchema() { @@ -22,165 +31,432 @@ document.addEventListener('alpine:init', () => { // Common metadata get label() { // Check uiSchema first (RJSF format) - if (this.uiSchema?.[this.fieldName]?.['ui:label']) { - return this.uiSchema[this.fieldName]['ui:label']; + if (this.uiSchema?.[this.fieldName]?.["ui:label"]) { + return this.uiSchema[this.fieldName]["ui:label"]; } // Fall back to schema title if (this.fieldSchema.title) { return this.fieldSchema.title; } // Default: format field name - return this.fieldName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + return this.fieldName + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); }, get helpText() { - return this.fieldSchema.description || ''; + return this.fieldSchema.description || ""; }, // Field-level unit extraction from uiSchema (RJSF format) get unit() { - return this.uiSchema?.[this.fieldName]?.['ui:unit'] || null; - }, - - // Error handling - get hasError() { - return !!this.fieldErrors?.[this.fieldName]; - }, - get errors() { - return this.fieldErrors?.[this.fieldName] || []; + return this.uiSchema?.[this.fieldName]?.["ui:unit"] || null; }, // Mode checks - get isViewMode() { return this.mode === 'view'; }, - get isEditMode() { return this.mode === 'edit'; }, + get isViewMode() { + return this.mode === "view"; + }, + get isEditMode() { + return this.mode === "edit"; + }, }); // Text widget - Alpine.data('textWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ - ...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), + Alpine.data( + "textWidget", + (fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({ + ...baseWidget( + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, + ), - get value() { return this.data[this.fieldName] || ''; }, - set value(v) { this.data[this.fieldName] = v; }, - })); + get value() { + return this.data[this.fieldName] || ""; + }, + set value(v) { + this.data[this.fieldName] = v; + }, + }), + ); // Textarea widget - Alpine.data('textareaWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ - ...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), + Alpine.data( + "textareaWidget", + (fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({ + ...baseWidget( + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, + ), - get value() { return this.data[this.fieldName] || ''; }, - set value(v) { this.data[this.fieldName] = v; }, - })); + get value() { + return this.data[this.fieldName] || ""; + }, + set value(v) { + this.data[this.fieldName] = v; + }, + }), + ); // Number widget with unit support - Alpine.data('numberWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ - ...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), + Alpine.data( + "numberWidget", + (fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({ + ...baseWidget( + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, + ), - get value() { return this.data[this.fieldName]; }, - set value(v) { - this.data[this.fieldName] = v === '' || v === null ? null : parseFloat(v); - }, - get hasValue() { - return this.value !== null && this.value !== undefined && this.value !== ''; - }, - // Format value with unit for view mode - get displayValue() { - if (!this.hasValue) return '—'; - return this.unit ? `${this.value} ${this.unit}` : String(this.value); - }, - })); + get value() { + return this.data[this.fieldName]; + }, + set value(v) { + this.data[this.fieldName] = + v === "" || v === null ? null : parseFloat(v); + }, + get hasValue() { + return ( + this.value !== null && this.value !== undefined && this.value !== "" + ); + }, + // Format value with unit for view mode + get displayValue() { + if (!this.hasValue) return "—"; + return this.unit ? `${this.value} ${this.unit}` : String(this.value); + }, + }), + ); // Select widget - Alpine.data('selectWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ - ...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), + Alpine.data( + "selectWidget", + (fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({ + ...baseWidget( + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, + ), - get value() { return this.data[this.fieldName] || ''; }, - set value(v) { this.data[this.fieldName] = v; }, - get options() { return this.fieldSchema.enum || []; }, - })); + get value() { + return this.data[this.fieldName] || ""; + }, + set value(v) { + this.data[this.fieldName] = v; + }, + get options() { + return this.fieldSchema.enum || []; + }, + }), + ); // Checkbox widget - Alpine.data('checkboxWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ - ...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), + Alpine.data( + "checkboxWidget", + (fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({ + ...baseWidget( + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, + ), - get checked() { return !!this.data[this.fieldName]; }, - set checked(v) { this.data[this.fieldName] = v; }, - })); + get checked() { + return !!this.data[this.fieldName]; + }, + set checked(v) { + this.data[this.fieldName] = v; + }, + }), + ); // Interval widget with unit support - Alpine.data('intervalWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ - ...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), + Alpine.data( + "intervalWidget", + (fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({ + ...baseWidget( + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, + ), - get start() { - return this.data[this.fieldName]?.start ?? null; - }, - set start(v) { - if (!this.data[this.fieldName]) this.data[this.fieldName] = {}; - this.data[this.fieldName].start = v === '' || v === null ? null : parseFloat(v); - }, - get end() { - return this.data[this.fieldName]?.end ?? null; - }, - set end(v) { - if (!this.data[this.fieldName]) this.data[this.fieldName] = {}; - this.data[this.fieldName].end = v === '' || v === null ? null : parseFloat(v); - }, - // Format interval with unit for view mode - get displayValue() { - const s = this.start, e = this.end; - const unitStr = this.unit ? ` ${this.unit}` : ''; + get start() { + return this.data[this.fieldName]?.start ?? null; + }, + set start(v) { + if (!this.data[this.fieldName]) this.data[this.fieldName] = {}; + this.data[this.fieldName].start = + v === "" || v === null ? null : parseFloat(v); + }, + get end() { + return this.data[this.fieldName]?.end ?? null; + }, + set end(v) { + if (!this.data[this.fieldName]) this.data[this.fieldName] = {}; + this.data[this.fieldName].end = + v === "" || v === null ? null : parseFloat(v); + }, + // Format interval with unit for view mode + get displayValue() { + const s = this.start, + e = this.end; + const unitStr = this.unit ? ` ${this.unit}` : ""; - if (s !== null && e !== null) return `${s} – ${e}${unitStr}`; - if (s !== null) return `≥ ${s}${unitStr}`; - if (e !== null) return `≤ ${e}${unitStr}`; - return '—'; - }, + if (s !== null && e !== null) return `${s} – ${e}${unitStr}`; + if (s !== null) return `≥ ${s}${unitStr}`; + if (e !== null) return `≤ ${e}${unitStr}`; + return "—"; + }, - get isSameValue() { - return this.start !== null && this.start === this.end; - }, + get isSameValue() { + return this.start !== null && this.start === this.end; + }, - // Validation: start must be <= end - get hasValidationError() { - if (this.isViewMode) return false; - const s = this.start; - const e = this.end; - // Only validate if both values are provided - if (s !== null && e !== null && typeof s === 'number' && typeof e === 'number') { - return s > e; - } - return false; - }, - - // Override hasError to include validation error - get hasError() { - return this.hasValidationError || !!this.fieldErrors?.[this.fieldName]; - }, - - // Override errors to include validation error message - get errors() { - const serverErrors = this.fieldErrors?.[this.fieldName] || []; - const validationErrors = this.hasValidationError - ? ['Start value must be less than or equal to end value'] - : []; - return [...validationErrors, ...serverErrors]; - }, - })); + // Validation: start must be <= end (client-side) + get hasValidationError() { + if (this.isViewMode) return false; + const s = this.start; + const e = this.end; + // Only validate if both values are provided + if ( + s !== null && + e !== null && + typeof s === "number" && + typeof e === "number" + ) { + return s > e; + } + return false; + }, + }), + ); // PubMed link widget - Alpine.data('pubmedWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ - ...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), + Alpine.data( + "pubmedWidget", + (fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({ + ...baseWidget( + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, + ), - get value() { return this.data[this.fieldName] || ''; }, - set value(v) { this.data[this.fieldName] = v; }, - get pubmedUrl() { - return this.value ? `https://pubmed.ncbi.nlm.nih.gov/${this.value}` : null; - }, - })); + get value() { + return this.data[this.fieldName] || ""; + }, + set value(v) { + this.data[this.fieldName] = v; + }, + get pubmedUrl() { + return this.value + ? `https://pubmed.ncbi.nlm.nih.gov/${this.value}` + : null; + }, + }), + ); // Compound link widget - Alpine.data('compoundWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({ - ...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode), + Alpine.data( + "compoundWidget", + (fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({ + ...baseWidget( + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, + ), - get value() { return this.data[this.fieldName] || ''; }, - set value(v) { this.data[this.fieldName] = v; }, - })); + get value() { + return this.data[this.fieldName] || ""; + }, + set value(v) { + this.data[this.fieldName] = v; + }, + }), + ); + + // TimeSeries table widget + Alpine.data( + "timeseriesTableWidget", + (fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({ + ...baseWidget( + fieldName, + data, + schema, + uiSchema, + mode, + debugErrors, + context, + ), + + chartInstance: null, + + // Getter/setter for measurements array + get measurements() { + return this.data[this.fieldName] || []; + }, + set measurements(v) { + this.data[this.fieldName] = v; + }, + + // Get description from sibling field + get description() { + return this.data?.description || ""; + }, + + // Get method from sibling field + get method() { + return this.data?.method || ""; + }, + + // Computed property for chart options + get chartOptions() { + return { + measurements: this.measurements, + xAxisLabel: this.data?.x_axis_label || "Time", + yAxisLabel: this.data?.y_axis_label || "Value", + xAxisUnit: this.data?.x_axis_unit || "", + yAxisUnit: this.data?.y_axis_unit || "", + }; + }, + + // Add new measurement + addMeasurement() { + if (!this.data[this.fieldName]) { + this.data[this.fieldName] = []; + } + this.data[this.fieldName].push({ + timestamp: null, + value: null, + error: null, + note: "", + }); + }, + + // Remove measurement by index + removeMeasurement(index) { + if ( + this.data[this.fieldName] && + Array.isArray(this.data[this.fieldName]) + ) { + this.data[this.fieldName].splice(index, 1); + } + }, + + // Update specific measurement field + updateMeasurement(index, field, value) { + if (this.data[this.fieldName] && this.data[this.fieldName][index]) { + if (field === "timestamp" || field === "value" || field === "error") { + // Parse all numeric fields (timestamp is days as float) + this.data[this.fieldName][index][field] = + value === "" || value === null ? null : parseFloat(value); + } else { + // Store other fields as-is + this.data[this.fieldName][index][field] = value; + } + } + }, + + // Format timestamp for display (timestamp is numeric days as float) + formatTimestamp(timestamp) { + return timestamp ?? ""; + }, + + // Sort by timestamp (numeric days) + sortByTimestamp() { + if ( + this.data[this.fieldName] && + Array.isArray(this.data[this.fieldName]) + ) { + this.data[this.fieldName].sort((a, b) => { + const tsA = a.timestamp ?? Infinity; + const tsB = b.timestamp ?? Infinity; + return tsA - tsB; + }); + } + }, + + // Chart lifecycle methods (delegates to TimeSeriesChart utility) + initChart() { + if (!this.isViewMode || !window.Chart || !window.TimeSeriesChart) + return; + + const canvas = this.$refs?.chartCanvas; + if (!canvas) return; + + this.destroyChart(); + + if (this.measurements.length === 0) return; + + this.chartInstance = window.TimeSeriesChart.create( + canvas, + this.chartOptions, + ); + }, + + updateChart() { + if (!this.chartInstance || !this.isViewMode) return; + window.TimeSeriesChart.update( + this.chartInstance, + this.measurements, + this.chartOptions, + ); + }, + + destroyChart() { + if (this.chartInstance) { + window.TimeSeriesChart.destroy(this.chartInstance); + this.chartInstance = null; + } + }, + + // Alpine lifecycle hooks + init() { + if (this.isViewMode && window.Chart) { + // Use $nextTick to ensure DOM is ready + this.$nextTick(() => { + this.initChart(); + }); + + // Watch measurements array for changes and update chart + this.$watch("data." + this.fieldName, () => { + if (this.chartInstance) { + this.updateChart(); + } + }); + } + }, + }), + ); }); diff --git a/static/js/api/additional-information.js b/static/js/api/additional-information.js index 827d1995..616a3cc6 100644 --- a/static/js/api/additional-information.js +++ b/static/js/api/additional-information.js @@ -32,14 +32,14 @@ window.AdditionalInformationApi = { sanitizePayload(value) { if (Array.isArray(value)) { return value - .map(item => this.sanitizePayload(item)) - .filter(item => item !== ''); + .map((item) => this.sanitizePayload(item)) + .filter((item) => item !== ""); } - if (value && typeof value === 'object') { + if (value && typeof value === "object") { const cleaned = {}; for (const [key, item] of Object.entries(value)) { - if (item === '') continue; + if (item === "") continue; cleaned[key] = this.sanitizePayload(item); } return cleaned; @@ -53,7 +53,7 @@ window.AdditionalInformationApi = { * @returns {string} CSRF token */ getCsrfToken() { - return document.querySelector('[name=csrf-token]')?.content || ''; + return document.querySelector("[name=csrf-token]")?.content || ""; }, /** @@ -62,10 +62,10 @@ window.AdditionalInformationApi = { */ _buildHeaders(includeContentType = true) { const headers = { - 'X-CSRFToken': this.getCsrfToken() + "X-CSRFToken": this.getCsrfToken(), }; if (includeContentType) { - headers['Content-Type'] = 'application/json'; + headers["Content-Type"] = "application/json"; } return headers; }, @@ -85,26 +85,34 @@ window.AdditionalInformationApi = { // Try to parse the error if it's a JSON string let parsedError = errorData; - if (typeof errorData.error === 'string' && errorData.error.startsWith('{')) { + const errorStr = errorData.detail || errorData.error; + if (typeof errorStr === "string" && errorStr.startsWith("{")) { try { - parsedError = JSON.parse(errorData.error); + parsedError = JSON.parse(errorStr); } catch { // Not JSON, use as-is } } // If it's a structured validation error, throw with field errors - if (parsedError.type === 'validation_error' && parsedError.field_errors) { + if (parsedError.type === "validation_error" && parsedError.field_errors) { this._log(`${action} VALIDATION ERROR`, parsedError); - const error = new Error(parsedError.message || 'Validation failed'); + const error = new Error(parsedError.message || "Validation failed"); error.fieldErrors = parsedError.field_errors; error.isValidationError = true; throw error; } // General error - const errorMsg = parsedError.message || parsedError.error || parsedError.detail || `${action} failed: ${response.statusText}`; - this._log(`${action} ERROR`, { status: response.status, error: errorMsg }); + const errorMsg = + parsedError.message || + parsedError.error || + parsedError.detail || + `${action} failed: ${response.statusText}`; + this._log(`${action} ERROR`, { + status: response.status, + error: errorMsg, + }); throw new Error(errorMsg); } @@ -118,9 +126,9 @@ window.AdditionalInformationApi = { * @returns {Promise} Object with schema definitions */ async loadSchemas() { - this._log('loadSchemas', 'Starting...'); - const response = await fetch('/api/v1/information/schema/'); - return this._handleResponse(response, 'loadSchemas'); + this._log("loadSchemas", "Starting..."); + const response = await fetch("/api/v1/information/schema/"); + return this._handleResponse(response, "loadSchemas"); }, /** @@ -129,9 +137,11 @@ window.AdditionalInformationApi = { * @returns {Promise} Array of additional information items */ async loadItems(scenarioUuid) { - this._log('loadItems', { scenarioUuid }); - const response = await fetch(`/api/v1/scenario/${scenarioUuid}/information/`); - return this._handleResponse(response, 'loadItems'); + this._log("loadItems", { scenarioUuid }); + const response = await fetch( + `/api/v1/scenario/${scenarioUuid}/information/`, + ); + return this._handleResponse(response, "loadItems"); }, /** @@ -140,11 +150,11 @@ window.AdditionalInformationApi = { * @returns {Promise<{schemas: Object, items: Array}>} */ async loadSchemasAndItems(scenarioUuid) { - this._log('loadSchemasAndItems', { scenarioUuid }); + this._log("loadSchemasAndItems", { scenarioUuid }); const [schemas, items] = await Promise.all([ this.loadSchemas(), - this.loadItems(scenarioUuid) + this.loadItems(scenarioUuid), ]); return { schemas, items }; @@ -159,7 +169,7 @@ window.AdditionalInformationApi = { */ async createItem(scenarioUuid, modelName, data) { const sanitizedData = this.sanitizePayload(data); - this._log('createItem', { scenarioUuid, modelName, data: sanitizedData }); + this._log("createItem", { scenarioUuid, modelName, data: sanitizedData }); // Normalize model name to lowercase const normalizedName = modelName.toLowerCase(); @@ -167,13 +177,13 @@ window.AdditionalInformationApi = { const response = await fetch( `/api/v1/scenario/${scenarioUuid}/information/${normalizedName}/`, { - method: 'POST', + method: "POST", headers: this._buildHeaders(), - body: JSON.stringify(sanitizedData) - } + body: JSON.stringify(sanitizedData), + }, ); - return this._handleResponse(response, 'createItem'); + return this._handleResponse(response, "createItem"); }, /** @@ -183,17 +193,17 @@ window.AdditionalInformationApi = { * @returns {Promise<{status: string}>} */ async deleteItem(scenarioUuid, itemUuid) { - this._log('deleteItem', { scenarioUuid, itemUuid }); + this._log("deleteItem", { scenarioUuid, itemUuid }); const response = await fetch( `/api/v1/scenario/${scenarioUuid}/information/item/${itemUuid}/`, { - method: 'DELETE', - headers: this._buildHeaders(false) - } + method: "DELETE", + headers: this._buildHeaders(false), + }, ); - return this._handleResponse(response, 'deleteItem'); + return this._handleResponse(response, "deleteItem"); }, /** @@ -205,7 +215,10 @@ window.AdditionalInformationApi = { */ async updateItem(scenarioUuid, item) { const sanitizedData = this.sanitizePayload(item.data); - this._log('updateItem', { scenarioUuid, item: { ...item, data: sanitizedData } }); + this._log("updateItem", { + scenarioUuid, + item: { ...item, data: sanitizedData }, + }); const { uuid, type } = item; @@ -213,20 +226,23 @@ window.AdditionalInformationApi = { const response = await fetch( `/api/v1/scenario/${scenarioUuid}/information/item/${uuid}/`, { - method: 'PATCH', + method: "PATCH", headers: this._buildHeaders(), - body: JSON.stringify(sanitizedData) - } + body: JSON.stringify(sanitizedData), + }, ); if (response.status === 405) { // PATCH not supported, fall back to delete+recreate - this._log('updateItem', 'PATCH not supported, falling back to delete+recreate'); + this._log( + "updateItem", + "PATCH not supported, falling back to delete+recreate", + ); await this.deleteItem(scenarioUuid, uuid); return await this.createItem(scenarioUuid, type, sanitizedData); } - return this._handleResponse(response, 'updateItem'); + return this._handleResponse(response, "updateItem"); }, /** @@ -236,38 +252,50 @@ window.AdditionalInformationApi = { * @returns {Promise} Array of results with success status */ async updateItems(scenarioUuid, items) { - this._log('updateItems', { scenarioUuid, itemCount: items.length }); + this._log("updateItems", { scenarioUuid, itemCount: items.length }); const results = []; for (const item of items) { try { const result = await this.updateItem(scenarioUuid, item); - results.push({ success: true, oldUuid: item.uuid, newUuid: result.uuid }); + results.push({ + success: true, + oldUuid: item.uuid, + newUuid: result.uuid, + }); } catch (error) { results.push({ success: false, oldUuid: item.uuid, error: error.message, fieldErrors: error.fieldErrors, - isValidationError: error.isValidationError + isValidationError: error.isValidationError, }); } } - const failed = results.filter(r => !r.success); + const failed = results.filter((r) => !r.success); if (failed.length > 0) { - // If all failures are validation errors, throw a validation error - const validationErrors = failed.filter(f => f.isValidationError); - if (validationErrors.length === failed.length && failed.length === 1) { - // Single validation error - preserve field errors for display - const error = new Error(failed[0].error); - error.fieldErrors = failed[0].fieldErrors; + // If all failures are validation errors, return all validation errors for display + const validationErrors = failed.filter((f) => f.isValidationError); + if (validationErrors.length === failed.length) { + // All failures are validation errors - return all field errors by item UUID + const allFieldErrors = {}; + validationErrors.forEach((ve) => { + allFieldErrors[ve.oldUuid] = ve.fieldErrors || {}; + }); + const error = new Error( + `${failed.length} item(s) have validation errors. Please correct them.`, + ); + error.fieldErrors = allFieldErrors; // Map of UUID -> field errors error.isValidationError = true; - error.itemUuid = failed[0].oldUuid; + error.isMultipleErrors = true; // Flag indicating multiple items have errors throw error; } // Multiple failures or mixed errors - show count - throw new Error(`Failed to update ${failed.length} item(s). Please check the form for errors.`); + throw new Error( + `Failed to update ${failed.length} item(s). Please check the form for errors.`, + ); } return results; @@ -285,16 +313,13 @@ window.AdditionalInformationApi = { * @returns {Promise<{uuid, url, name, description, review_status, package}>} */ async createScenario(packageUuid, payload) { - this._log('createScenario', { packageUuid, payload }); - const response = await fetch( - `/api/v1/package/${packageUuid}/scenario/`, - { - method: 'POST', - headers: this._buildHeaders(), - body: JSON.stringify(payload) - } - ); - return this._handleResponse(response, 'createScenario'); + this._log("createScenario", { packageUuid, payload }); + const response = await fetch(`/api/v1/package/${packageUuid}/scenario/`, { + method: "POST", + headers: this._buildHeaders(), + body: JSON.stringify(payload), + }); + return this._handleResponse(response, "createScenario"); }, /** @@ -302,9 +327,9 @@ window.AdditionalInformationApi = { * @returns {Promise<{groups: string[]}>} */ async loadGroups() { - this._log('loadGroups', 'Starting...'); - const response = await fetch('/api/v1/information/groups/'); - return this._handleResponse(response, 'loadGroups'); + this._log("loadGroups", "Starting..."); + const response = await fetch("/api/v1/information/groups/"); + return this._handleResponse(response, "loadGroups"); }, /** @@ -313,7 +338,7 @@ window.AdditionalInformationApi = { * @returns {Promise} Object with subcategories as keys and arrays of model info */ async loadGroupModels(groupName) { - this._log('loadGroupModels', { groupName }); + this._log("loadGroupModels", { groupName }); const response = await fetch(`/api/v1/information/groups/${groupName}/`); return this._handleResponse(response, `loadGroupModels-${groupName}`); }, @@ -323,8 +348,8 @@ window.AdditionalInformationApi = { * @param {Array} groupNames - Defaults to ['soil', 'sludge', 'sediment'] * @returns {Promise} Object with group names as keys */ - async loadGroupsWithModels(groupNames = ['soil', 'sludge', 'sediment']) { - this._log('loadGroupsWithModels', { groupNames }); + async loadGroupsWithModels(groupNames = ["soil", "sludge", "sediment"]) { + this._log("loadGroupsWithModels", { groupNames }); const results = {}; const promises = groupNames.map(async (groupName) => { @@ -347,9 +372,9 @@ window.AdditionalInformationApi = { * @returns {Object} Object with group names as keys and filtered schemas as values */ organizeSchemasByGroup(schemas, groupModelsData) { - this._log('organizeSchemasByGroup', { + this._log("organizeSchemasByGroup", { schemaCount: Object.keys(schemas).length, - groupCount: Object.keys(groupModelsData).length + groupCount: Object.keys(groupModelsData).length, }); const organized = {}; @@ -377,18 +402,18 @@ window.AdditionalInformationApi = { * @param {Array} groupNames - Defaults to ['soil', 'sludge', 'sediment'] * @returns {Promise<{schemas, groupSchemas, groupModels}>} */ - async loadSchemasWithGroups(groupNames = ['soil', 'sludge', 'sediment']) { - this._log('loadSchemasWithGroups', { groupNames }); + async loadSchemasWithGroups(groupNames = ["soil", "sludge", "sediment"]) { + this._log("loadSchemasWithGroups", { groupNames }); // Load schemas and all groups in parallel const [schemas, groupModels] = await Promise.all([ this.loadSchemas(), - this.loadGroupsWithModels(groupNames) + this.loadGroupsWithModels(groupNames), ]); // Organize schemas by group const groupSchemas = this.organizeSchemasByGroup(schemas, groupModels); return { schemas, groupSchemas, groupModels }; - } + }, }; diff --git a/static/js/utils/timeseries-chart.js b/static/js/utils/timeseries-chart.js new file mode 100644 index 00000000..1d6c5d5e --- /dev/null +++ b/static/js/utils/timeseries-chart.js @@ -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; + }, +}; diff --git a/templates/components/schema_form.html b/templates/components/schema_form.html index 7b897320..ba17e551 100644 --- a/templates/components/schema_form.html +++ b/templates/components/schema_form.html @@ -34,7 +34,7 @@ x-if="getWidget(fieldName, schema.properties[fieldName]) === 'text'" >
{% include "components/widgets/text_widget.html" %}
@@ -45,7 +45,7 @@ x-if="getWidget(fieldName, schema.properties[fieldName]) === 'textarea'" >
{% include "components/widgets/textarea_widget.html" %}
@@ -56,7 +56,7 @@ x-if="getWidget(fieldName, schema.properties[fieldName]) === 'number'" >
{% include "components/widgets/number_widget.html" %}
@@ -67,7 +67,7 @@ x-if="getWidget(fieldName, schema.properties[fieldName]) === 'select'" >
{% include "components/widgets/select_widget.html" %}
@@ -78,7 +78,7 @@ x-if="getWidget(fieldName, schema.properties[fieldName]) === 'checkbox'" >
{% include "components/widgets/checkbox_widget.html" %}
@@ -89,7 +89,7 @@ x-if="getWidget(fieldName, schema.properties[fieldName]) === 'interval'" >
{% include "components/widgets/interval_widget.html" %}
@@ -100,7 +100,7 @@ x-if="getWidget(fieldName, schema.properties[fieldName]) === 'pubmed-link'" >
{% include "components/widgets/pubmed_link_widget.html" %}
@@ -111,11 +111,22 @@ x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'" >
{% include "components/widgets/compound_link_widget.html" %}
+ + + diff --git a/templates/components/widgets/checkbox_widget.html b/templates/components/widgets/checkbox_widget.html index be2059e6..368ddbc5 100644 --- a/templates/components/widgets/checkbox_widget.html +++ b/templates/components/widgets/checkbox_widget.html @@ -6,7 +6,7 @@ -