forked from enviPath/enviPy
[Feature] Dynamic additional information rendering in frontend (#282)
This implements a version of #274, relying on Pydantics built in JSON schema and JSON rendering. Requires additional UI tagging in the ai model repo but will remove HTML tags. Example scenario with filled information: 5882df9c-dae1-4d80-a40e-db4724271456/scenario/3a4d395a-6a6d-4154-8ce3-ced667fceec0 Reviewed-on: enviPath/enviPy#282 Co-authored-by: Tobias O <tobias.olenyi@envipath.com> Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
186
static/js/alpine/components/widgets.js
Normal file
186
static/js/alpine/components/widgets.js
Normal file
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 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, fieldErrors, mode) => ({
|
||||
fieldName,
|
||||
data,
|
||||
schema,
|
||||
uiSchema,
|
||||
fieldErrors,
|
||||
mode,
|
||||
|
||||
// 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;
|
||||
},
|
||||
|
||||
// Error handling
|
||||
get hasError() {
|
||||
return !!this.fieldErrors?.[this.fieldName];
|
||||
},
|
||||
get errors() {
|
||||
return this.fieldErrors?.[this.fieldName] || [];
|
||||
},
|
||||
|
||||
// Mode checks
|
||||
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),
|
||||
|
||||
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),
|
||||
|
||||
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),
|
||||
|
||||
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),
|
||||
|
||||
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),
|
||||
|
||||
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),
|
||||
|
||||
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
|
||||
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];
|
||||
},
|
||||
}));
|
||||
|
||||
// PubMed link widget
|
||||
Alpine.data('pubmedWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
|
||||
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
|
||||
|
||||
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),
|
||||
|
||||
get value() { return this.data[this.fieldName] || ''; },
|
||||
set value(v) { this.data[this.fieldName] = v; },
|
||||
}));
|
||||
});
|
||||
Reference in New Issue
Block a user