omnidiag / frontend /src /utils /schemaFieldParser.js
yahyoha's picture
fix(ui): schema-driven form controls overhaul
b351ed7
Raw
History Blame Contribute Delete
6.3 kB
/**
* schemaFieldParser.js
* =====================
* Parses a JSON Schema (from GET /api/v4/{disease}/schema) into an array of
* FieldMetadata objects that drive dynamic form rendering.
*
* Maps JSON Schema types to React component types:
* - integer + min=0 + max=1 + demographic β†’ 'segmented' (radio group, e.g. Sex)
* - integer + min=0 + max=1 β†’ 'toggle' (binary yes/no switch)
* - string + enum [...] β†’ 'select' (dropdown)
* - integer + range ≀ 12 β†’ 'slider' (ordinal / small-range)
* - number (float) β†’ 'number' (constrained float input)
* - integer + range > 12 β†’ 'number' (constrained int input)
* - string (no enum) β†’ 'text' (fallback text input)
*/
/**
* @typedef {Object} FieldMetadata
* @property {string} name - The field key (e.g., "Age", "HighBP")
* @property {string} title - Human-readable label derived from description
* @property {string} type - JSON Schema type: "string" | "integer" | "number"
* @property {string} component - React component type: "toggle" | "select" | "number" | "slider" | "text"
* @property {{ required: boolean, minimum?: number, maximum?: number, enum?: (string|number)[], step?: number }} validation
* @property {string} description - Full field description
* @property {*} default - Default value from schema example
* @property {string} [category] - Inferred grouping category (set later by featureCategorizer)
*/
/**
* Extract a human-readable title from a field name + description.
* Falls back to the field name with snake_case β†’ Title Case conversion.
*/
function deriveTitle(fieldName, description) {
if (description) {
// Use the part before any colon or parentheses as the short title
const clean = description.split(':')[0].split('(')[0].trim();
if (clean && clean.length < 50) return clean;
}
// Snake_case / CamelCase β†’ Title Case
return fieldName
.replace(/([A-Z])/g, ' $1')
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
.trim();
}
/**
* Determine the React component type from schema field properties.
*/
/**
* Fields that represent distinct demographic/clinical states (not on/off).
* These get a segmented radio-group control instead of a toggle switch.
*/
const DEMOGRAPHIC_BINARY_FIELDS = new Set([
'sex',
]);
function resolveComponentType(schemaField) {
const { type, minimum, maximum, enum: enumValues } = schemaField;
const name = (schemaField.name || '').toLowerCase();
// Rule 1: integer with min=0, max=1, demographic β†’ Segmented (radio group)
if (type === 'integer' && minimum === 0 && maximum === 1 && DEMOGRAPHIC_BINARY_FIELDS.has(name)) {
return 'segmented';
}
// Rule 2: integer with min=0, max=1 β†’ Toggle (binary yes/no)
if (type === 'integer' && minimum === 0 && maximum === 1) {
return 'toggle';
}
// Rule 2: string with enum β†’ Dropdown
if (type === 'string' && Array.isArray(enumValues) && enumValues.length > 0) {
return 'select';
}
// Rule 3: integer with small range (≀ 12 distinct values) β†’ Slider
if (type === 'integer' && minimum !== undefined && maximum !== undefined) {
const range = maximum - minimum;
if (range <= 12) return 'slider';
return 'number';
}
// Rule 4: number (float) β†’ Number input
if (type === 'number') {
return 'number';
}
// Rule 5: integer with wide range β†’ Number input
if (type === 'integer') {
return 'number';
}
// Rule 6: string with no enum β†’ Text input (fallback)
if (type === 'string') {
return 'text';
}
// Fallback
return 'text';
}
/**
* Extract default value for a field from the schema's example data.
*/
function extractDefault(fieldName, schema) {
// Check in $defs first, then root-level example
const example =
schema?.$defs?.[Object.keys(schema?.$defs || {})[0]]?.example ||
schema?.example ||
schema?.json_schema_extra?.example ||
{};
if (fieldName in example) return example[fieldName];
return undefined;
}
/**
* Parse the full JSON Schema into an array of FieldMetadata.
*
* @param {object} schema - The JSON Schema object from api.getSchema(disease)
* @returns {FieldMetadata[]} Ordered list of field definitions
*/
export function parseSchema(schema) {
if (!schema || !schema.properties) {
console.warn('[schemaFieldParser] Invalid schema β€” missing properties', schema);
return [];
}
const requiredFields = new Set(schema.required || []);
return Object.entries(schema.properties).map(([name, prop]) => {
const fieldType = prop.type || 'string';
const validation = {
required: requiredFields.has(name),
minimum: prop.minimum,
maximum: prop.maximum,
enum: prop.enum || undefined,
step: prop.multipleOf || (fieldType === 'number' ? 0.1 : 1),
};
return {
name,
title: deriveTitle(name, prop.description || prop.title),
type: fieldType,
component: resolveComponentType({ ...prop, type: fieldType, name }),
validation,
description: prop.description || '',
default: extractDefault(name, schema),
category: null, // assigned later by featureCategorizer
};
});
}
/**
* Parse default values from schema examples into a flat form values object.
*
* @param {FieldMetadata[]} fields
* @param {object} schema - Original JSON Schema
* @returns {object} Default form values keyed by field name
*/
export function extractDefaultValues(fields, schema) {
const example =
schema?.$defs?.[Object.keys(schema?.$defs || {})[0]]?.example ||
schema?.example ||
{};
const defaults = {};
for (const field of fields) {
if (field.name in example) {
defaults[field.name] = example[field.name];
} else {
// Fallback: use minimum, or 0 for numbers, '' for strings
if (field.type === 'number' || field.type === 'integer') {
defaults[field.name] = field.validation.minimum ?? 0;
} else if (field.type === 'string') {
defaults[field.name] = field.validation.enum?.[0] ?? '';
} else {
defaults[field.name] = '';
}
}
}
return defaults;
}