[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

@ -34,7 +34,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'text'"
>
<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" %}
</div>
@ -45,7 +45,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'textarea'"
>
<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" %}
</div>
@ -56,7 +56,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'number'"
>
<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" %}
</div>
@ -67,7 +67,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'select'"
>
<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" %}
</div>
@ -78,7 +78,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'checkbox'"
>
<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" %}
</div>
@ -89,7 +89,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'interval'"
>
<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" %}
</div>
@ -100,7 +100,7 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'pubmed-link'"
>
<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" %}
</div>
@ -111,11 +111,22 @@
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'"
>
<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" %}
</div>
</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>
</template>

View File

@ -6,7 +6,7 @@
<span
class="label-text"
:class="{
'text-error': hasError,
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
@ -38,9 +38,12 @@
</template>
<!-- Errors -->
<template x-if="hasError">
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<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>
</template>
</div>

View File

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

View File

@ -6,7 +6,7 @@
<span
class="label-text"
:class="{
'text-error': hasError,
'text-error': hasValidationError || $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
@ -41,11 +41,11 @@
<!-- Edit mode: two inputs with shared unit badge -->
<template x-if="isEditMode">
<div class="flex gap-2 items-center">
<div class="flex items-center gap-2">
<input
type="number"
class="input input-bordered flex-1"
:class="{ 'input-error': hasError }"
:class="{ 'input-error': hasValidationError || $store.validationErrors.hasError(fieldName, context) }"
placeholder="Min"
x-model="start"
/>
@ -53,7 +53,7 @@
<input
type="number"
class="input input-bordered flex-1"
:class="{ 'input-error': hasError }"
:class="{ 'input-error': hasValidationError || $store.validationErrors.hasError(fieldName, context) }"
placeholder="Max"
x-model="end"
/>
@ -64,9 +64,22 @@
</template>
<!-- Errors -->
<template x-if="hasError">
<template
x-if="hasValidationError || $store.validationErrors.hasError(fieldName, context)"
>
<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>
</template>
</div>

View File

@ -6,7 +6,7 @@
<span
class="label-text"
:class="{
'text-error': hasError,
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
@ -41,7 +41,7 @@
<input
type="number"
: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"
/>
<template x-if="unit">
@ -54,9 +54,12 @@
</template>
<!-- Errors -->
<template x-if="hasError">
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<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>
</template>
</div>

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@
<span
class="label-text"
:class="{
'text-error': hasError,
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
@ -41,15 +41,18 @@
<template x-if="isEditMode">
<textarea
class="textarea textarea-bordered w-full"
:class="{ 'textarea-error': hasError }"
:class="{ 'textarea-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value"
></textarea>
</template>
<!-- Errors -->
<template x-if="hasError">
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<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>
</template>
</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"
/>
{# 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 #}
<script
defer
@ -31,6 +35,7 @@
<script src="{% static 'js/alpine/pagination.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/widgets.js' %}"></script>
{# Font Awesome #}
<link

View File

@ -18,7 +18,9 @@
const names = Object.keys(this.schemas);
// Remove duplicates, exclude existing types, and sort alphabetically by display title
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) => {
const titleA = (this.schemas[a]?.schema?.['x-title'] || a).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
this.formData = null;
this.formRenderKey++;
// Clear previous errors
this.error = null;
Alpine.store('validationErrors').clearErrors(); // No context - clears all
});
// Load schemas and existing items
@ -63,6 +68,7 @@
this.selectedType = '';
this.error = null;
this.formData = null;
Alpine.store('validationErrors').clearErrors(); // No context - clears all
},
setFormData(data) {
@ -96,11 +102,12 @@
window.location.reload();
} catch (err) {
if (err.isValidationError && err.fieldErrors) {
window.dispatchEvent(new CustomEvent('set-field-errors', {
detail: err.fieldErrors
}));
// No context for add modal - simple flat errors
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 {
this.isSubmitting = false;
}
@ -155,7 +162,7 @@
<template x-for="name in sortedSchemaNames" :key="name">
<option
: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>
</template>
</select>
@ -169,6 +176,7 @@
x-data="schemaRenderer({
rjsf: schemas[selectedType],
mode: 'edit'
// No context - single form, backward compatible
})"
x-init="await init(); $dispatch('form-data-ready', data)"
>

View File

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