Actualización del Dashboard para integrar la gestión de datasets y plots mediante nuevos componentes. Se implementaron tablas editables para la visualización y manipulación de datos, mejorando la experiencia del usuario. Además, se realizaron ajustes en los esquemas de configuración y se optimizó la presentación de formularios, asegurando una interfaz más intuitiva y funcional.

This commit is contained in:
Miguel 2025-08-12 20:50:53 +02:00
parent 745af5fa1f
commit 500b68c4d5
11 changed files with 2214 additions and 173 deletions

View File

@ -1,17 +1,80 @@
Solicitud (resumen): "En el Dashboard, quiero que la configuración del PLC esté arriba de los datasets (mencionado en @App.jsx)."
# Memoria de Evolución del Proyecto
Cambio aplicado:
- Se ajustó el orden de renderizado de secciones en `frontend/src/pages/Dashboard.jsx` para priorizar `plc` por encima de `dataset-definitions` y `dataset-variables`.
- Implementado con `useMemo` que ordena los IDs usando `preferredOrder = ['plc', 'dataset-definitions', 'dataset-variables']`.
## Sistema de Tablas Editables para Datasets y Plots (10/08/2025)
Motivación/decisión:
- Priorizar la configuración del PLC en la vista Dashboard para que quede por encima de los datasets.
- Comentarios y variables del código en inglés.
### Solicitud del Usuario
El usuario solicitó cambiar la presentación de los datasets de un formato de formulario a un formato de tabla para poder leer/escribir campos directamente. Quería:
- Tablas con botones de eliminar para cada fila
- Control reutilizable que tome schema + data + uiSchema y genere tabla con botones
- Sistema maestro-detalle: tabla de datasets -> selector -> tabla de variables del dataset
- Mismo patrón para plots y sus variables
- Todo integrado en Dashboard.jsx
Impacto:
- La sección de configuración del PLC se muestra primero en el Dashboard.
### Implementación Realizada
Archivos afectados:
- `frontend/src/pages/Dashboard.jsx`
**1. Componente EditableTable.jsx**
- Componente reutilizable que convierte schemas JSON en tablas editables
- Maneja tanto arrays como objetos con keys
- Modales para agregar/editar items
- Soporte para widgets: text, select, checkbox, number/updown
- Botones de eliminar por fila
Fecha: 2025-08-12
**2. DatasetTableManager.jsx**
- Gestiona datasets y variables en sistema maestro-detalle
- Tabla de dataset definitions con CRUD completo
- Selector de dataset que carga tabla de variables correspondiente
- Variables como objetos con keys (name, area, db, offset, type, streaming)
- Integración con APIs de configuración
**3. PlotTableManager.jsx**
- Similar a datasets pero para plots
- Plot definitions con propiedades como name, time_window, y_min/max, triggers
- Variables como array simple de strings
- Componente especializado PlotVariablesTable para arrays de strings
**4. Modificación Dashboard.jsx**
- Reemplazó secciones dataset-definitions/variables con DatasetTableManager
- Reemplazó secciones plot-definitions/variables con PlotTableManager
- Mantiene PLC config como formulario RJSF original
- Organización clara por tipo de gestión
### Decisiones Técnicas
- EditableTable maneja conversión automática entre arrays y objetos con keys
- Variables de datasets son objetos complejos, variables de plots son strings simples
- Guardado automático al hacer cambios con feedback visual
- Modales para evitar edición inline compleja
- Reutilización del sistema de schemas existente
### Corrección: Sistema de Formularios de Una Sola Fila (10/08/2025)
**Problema detectado**: Usuario reportó "No schema properties defined for this table" y solicitó formularios de una sola fila basados en schemas-ui.
**Causa**: Acceso incorrecto a propiedades del schema y enfoque muy complejo de tabla tradicional.
**Solución implementada**:
**1. FormTable.jsx - Componente corregido**
- Usa schemas RJSF directamente con additionalProperties
- Muestra cada objeto como un formulario completo en una tarjeta
- Modo readonly por defecto, modo edit al hacer clic en editar
- Integración completa con LayoutObjectFieldTemplate y widgets
**2. DatasetFormManager.jsx y PlotFormManager.jsx**
- Acceso correcto a schemas: `schema.properties.datasets`
- Logs de consola para debugging de carga de schemas
- Guardado que preserva estructura completa del config
- Sistema maestro-detalle funcional
**3. Presentación mejorada**
- Cada dataset/plot/variable se muestra como un formulario completo
- Botones de editar/eliminar por tarjeta
- Formularios que respetan completamente los UI schemas
- Generación automática de IDs para nuevos elementos
### Beneficios
- Formularios de una sola fila como solicitó el usuario
- Usa completamente los schemas-ui existentes
- CRUD completo desde interfaz gráfica
- Debugging mejorado con logs de consola
- Mantiene integridad completa con schemas JSON existentes
- Patrón reutilizable para futuras entidades

View File

@ -9371,8 +9371,22 @@
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T19:46:18.227132",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-12T20:49:00.212504",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
}
],
"last_updated": "2025-08-12T19:08:01.450637",
"total_entries": 878
"last_updated": "2025-08-12T20:49:00.212504",
"total_entries": 880
}

View File

@ -1,99 +1,99 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "dataset-definitions.schema.json",
"title": "Dataset Definitions",
"description": "Schema for dataset definitions (metadata only, no variables)",
"type": "object",
"additionalProperties": false,
"properties": {
"datasets": {
"type": "object",
"title": "Dataset Definitions",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Dataset Name",
"description": "Human-readable name of the dataset",
"minLength": 1,
"maxLength": 60
},
"prefix": {
"type": "string",
"title": "CSV Prefix",
"description": "Prefix for CSV files",
"pattern": "^[a-zA-Z0-9_-]+$",
"minLength": 1,
"maxLength": 20
},
"sampling_interval": {
"type": [
"number",
"null"
],
"title": "Sampling interval (s)",
"description": "Leave empty to use the global interval",
"minimum": 0.01,
"maximum": 10
},
"enabled": {
"type": "boolean",
"title": "Dataset Enabled",
"default": false,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
}
},
"created": {
"type": [
"string",
"null"
],
"title": "Created"
}
},
"required": [
"name",
"prefix"
]
}
},
"active_datasets": {
"type": "array",
"title": "Active Datasets",
"items": {
"type": "string"
},
"default": []
},
"current_dataset_id": {
"type": [
"string",
"null"
],
"title": "Current Dataset Id"
},
"version": {
"type": "string",
"title": "Version"
},
"last_update": {
"type": [
"string",
"null"
],
"title": "Last Update"
}
"$id": "dataset-definitions.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"description": "Schema for dataset definitions (metadata only, no variables)",
"properties": {
"active_datasets": {
"default": [],
"items": {
"type": "string"
},
"title": "Active Datasets",
"type": "array"
},
"required": [
"datasets"
]
"current_dataset_id": {
"title": "Current Dataset Id",
"type": [
"string",
"null"
]
},
"datasets": {
"additionalProperties": {
"properties": {
"created": {
"title": "Created",
"type": [
"string",
"null"
]
},
"enabled": {
"default": false,
"enum": [
true,
false
],
"options": {
"enum_titles": [
"Activate",
"Deactivate"
]
},
"title": "Dataset Enabled",
"type": "boolean"
},
"name": {
"description": "Human-readable name of the dataset",
"maxLength": 60,
"minLength": 1,
"title": "Dataset Name",
"type": "string"
},
"prefix": {
"description": "Prefix for CSV files",
"maxLength": 20,
"minLength": 1,
"pattern": "^[a-zA-Z0-9_-]+$",
"title": "CSV Prefix",
"type": "string"
},
"sampling_interval": {
"description": "Leave empty to use the global interval",
"maximum": 10,
"minimum": 0.01,
"title": "Sampling interval (s)",
"type": [
"number",
"null"
]
}
},
"required": [
"name",
"prefix"
],
"type": "object"
},
"title": "Dataset Definitions",
"type": "object"
},
"last_update": {
"title": "Last Update",
"type": [
"string",
"null"
]
},
"version": {
"title": "Version",
"type": "string"
}
},
"required": [
"datasets"
],
"title": "Dataset Definitions",
"type": "object"
}

View File

@ -1,68 +1,93 @@
{
"datasets": {
"ui:description": "📊 Configure dataset metadata: names, CSV file prefixes, sampling intervals, and activation status",
"ui:options": {
"addable": true,
"orderable": true,
"removable": true
},
"additionalProperties": {
"ui:order": [
"name",
"prefix",
"enabled",
"sampling_interval",
"created"
],
"name": {
"ui:placeholder": "e.g., Temperature Sensors, Production Line A",
"ui:help": "Human-readable name for this dataset"
},
"prefix": {
"ui:placeholder": "e.g., temp, line_a, sensors",
"ui:help": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)"
},
"enabled": {
"ui:widget": "checkbox",
"ui:help": "When enabled, this dataset will be actively sampled and recorded"
},
"sampling_interval": {
"ui:widget": "updown",
"ui:placeholder": "Leave empty to use global interval",
"ui:help": "Custom sampling interval in seconds (0.01-10s). Leave empty to use the global PLC sampling interval."
},
"created": {
"ui:widget": "text",
"ui:readonly": true,
"ui:help": "Timestamp when this dataset was created"
}
}
},
"active_datasets": {
"ui:widget": "checkboxes",
"ui:description": "✅ Select which datasets are currently active for sampling",
"ui:help": "Only active datasets will be sampled from the PLC and written to CSV files"
},
"current_dataset_id": {
"ui:widget": "select",
"ui:description": "🎯 Currently selected dataset for variable editing",
"ui:help": "This determines which dataset is shown by default in the interface"
},
"version": {
"ui:widget": "text",
"active_datasets": {
"ui:description": "✅ Select which datasets are currently active for sampling",
"ui:help": "Only active datasets will be sampled from the PLC and written to CSV files",
"ui:widget": "checkboxes",
"ui:column": 3
},
"current_dataset_id": {
"ui:description": "🎯 Currently selected dataset for variable editing",
"ui:help": "This determines which dataset is shown by default in the interface",
"ui:widget": "select",
"ui:column": 3
},
"datasets": {
"additionalProperties": {
"created": {
"ui:help": "Timestamp when this dataset was created",
"ui:readonly": true,
"ui:help": "Configuration schema version"
"ui:widget": "text"
},
"enabled": {
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
"ui:widget": "checkbox"
},
"name": {
"ui:help": "Human-readable name for this dataset",
"ui:placeholder": "e.g., Temperature Sensors, Production Line A"
},
"prefix": {
"ui:help": "Short prefix for CSV filenames (alphanumeric, underscore, dash only)",
"ui:placeholder": "e.g., temp, line_a, sensors"
},
"sampling_interval": {
"ui:help": "Custom sampling interval in seconds (0.01-10s). Leave empty to use the global PLC sampling interval.",
"ui:placeholder": "Leave empty to use global interval",
"ui:widget": "updown"
},
"ui:order": [
"name",
"prefix",
"enabled",
"sampling_interval",
"created"
]
},
"last_update": {
"ui:widget": "text",
"ui:readonly": true,
"ui:help": "Timestamp of last configuration update"
"ui:description": "📊 Configure dataset metadata: names, CSV file prefixes, sampling intervals, and activation status",
"ui:options": {
"addable": true,
"orderable": true,
"removable": true
},
"ui:order": [
"datasets",
"active_datasets",
"current_dataset_id",
"version",
"last_update"
"ui:column": 3
},
"last_update": {
"ui:help": "Timestamp of last configuration update",
"ui:readonly": true,
"ui:widget": "text",
"ui:column": 3
},
"ui:order": [
"datasets",
"active_datasets",
"current_dataset_id",
"version",
"last_update"
],
"version": {
"ui:help": "Configuration schema version",
"ui:readonly": true,
"ui:widget": "text",
"ui:column": 3
},
"ui:layout": [
[
{
"name": "active_datasets",
"width": 3
},
{
"name": "current_dataset_id",
"width": 3
},
{
"name": "last_update",
"width": 3
},
{
"name": "version",
"width": 3
}
]
]
}

View File

@ -0,0 +1,231 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider
} from '@chakra-ui/react'
import FormTable from './FormTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* DatasetFormManager - Gestiona datasets y variables usando FormTable
*/
export default function DatasetFormManager() {
const [datasets, setDatasets] = useState({})
const [datasetVariables, setDatasetVariables] = useState({})
const [selectedDatasetId, setSelectedDatasetId] = useState('')
const [datasetSchema, setDatasetSchema] = useState(null)
const [datasetUiSchema, setDatasetUiSchema] = useState({})
const [variableSchema, setVariableSchema] = useState(null)
const [variableUiSchema, setVariableUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
getSchema('dataset-definitions'),
getSchema('dataset-variables')
])
console.log('Dataset schema response:', datasetSchemaResp)
console.log('Variable schema response:', variableSchemaResp)
// Cargar datos
const [datasetDataResp, variableDataResp] = await Promise.all([
readConfig('dataset-definitions'),
readConfig('dataset-variables')
])
console.log('Dataset data response:', datasetDataResp)
console.log('Variable data response:', variableDataResp)
// Extraer schemas correctamente
setDatasetSchema(datasetSchemaResp.schema?.properties?.datasets)
setDatasetUiSchema(datasetSchemaResp.ui_schema?.datasets || {})
setVariableSchema(variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
setVariableUiSchema(variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables || {})
setDatasets(datasetDataResp.data?.datasets || {})
setDatasetVariables(variableDataResp.data?.dataset_variables || {})
// Seleccionar primer dataset
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
if (datasetIds.length > 0 && !selectedDatasetId) {
setSelectedDatasetId(datasetIds[0])
}
} catch (error) {
console.error('Error loading data:', error)
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const saveDatasets = async (newDatasets) => {
try {
const currentConfig = await readConfig('dataset-definitions')
const saveData = {
...currentConfig.data,
datasets: newDatasets,
last_update: new Date().toISOString()
}
await writeConfig('dataset-definitions', saveData)
setDatasets(newDatasets)
setMessage('Datasets saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving datasets:', error)
setMessage(`Error saving datasets: ${error.message}`)
}
}
const saveDatasetVariables = async (newVariables) => {
try {
const currentConfig = await readConfig('dataset-variables')
const updatedDatasetVariables = {
...datasetVariables,
[selectedDatasetId]: {
variables: newVariables,
streaming_variables: Object.keys(newVariables).filter(key => newVariables[key]?.streaming)
}
}
const saveData = {
...currentConfig.data,
dataset_variables: updatedDatasetVariables,
last_update: new Date().toISOString()
}
await writeConfig('dataset-variables', saveData)
setDatasetVariables(updatedDatasetVariables)
setMessage('Dataset variables saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving variables:', error)
setMessage(`Error saving variables: ${error.message}`)
}
}
const currentDatasetVariables = useMemo(() => {
return selectedDatasetId && datasetVariables[selectedDatasetId]
? datasetVariables[selectedDatasetId].variables || {}
: {}
}, [selectedDatasetId, datasetVariables])
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
value: id,
label: `${dataset.name || id} (${id})`
}))
if (loading) {
return <Text>Loading datasets...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Datasets */}
<Card>
<CardHeader>
<Heading size="sm">📊 Dataset Definitions</Heading>
</CardHeader>
<CardBody>
{datasetSchema ? (
<FormTable
schema={datasetSchema}
uiSchema={datasetUiSchema}
data={datasets}
onChange={saveDatasets}
title="Datasets"
keyField="id"
/>
) : (
<Alert status="warning">
<AlertIcon />
Dataset schema not available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Variables del Dataset */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Dataset Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Dataset:</Text>
<Select
size="sm"
value={selectedDatasetId}
onChange={(e) => setSelectedDatasetId(e.target.value)}
placeholder="Select dataset"
width="200px"
>
{datasetOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedDatasetId ? (
<Alert status="info">
<AlertIcon />
Select a dataset to manage its variables
</Alert>
) : variableSchema ? (
<FormTable
schema={variableSchema}
uiSchema={variableUiSchema}
data={currentDatasetVariables}
onChange={saveDatasetVariables}
title={`Variables for: ${datasets[selectedDatasetId]?.name || selectedDatasetId}`}
keyField="name"
/>
) : (
<Alert status="warning">
<AlertIcon />
Variable schema not available
</Alert>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,264 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Button,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider
} from '@chakra-ui/react'
import EditableTable from './EditableTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* DatasetTableManager - Componente para gestionar datasets y sus variables
* Muestra tabla de datasets y tabla de variables del dataset seleccionado
*/
export default function DatasetTableManager() {
const [datasets, setDatasets] = useState({})
const [datasetVariables, setDatasetVariables] = useState({})
const [selectedDatasetId, setSelectedDatasetId] = useState('')
const [datasetSchema, setDatasetSchema] = useState(null)
const [datasetUiSchema, setDatasetUiSchema] = useState({})
const [variableSchema, setVariableSchema] = useState(null)
const [variableUiSchema, setVariableUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
// Cargar schemas y datos al montar
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
getSchema('dataset-definitions'),
getSchema('dataset-variables')
])
// Cargar datos de configuración
const [datasetDataResp, variableDataResp] = await Promise.all([
readConfig('dataset-definitions'),
readConfig('dataset-variables')
])
setDatasetSchema(datasetSchemaResp.schema?.properties?.datasets)
setDatasetUiSchema(datasetSchemaResp.ui_schema?.datasets || {})
setVariableSchema(variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
setVariableUiSchema(variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables || {})
setDatasets(datasetDataResp.data?.datasets || {})
setDatasetVariables(datasetDataResp.data?.dataset_variables || {})
// Seleccionar el primer dataset si existe
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
if (datasetIds.length > 0 && !selectedDatasetId) {
setSelectedDatasetId(datasetIds[0])
}
} catch (error) {
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const saveDatasets = async (newDatasets) => {
setSaving(true)
setMessage('')
try {
// Construir el objeto completo para guardar
const saveData = {
datasets: newDatasets,
// Mantener otros campos existentes
active_datasets: [], // Esto se puede gestionar por separado
current_dataset_id: selectedDatasetId,
version: "1.0",
last_update: new Date().toISOString()
}
await writeConfig('dataset-definitions', saveData)
setDatasets(newDatasets)
setMessage('Datasets saved successfully')
} catch (error) {
setMessage(`Error saving datasets: ${error.message}`)
} finally {
setSaving(false)
}
}
const saveDatasetVariables = async (newVariables) => {
setSaving(true)
setMessage('')
try {
const updatedDatasetVariables = {
...datasetVariables,
[selectedDatasetId]: {
variables: newVariables,
streaming_variables: [] // Esto se puede calcular automáticamente
}
}
const saveData = {
dataset_variables: updatedDatasetVariables,
version: "1.0",
last_update: new Date().toISOString()
}
await writeConfig('dataset-variables', saveData)
setDatasetVariables(updatedDatasetVariables)
setMessage('Dataset variables saved successfully')
} catch (error) {
setMessage(`Error saving variables: ${error.message}`)
} finally {
setSaving(false)
}
}
// Convertir datos de datasets para el componente EditableTable
const datasetsForTable = useMemo(() => {
return Object.entries(datasets).map(([id, data]) => ({
id,
...data
}))
}, [datasets])
// Convertir variables del dataset seleccionado para el componente EditableTable
const variablesForTable = useMemo(() => {
if (!selectedDatasetId || !datasetVariables[selectedDatasetId]) {
return []
}
const variables = datasetVariables[selectedDatasetId].variables || {}
return Object.entries(variables).map(([name, data]) => ({
name,
...data
}))
}, [selectedDatasetId, datasetVariables])
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
value: id,
label: `${dataset.name} (${id})`
}))
if (loading) {
return <Text>Loading datasets...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Tabla de Datasets */}
<Card>
<CardHeader>
<Heading size="sm">📊 Datasets</Heading>
</CardHeader>
<CardBody>
{datasetSchema ? (
<EditableTable
schema={datasetSchema}
uiSchema={datasetUiSchema.additionalProperties || {}}
data={datasetsForTable}
onChange={(newData) => {
// Convertir de array a objeto con keys
const newDatasets = {}
newData.forEach(item => {
const { id, ...rest } = item
newDatasets[id] = rest
})
saveDatasets(newDatasets)
}}
title="Dataset Definitions"
keyField="id"
/>
) : (
<Alert status="warning">
<AlertIcon />
No dataset schema available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Selector de Dataset y Tabla de Variables */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Dataset Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Dataset:</Text>
<Select
size="sm"
value={selectedDatasetId}
onChange={(e) => setSelectedDatasetId(e.target.value)}
placeholder="Select dataset"
width="200px"
>
{datasetOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedDatasetId ? (
<Alert status="info">
<AlertIcon />
Select a dataset to manage its variables
</Alert>
) : variableSchema ? (
<EditableTable
schema={variableSchema}
uiSchema={variableUiSchema.additionalProperties || {}}
data={variablesForTable}
onChange={(newData) => {
// Convertir de array a objeto con keys
const newVariables = {}
newData.forEach(item => {
const { name, ...rest } = item
newVariables[name] = rest
})
saveDatasetVariables(newVariables)
}}
title={`Variables for dataset: ${datasets[selectedDatasetId]?.name || selectedDatasetId}`}
keyField="name"
/>
) : (
<Alert status="warning">
<AlertIcon />
No variable schema available
</Alert>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,355 @@
import React, { useState, useMemo } from 'react'
import {
Box,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
Input,
Select,
Checkbox,
IconButton,
HStack,
VStack,
Text,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure,
Alert,
AlertIcon,
NumberInput,
NumberInputField,
NumberInputStepper,
NumberIncrementStepper,
NumberDecrementStepper,
useColorModeValue
} from '@chakra-ui/react'
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
/**
* EditableTable - Componente reutilizable para editar arrays de objetos en forma de tabla
*
* @param {Object} schema - JSON Schema que define la estructura de los objetos
* @param {Object} uiSchema - UI Schema con widgets y configuraciones
* @param {Array} data - Array de objetos a editar
* @param {Function} onChange - Callback que se ejecuta al cambiar los datos: (newData) => void
* @param {string} title - Título de la tabla
* @param {string} keyField - Campo que actúa como clave única (ej: 'id', 'name')
*/
export default function EditableTable({
schema,
uiSchema = {},
data = [],
onChange,
title = "Data",
keyField = "id",
allowAdd = true,
allowEdit = true,
allowDelete = true
}) {
const [editingKey, setEditingKey] = useState(null)
const [editingData, setEditingData] = useState({})
const [newItem, setNewItem] = useState({})
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure()
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
// Extraer propiedades del schema
const properties = useMemo(() => {
if (!schema?.properties) return {}
return schema.properties
}, [schema])
const propertyNames = useMemo(() => {
return Object.keys(properties)
}, [properties])
// Convertir array de objetos a formato objeto con keys
const dataAsObject = useMemo(() => {
if (Array.isArray(data)) {
const result = {}
data.forEach((item, index) => {
const key = item[keyField] || `item_${index}`
result[key] = item
})
return result
}
return data || {}
}, [data, keyField])
const dataKeys = Object.keys(dataAsObject)
const handleDelete = (key) => {
const newDataObj = { ...dataAsObject }
delete newDataObj[key]
// Convertir de vuelta a array si es necesario
const newData = Array.isArray(data)
? Object.values(newDataObj)
: newDataObj
onChange(newData)
}
const handleAdd = () => {
if (!newItem[keyField]) {
alert(`Please provide a ${keyField}`)
return
}
const newDataObj = { ...dataAsObject }
newDataObj[newItem[keyField]] = { ...newItem }
// Convertir de vuelta a array si es necesario
const newData = Array.isArray(data)
? Object.values(newDataObj)
: newDataObj
onChange(newData)
setNewItem({})
onAddClose()
}
const handleEdit = () => {
if (!editingKey) return
const newDataObj = { ...dataAsObject }
newDataObj[editingKey] = { ...editingData }
// Convertir de vuelta a array si es necesario
const newData = Array.isArray(data)
? Object.values(newDataObj)
: newDataObj
onChange(newData)
setEditingKey(null)
setEditingData({})
onEditClose()
}
const openEdit = (key) => {
setEditingKey(key)
setEditingData({ ...dataAsObject[key] })
onEditOpen()
}
const renderInput = (propertyName, value, setValue, itemData = {}) => {
const property = properties[propertyName]
const uiConfig = uiSchema[propertyName] || {}
const widget = uiConfig['ui:widget'] || 'text'
const commonProps = {
size: 'sm',
value: value || '',
onChange: (e) => setValue({ ...itemData, [propertyName]: e.target.value })
}
if (property?.enum && widget === 'select') {
return (
<Select {...commonProps} placeholder="Select...">
{property.enum.map(option => (
<option key={option} value={option}>{option}</option>
))}
</Select>
)
}
if (property?.type === 'boolean' || widget === 'checkbox') {
return (
<Checkbox
isChecked={!!value}
onChange={(e) => setValue({ ...itemData, [propertyName]: e.target.checked })}
/>
)
}
if (property?.type === 'number' || property?.type === 'integer' || widget === 'updown') {
return (
<NumberInput
size="sm"
value={value || ''}
onChange={(valueString) => setValue({ ...itemData, [propertyName]: parseFloat(valueString) || null })}
min={property?.minimum}
max={property?.maximum}
step={property?.type === 'integer' ? 1 : 0.01}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
)
}
return <Input {...commonProps} placeholder={uiConfig['ui:placeholder'] || ''} />
}
const renderValue = (propertyName, value) => {
const property = properties[propertyName]
if (property?.type === 'boolean') {
return <Checkbox isChecked={!!value} isReadOnly />
}
if (value === null || value === undefined) {
return <Text color={muted}>-</Text>
}
return <Text>{String(value)}</Text>
}
const getColumnTitle = (propertyName) => {
const property = properties[propertyName]
return property?.title || propertyName
}
if (propertyNames.length === 0) {
return (
<Alert status="warning">
<AlertIcon />
No schema properties defined for this table
</Alert>
)
}
return (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{title}</Text>
{allowAdd && (
<Button size="sm" leftIcon={<AddIcon />} onClick={onAddOpen}>
Add Item
</Button>
)}
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
<Tr>
{propertyNames.map(prop => (
<Th key={prop}>{getColumnTitle(prop)}</Th>
))}
{(allowEdit || allowDelete) && <Th width="100px">Actions</Th>}
</Tr>
</Thead>
<Tbody>
{dataKeys.length === 0 ? (
<Tr>
<Td colSpan={propertyNames.length + 1}>
<Text color={muted} textAlign="center">No items</Text>
</Td>
</Tr>
) : (
dataKeys.map(key => (
<Tr key={key}>
{propertyNames.map(prop => (
<Td key={prop}>
{renderValue(prop, dataAsObject[key][prop])}
</Td>
))}
{(allowEdit || allowDelete) && (
<Td>
<HStack spacing={1}>
{allowEdit && (
<IconButton
icon={<EditIcon />}
size="xs"
variant="outline"
onClick={() => openEdit(key)}
/>
)}
{allowDelete && (
<IconButton
icon={<DeleteIcon />}
size="xs"
variant="outline"
colorScheme="red"
onClick={() => handleDelete(key)}
/>
)}
</HStack>
</Td>
)}
</Tr>
))
)}
</Tbody>
</Table>
</Box>
{/* Add Modal */}
<Modal isOpen={isAddOpen} onClose={onAddClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Add New Item</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
{propertyNames.map(prop => (
<Box key={prop} width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
{getColumnTitle(prop)}
{schema.required?.includes(prop) && <Text as="span" color="red.500">*</Text>}
</Text>
{renderInput(prop, newItem[prop], setNewItem, newItem)}
{uiSchema[prop]?.['ui:help'] && (
<Text fontSize="xs" color={muted} mt={1}>
{uiSchema[prop]['ui:help']}
</Text>
)}
</Box>
))}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onAddClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Edit Modal */}
<Modal isOpen={isEditOpen} onClose={onEditClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Edit Item: {editingKey}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
{propertyNames.map(prop => (
<Box key={prop} width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
{getColumnTitle(prop)}
{schema.required?.includes(prop) && <Text as="span" color="red.500">*</Text>}
</Text>
{renderInput(prop, editingData[prop], setEditingData, editingData)}
{uiSchema[prop]?.['ui:help'] && (
<Text fontSize="xs" color={muted} mt={1}>
{uiSchema[prop]['ui:help']}
</Text>
)}
</Box>
))}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
)
}

View File

@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react'
import {
Box,
VStack,
HStack,
Text,
Button,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider,
IconButton,
Flex,
Badge
} from '@chakra-ui/react'
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
import Form from '@rjsf/chakra-ui'
import validator from '@rjsf/validator-ajv8'
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate.jsx'
import { widgets } from './rjsf/widgets.jsx'
/**
* FormTable - Muestra objetos como filas de formularios usando schemas RJSF
*/
export default function FormTable({
schema,
uiSchema = {},
data = {},
onChange,
title = "Data",
keyField = "id",
allowAdd = true,
allowDelete = true
}) {
const [editingKey, setEditingKey] = useState(null)
const [addingNew, setAddingNew] = useState(false)
const [newKey, setNewKey] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
if (!schema || !schema.additionalProperties) {
return (
<Alert status="warning">
<AlertIcon />
Schema not available for {title}
</Alert>
)
}
const itemSchema = schema.additionalProperties
const itemUiSchema = uiSchema.additionalProperties || {}
const dataKeys = Object.keys(data)
const handleAdd = (formData) => {
if (!newKey) {
alert('Please provide a key/ID')
return
}
const newData = {
...data,
[newKey]: formData
}
onChange(newData)
setAddingNew(false)
setNewKey('')
}
const handleEdit = (key, formData) => {
const newData = {
...data,
[key]: formData
}
onChange(newData)
setEditingKey(null)
}
const handleDelete = (key) => {
if (confirm(`¿Eliminar "${key}"?`)) {
const newData = { ...data }
delete newData[key]
onChange(newData)
}
}
const generateNewKey = () => {
const baseName = keyField === 'id' ? 'item' : 'new'
let counter = 1
let newKey = `${baseName}_${counter}`
while (data[newKey]) {
counter++
newKey = `${baseName}_${counter}`
}
return newKey
}
const startAdd = () => {
setNewKey(generateNewKey())
setAddingNew(true)
}
return (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{title}</Text>
{allowAdd && (
<Button size="sm" leftIcon={<AddIcon />} onClick={startAdd}>
Add Item
</Button>
)}
</HStack>
{dataKeys.length === 0 && !addingNew && (
<Box p={4} borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Text color={muted} textAlign="center">No items found</Text>
</Box>
)}
{/* Formulario para agregar nuevo item */}
{addingNew && (
<Card borderColor="blue.200" borderWidth="2px">
<CardHeader pb={2}>
<HStack justify="space-between">
<Heading size="xs" color="blue.600">
Adding: {newKey}
</Heading>
<Button size="xs" variant="ghost" onClick={() => setAddingNew(false)}>
Cancel
</Button>
</HStack>
</CardHeader>
<CardBody pt={0}>
<Form
schema={itemSchema}
uiSchema={itemUiSchema}
formData={{}}
validator={validator}
onSubmit={({ formData }) => handleAdd(formData)}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
widgets={widgets}
>
<HStack mt={3}>
<Button type="submit" size="sm" colorScheme="blue">
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => setAddingNew(false)}>
Cancel
</Button>
</HStack>
</Form>
</CardBody>
</Card>
)}
{/* Formularios para items existentes */}
{dataKeys.map(key => (
<Card key={key} borderWidth="1px" borderColor={borderColor}>
<CardHeader pb={2}>
<HStack justify="space-between">
<HStack>
<Heading size="xs">{key}</Heading>
{editingKey === key && (
<Badge colorScheme="orange" size="sm">Editing</Badge>
)}
</HStack>
<HStack spacing={1}>
{editingKey === key ? (
<Button
size="xs"
variant="ghost"
onClick={() => setEditingKey(null)}
>
Cancel
</Button>
) : (
<>
<IconButton
icon={<EditIcon />}
size="xs"
variant="outline"
onClick={() => setEditingKey(key)}
/>
{allowDelete && (
<IconButton
icon={<DeleteIcon />}
size="xs"
variant="outline"
colorScheme="red"
onClick={() => handleDelete(key)}
/>
)}
</>
)}
</HStack>
</HStack>
</CardHeader>
<CardBody pt={0}>
<Form
schema={itemSchema}
uiSchema={itemUiSchema}
formData={data[key] || {}}
validator={validator}
onChange={editingKey === key ? undefined : () => { }}
onSubmit={editingKey === key ? ({ formData }) => handleEdit(key, formData) : undefined}
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
widgets={widgets}
readonly={editingKey !== key}
>
{editingKey === key && (
<HStack mt={3}>
<Button type="submit" size="sm" colorScheme="blue">
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingKey(null)}>
Cancel
</Button>
</HStack>
)}
</Form>
</CardBody>
</Card>
))}
</VStack>
)
}

View File

@ -0,0 +1,399 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider,
Button,
Input,
IconButton,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react'
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
import FormTable from './FormTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* PlotVariablesManager - Componente para gestionar array de strings (variables de plot)
*/
function PlotVariablesManager({ variables = [], onChange, title = "Variables" }) {
const [newVariable, setNewVariable] = useState('')
const [editingIndex, setEditingIndex] = useState(null)
const [editingValue, setEditingValue] = useState('')
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure()
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
const handleAdd = () => {
if (!newVariable.trim()) return
const newVariables = [...variables, newVariable.trim()]
onChange(newVariables)
setNewVariable('')
onAddClose()
}
const handleEdit = () => {
if (editingIndex === null || !editingValue.trim()) return
const newVariables = [...variables]
newVariables[editingIndex] = editingValue.trim()
onChange(newVariables)
setEditingIndex(null)
setEditingValue('')
onEditClose()
}
const handleDelete = (index) => {
if (confirm('¿Eliminar esta variable?')) {
const newVariables = variables.filter((_, i) => i !== index)
onChange(newVariables)
}
}
const openEdit = (index) => {
setEditingIndex(index)
setEditingValue(variables[index])
onEditOpen()
}
return (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{title}</Text>
<Button size="sm" leftIcon={<AddIcon />} onClick={onAddOpen}>
Add Variable
</Button>
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
<Tr>
<Th>Variable Name</Th>
<Th width="100px">Actions</Th>
</Tr>
</Thead>
<Tbody>
{variables.length === 0 ? (
<Tr>
<Td colSpan={2}>
<Text color={muted} textAlign="center">No variables</Text>
</Td>
</Tr>
) : (
variables.map((variable, index) => (
<Tr key={index}>
<Td>{variable}</Td>
<Td>
<HStack spacing={1}>
<IconButton
icon={<EditIcon />}
size="xs"
variant="outline"
onClick={() => openEdit(index)}
/>
<IconButton
icon={<DeleteIcon />}
size="xs"
variant="outline"
colorScheme="red"
onClick={() => handleDelete(index)}
/>
</HStack>
</Td>
</Tr>
))
)}
</Tbody>
</Table>
</Box>
{/* Add Modal */}
<Modal isOpen={isAddOpen} onClose={onAddClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Add Variable</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
<Box width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
Variable Name *
</Text>
<Input
size="sm"
value={newVariable}
onChange={(e) => setNewVariable(e.target.value)}
placeholder="e.g., UR29_Brix"
/>
<Text fontSize="xs" color={muted} mt={1}>
Enter the name of the variable to plot
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onAddClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Edit Modal */}
<Modal isOpen={isEditOpen} onClose={onEditClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Edit Variable</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
<Box width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
Variable Name *
</Text>
<Input
size="sm"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
placeholder="e.g., UR29_Brix"
/>
<Text fontSize="xs" color={muted} mt={1}>
Enter the name of the variable to plot
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
)
}
/**
* PlotFormManager - Gestiona plots y variables usando FormTable
*/
export default function PlotFormManager() {
const [plots, setPlots] = useState({})
const [plotVariables, setPlotVariables] = useState({})
const [selectedPlotId, setSelectedPlotId] = useState('')
const [plotSchema, setPlotSchema] = useState(null)
const [plotUiSchema, setPlotUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas
const [plotSchemaResp, plotVariableSchemaResp] = await Promise.all([
getSchema('plot-definitions'),
getSchema('plot-variables')
])
console.log('Plot schema response:', plotSchemaResp)
console.log('Plot variable schema response:', plotVariableSchemaResp)
// Cargar datos
const [plotDataResp, plotVariableDataResp] = await Promise.all([
readConfig('plot-definitions'),
readConfig('plot-variables')
])
console.log('Plot data response:', plotDataResp)
console.log('Plot variable data response:', plotVariableDataResp)
// Extraer schemas
setPlotSchema(plotSchemaResp.schema?.properties?.plots)
setPlotUiSchema(plotSchemaResp.ui_schema?.plots || {})
setPlots(plotDataResp.data?.plots || {})
setPlotVariables(plotVariableDataResp.data?.plot_variables || {})
// Seleccionar primer plot
const plotIds = Object.keys(plotDataResp.data?.plots || {})
if (plotIds.length > 0 && !selectedPlotId) {
setSelectedPlotId(plotIds[0])
}
} catch (error) {
console.error('Error loading plot data:', error)
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const savePlots = async (newPlots) => {
try {
const currentConfig = await readConfig('plot-definitions')
const saveData = {
...currentConfig.data,
plots: newPlots,
last_saved: new Date().toISOString()
}
await writeConfig('plot-definitions', saveData)
setPlots(newPlots)
setMessage('Plots saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving plots:', error)
setMessage(`Error saving plots: ${error.message}`)
}
}
const savePlotVariables = async (newVariables) => {
try {
const currentConfig = await readConfig('plot-variables')
const updatedPlotVariables = {
...plotVariables,
[selectedPlotId]: {
variables: newVariables
}
}
const saveData = {
...currentConfig.data,
plot_variables: updatedPlotVariables,
last_update: new Date().toISOString()
}
await writeConfig('plot-variables', saveData)
setPlotVariables(updatedPlotVariables)
setMessage('Plot variables saved successfully')
setTimeout(() => setMessage(''), 3000)
} catch (error) {
console.error('Error saving plot variables:', error)
setMessage(`Error saving variables: ${error.message}`)
}
}
const currentPlotVariables = useMemo(() => {
return selectedPlotId && plotVariables[selectedPlotId]
? plotVariables[selectedPlotId].variables || []
: []
}, [selectedPlotId, plotVariables])
const plotOptions = Object.entries(plots).map(([id, plot]) => ({
value: id,
label: `${plot.name || id} (${id})`
}))
if (loading) {
return <Text>Loading plots...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Plots */}
<Card>
<CardHeader>
<Heading size="sm">📈 Plot Definitions</Heading>
</CardHeader>
<CardBody>
{plotSchema ? (
<FormTable
schema={plotSchema}
uiSchema={plotUiSchema}
data={plots}
onChange={savePlots}
title="Plots"
keyField="session_id"
/>
) : (
<Alert status="warning">
<AlertIcon />
Plot schema not available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Variables del Plot */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Plot Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Plot:</Text>
<Select
size="sm"
value={selectedPlotId}
onChange={(e) => setSelectedPlotId(e.target.value)}
placeholder="Select plot"
width="200px"
>
{plotOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedPlotId ? (
<Alert status="info">
<AlertIcon />
Select a plot to manage its variables
</Alert>
) : (
<PlotVariablesManager
variables={currentPlotVariables}
onChange={savePlotVariables}
title={`Variables for: ${plots[selectedPlotId]?.name || selectedPlotId}`}
/>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -0,0 +1,414 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
Box,
VStack,
HStack,
Text,
Select,
Button,
Card,
CardBody,
CardHeader,
Heading,
Alert,
AlertIcon,
useColorModeValue,
Divider,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
IconButton,
Input,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react'
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
import EditableTable from './EditableTable.jsx'
import { getSchema, readConfig, writeConfig } from '../services/api.js'
/**
* PlotVariablesTable - Componente especializado para editar variables de plots (array de strings)
*/
function PlotVariablesTable({ variables = [], onChange, title = "Variables" }) {
const [newVariable, setNewVariable] = useState('')
const { isOpen, onOpen, onClose } = useDisclosure()
const [editingIndex, setEditingIndex] = useState(null)
const [editingValue, setEditingValue] = useState('')
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
const muted = useColorModeValue('gray.600', 'gray.300')
const borderColor = useColorModeValue('gray.200', 'gray.600')
const handleAdd = () => {
if (!newVariable.trim()) return
const newVariables = [...variables, newVariable.trim()]
onChange(newVariables)
setNewVariable('')
onClose()
}
const handleEdit = () => {
if (editingIndex === null || !editingValue.trim()) return
const newVariables = [...variables]
newVariables[editingIndex] = editingValue.trim()
onChange(newVariables)
setEditingIndex(null)
setEditingValue('')
onEditClose()
}
const handleDelete = (index) => {
const newVariables = variables.filter((_, i) => i !== index)
onChange(newVariables)
}
const openEdit = (index) => {
setEditingIndex(index)
setEditingValue(variables[index])
onEditOpen()
}
return (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="semibold">{title}</Text>
<Button size="sm" leftIcon={<AddIcon />} onClick={onOpen}>
Add Variable
</Button>
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
<Tr>
<Th>Variable Name</Th>
<Th width="100px">Actions</Th>
</Tr>
</Thead>
<Tbody>
{variables.length === 0 ? (
<Tr>
<Td colSpan={2}>
<Text color={muted} textAlign="center">No variables</Text>
</Td>
</Tr>
) : (
variables.map((variable, index) => (
<Tr key={index}>
<Td>{variable}</Td>
<Td>
<HStack spacing={1}>
<IconButton
icon={<EditIcon />}
size="xs"
variant="outline"
onClick={() => openEdit(index)}
/>
<IconButton
icon={<DeleteIcon />}
size="xs"
variant="outline"
colorScheme="red"
onClick={() => handleDelete(index)}
/>
</HStack>
</Td>
</Tr>
))
)}
</Tbody>
</Table>
</Box>
{/* Add Modal */}
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Add Variable</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
<Box width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
Variable Name *
</Text>
<Input
size="sm"
value={newVariable}
onChange={(e) => setNewVariable(e.target.value)}
placeholder="e.g., UR29_Brix"
/>
<Text fontSize="xs" color={muted} mt={1}>
Enter the name of the variable to plot
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Edit Modal */}
<Modal isOpen={isEditOpen} onClose={onEditClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Edit Variable</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={3}>
<Box width="100%">
<Text fontSize="sm" fontWeight="medium" mb={1}>
Variable Name *
</Text>
<Input
size="sm"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
placeholder="e.g., UR29_Brix"
/>
<Text fontSize="xs" color={muted} mt={1}>
Enter the name of the variable to plot
</Text>
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
)
}
/**
* PlotTableManager - Componente para gestionar plots y sus variables
* Muestra tabla de plots y tabla de variables del plot seleccionado
*/
export default function PlotTableManager() {
const [plots, setPlots] = useState({})
const [plotVariables, setPlotVariables] = useState({})
const [selectedPlotId, setSelectedPlotId] = useState('')
const [plotSchema, setPlotSchema] = useState(null)
const [plotUiSchema, setPlotUiSchema] = useState({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const muted = useColorModeValue('gray.600', 'gray.300')
// Cargar schemas y datos al montar
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
// Cargar schemas
const [plotSchemaResp, plotVariableSchemaResp] = await Promise.all([
getSchema('plot-definitions'),
getSchema('plot-variables')
])
// Cargar datos de configuración
const [plotDataResp, plotVariableDataResp] = await Promise.all([
readConfig('plot-definitions'),
readConfig('plot-variables')
])
setPlotSchema(plotSchemaResp.schema?.properties?.plots)
setPlotUiSchema(plotSchemaResp.ui_schema?.plots || {})
setPlots(plotDataResp.data?.plots || {})
setPlotVariables(plotVariableDataResp.data?.plot_variables || {})
// Seleccionar el primer plot si existe
const plotIds = Object.keys(plotDataResp.data?.plots || {})
if (plotIds.length > 0 && !selectedPlotId) {
setSelectedPlotId(plotIds[0])
}
} catch (error) {
setMessage(`Error loading data: ${error.message}`)
} finally {
setLoading(false)
}
}
const savePlots = async (newPlots) => {
setSaving(true)
setMessage('')
try {
// Construir el objeto completo para guardar
const saveData = {
plots: newPlots,
session_counter: 0, // Esto se puede gestionar por separado
last_saved: new Date().toISOString(),
version: "1.0"
}
await writeConfig('plot-definitions', saveData)
setPlots(newPlots)
setMessage('Plots saved successfully')
} catch (error) {
setMessage(`Error saving plots: ${error.message}`)
} finally {
setSaving(false)
}
}
const savePlotVariables = async (newVariables) => {
setSaving(true)
setMessage('')
try {
const updatedPlotVariables = {
...plotVariables,
[selectedPlotId]: {
variables: newVariables
}
}
const saveData = {
plot_variables: updatedPlotVariables,
version: "1.0",
last_update: new Date().toISOString()
}
await writeConfig('plot-variables', saveData)
setPlotVariables(updatedPlotVariables)
setMessage('Plot variables saved successfully')
} catch (error) {
setMessage(`Error saving variables: ${error.message}`)
} finally {
setSaving(false)
}
}
// Convertir datos de plots para el componente EditableTable
const plotsForTable = useMemo(() => {
return Object.entries(plots).map(([id, data]) => ({
id,
...data
}))
}, [plots])
// Variables del plot seleccionado
const variablesForTable = useMemo(() => {
if (!selectedPlotId || !plotVariables[selectedPlotId]) {
return []
}
return plotVariables[selectedPlotId].variables || []
}, [selectedPlotId, plotVariables])
const plotOptions = Object.entries(plots).map(([id, plot]) => ({
value: id,
label: `${plot.name} (${id})`
}))
if (loading) {
return <Text>Loading plots...</Text>
}
return (
<VStack align="stretch" spacing={4}>
{message && (
<Alert status={message.includes('Error') ? 'error' : 'success'}>
<AlertIcon />
{message}
</Alert>
)}
{/* Tabla de Plots */}
<Card>
<CardHeader>
<Heading size="sm">📈 Plots</Heading>
</CardHeader>
<CardBody>
{plotSchema ? (
<EditableTable
schema={plotSchema}
uiSchema={plotUiSchema.additionalProperties || {}}
data={plotsForTable}
onChange={(newData) => {
// Convertir de array a objeto con keys
const newPlots = {}
newData.forEach(item => {
const { id, ...rest } = item
newPlots[id] = rest
})
savePlots(newPlots)
}}
title="Plot Definitions"
keyField="id"
/>
) : (
<Alert status="warning">
<AlertIcon />
No plot schema available
</Alert>
)}
</CardBody>
</Card>
<Divider />
{/* Selector de Plot y Tabla de Variables */}
<Card>
<CardHeader>
<HStack justify="space-between">
<Heading size="sm">🔧 Plot Variables</Heading>
<HStack>
<Text fontSize="sm" color={muted}>Plot:</Text>
<Select
size="sm"
value={selectedPlotId}
onChange={(e) => setSelectedPlotId(e.target.value)}
placeholder="Select plot"
width="200px"
>
{plotOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
</HStack>
</HStack>
</CardHeader>
<CardBody>
{!selectedPlotId ? (
<Alert status="info">
<AlertIcon />
Select a plot to manage its variables
</Alert>
) : (
<PlotVariablesTable
variables={variablesForTable}
onChange={savePlotVariables}
title={`Variables for plot: ${plots[selectedPlotId]?.name || selectedPlotId}`}
/>
)}
</CardBody>
</Card>
</VStack>
)
}

View File

@ -5,6 +5,8 @@ import Form from '@rjsf/chakra-ui'
import validator from '@rjsf/validator-ajv8'
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate.jsx'
import { widgets } from '../components/rjsf/widgets.jsx'
import DatasetFormManager from '../components/DatasetFormManager.jsx'
import PlotFormManager from '../components/PlotFormManager.jsx'
import {
getStatus,
getEvents,
@ -225,7 +227,47 @@ export default function DashboardPage() {
{statusError && <Alert status="error" mb={3}><AlertIcon />{statusError}</Alert>}
{status && <StatusBar status={status} />}
{available.map((sectionId) => (
{/* Sección PLC Config */}
{available.includes('plc') && (
<Card mb={4}>
<CardBody>
<Flex wrap="wrap" gap={2} align="center" mb={3}>
<Text fontWeight="semibold" textTransform="uppercase">🧩 PLC Configuration</Text>
<Box ml="auto">
<SectionControls sectionId="plc" />
</Box>
</Flex>
<SectionForm sectionId="plc" />
</CardBody>
</Card>
)}
{/* Sección Datasets con Tablas */}
{(available.includes('dataset-definitions') || available.includes('dataset-variables')) && (
<Card mb={4}>
<CardBody>
<Flex wrap="wrap" gap={2} align="center" mb={3}>
<Text fontWeight="semibold" textTransform="uppercase">📊 Dataset Management</Text>
</Flex>
<DatasetFormManager />
</CardBody>
</Card>
)}
{/* Sección Plots con Tablas */}
{(available.includes('plot-definitions') || available.includes('plot-variables')) && (
<Card mb={4}>
<CardBody>
<Flex wrap="wrap" gap={2} align="center" mb={3}>
<Text fontWeight="semibold" textTransform="uppercase">📈 Plot Management</Text>
</Flex>
<PlotFormManager />
</CardBody>
</Card>
)}
{/* Otras secciones que no son datasets ni plots */}
{available.filter(id => !['dataset-definitions', 'dataset-variables', 'plot-definitions', 'plot-variables', 'plc'].includes(id)).map((sectionId) => (
<Card key={sectionId} mb={4}>
<CardBody>
<Flex wrap="wrap" gap={2} align="center" mb={3}>