/** * Alpine.js Widget Components for Schema Forms * * Centralized widget component definitions for dynamic form rendering. * Each widget receives explicit parameters instead of context object for better traceability. */ document.addEventListener("alpine:init", () => { // Base widget factory with common functionality const baseWidget = ( fieldName, data, schema, uiSchema, 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() { return this.schema?.properties?.[this.fieldName] || {}; }, // Common metadata get label() { // Check uiSchema first (RJSF format) 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()); }, get helpText() { return this.fieldSchema.description || ""; }, // Field-level unit extraction from uiSchema (RJSF format) get unit() { return this.uiSchema?.[this.fieldName]?.["ui:unit"] || null; }, // Mode checks get isViewMode() { return this.mode === "view"; }, get isEditMode() { return this.mode === "edit"; }, }); // Text widget 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; }, }), ); // Textarea widget 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; }, }), ); // Number widget with unit support 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); }, }), ); // Select widget 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 || []; }, }), ); // Checkbox widget 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; }, }), ); // Interval widget with unit support 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}` : ""; 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; }, // 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, 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; }, }), ); // Compound link widget 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; }, }), ); // 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(); } }); } }, }), ); });