Mejora 3: Implementado el auto-guardado con debounce en el formulario de configuración.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { Box, TextField, Button, Paper, CircularProgress, Typography } from '@mui/material';
|
||||
import { Box, TextField, Paper, CircularProgress, Chip } from '@mui/material';
|
||||
import type { Configuracion } from '../types';
|
||||
import * as api from '../services/apiService';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
|
||||
interface Props {
|
||||
config: Configuracion | null;
|
||||
@@ -9,61 +10,79 @@ interface Props {
|
||||
}
|
||||
|
||||
const FormularioConfiguracion = ({ config, setConfig }: Props) => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const debouncedConfig = useDebounce(config, 750);
|
||||
|
||||
const isInitialLoad = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Solo procedemos si tenemos un objeto de configuración "debounced"
|
||||
if (debouncedConfig) {
|
||||
// Si la 'ref' es true, significa que esta es la primera vez que recibimos
|
||||
// un objeto de configuración válido. Lo ignoramos y marcamos la carga inicial como completada.
|
||||
if (isInitialLoad.current) {
|
||||
isInitialLoad.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Si la 'ref' ya es false, significa que cualquier cambio posterior
|
||||
// es una modificación real del usuario, por lo que procedemos a guardar.
|
||||
const saveConfig = async () => {
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
await api.guardarConfiguracion(debouncedConfig);
|
||||
setSaveStatus('saved');
|
||||
setTimeout(() => {
|
||||
setSaveStatus('idle');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Error en el auto-guardado:", err);
|
||||
setSaveStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
saveConfig();
|
||||
}
|
||||
}, [debouncedConfig]); // La dependencia sigue siendo la misma
|
||||
|
||||
|
||||
if (!config) {
|
||||
return <CircularProgress />;
|
||||
}
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSaveStatus('idle');
|
||||
const { name, value } = event.target;
|
||||
const numericFields = ['intervaloMinutos', 'cantidadTitularesAScrapear'];
|
||||
|
||||
setConfig(prevConfig => prevConfig ? {
|
||||
...prevConfig,
|
||||
[name]: numericFields.includes(name) ? Number(value) : value
|
||||
} : null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!config) return;
|
||||
setSaving(true);
|
||||
setSuccess(false);
|
||||
try {
|
||||
await api.guardarConfiguracion(config);
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Error al guardar configuración", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
const getSaveStatusIndicator = () => {
|
||||
if (saveStatus === 'saving') {
|
||||
return <Chip size="small" label="Guardando..." icon={<CircularProgress size={16} />} />;
|
||||
}
|
||||
if (saveStatus === 'saved') {
|
||||
return <Chip size="small" label="Guardado" color="success" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ padding: 2 }}>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<TextField fullWidth name="rutaCsv" label="Ruta del archivo CSV" value={config.rutaCsv} onChange={handleChange} variant="outlined" sx={{ mb: 2 }} disabled={saving} />
|
||||
<TextField fullWidth name="intervaloMinutos" label="Intervalo de Actualización (minutos)" value={config.intervaloMinutos} onChange={handleChange} type="number" variant="outlined" sx={{ mb: 2 }} disabled={saving} />
|
||||
<TextField fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo" value={config.cantidadTitularesAScrapear} onChange={handleChange} type="number" variant="outlined" sx={{ mb: 2 }} disabled={saving} />
|
||||
<Box component="form">
|
||||
<TextField fullWidth name="rutaCsv" label="Ruta del archivo CSV" value={config.rutaCsv} onChange={handleChange} sx={{ mb: 2 }} />
|
||||
<TextField fullWidth name="intervaloMinutos" label="Intervalo de Actualización (minutos)" value={config.intervaloMinutos} onChange={handleChange} type="number" sx={{ mb: 2 }} />
|
||||
<TextField fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo" value={config.cantidadTitularesAScrapear} onChange={handleChange} type="number" sx={{ mb: 2 }} />
|
||||
<TextField
|
||||
fullWidth
|
||||
name="viñetaPorDefecto"
|
||||
label="Viñeta por Defecto"
|
||||
value={config.viñetaPorDefecto}
|
||||
onChange={handleChange}
|
||||
variant="outlined"
|
||||
sx={{ mb: 2 }}
|
||||
disabled={saving}
|
||||
fullWidth name="viñetaPorDefecto" label="Viñeta por Defecto" value={config.viñetaPorDefecto}
|
||||
onChange={handleChange} sx={{ mb: 2 }}
|
||||
helperText="El símbolo a usar si un titular no tiene una viñeta específica."
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
{success && <Typography color="success.main" sx={{ mr: 2 }}>¡Guardado!</Typography>}
|
||||
<Button type="submit" variant="contained" disabled={saving}>
|
||||
{saving ? <CircularProgress size={24} /> : 'Guardar Cambios'}
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: '36.5px' }}>
|
||||
{getSaveStatusIndicator()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
27
frontend/src/hooks/useDebounce.ts
Normal file
27
frontend/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// frontend/src/hooks/useDebounce.ts
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Este hook toma un valor y un retardo (delay) en milisegundos.
|
||||
// Devuelve una nueva versión del valor que solo se actualiza
|
||||
// después de que el valor original no haya cambiado durante el 'delay' especificado.
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Configura un temporizador para actualizar el valor "debounced"
|
||||
// después de que pase el tiempo de 'delay'.
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Función de limpieza: Si el 'value' cambia (porque el usuario sigue escribiendo),
|
||||
// este return se ejecuta primero, limpiando el temporizador anterior.
|
||||
// Esto previene que el valor se actualice mientras el usuario sigue interactuando.
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]); // El efecto se vuelve a ejecutar solo si el valor o el retardo cambian.
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
Reference in New Issue
Block a user