forked from enviPath/enviPy
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>
463 lines
11 KiB
JavaScript
463 lines
11 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
});
|
||
}
|
||
},
|
||
}),
|
||
);
|
||
});
|