From f28804bb5b7a84b0e89926be74534a8365467524 Mon Sep 17 00:00:00 2001
From: Guillermo Arrieta Medina
Date: Tue, 24 Feb 2026 14:28:52 -0600
Subject: [PATCH] =?UTF-8?q?closes=20#133:=20Mejoras=20de=20usabilidad=20en?=
=?UTF-8?q?=20ContenidoTem=C3=A1tico=20=E2=80=94=20edici=C3=B3n=20inmediat?=
=?UTF-8?q?a=20y=20foco?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Mueve el botón "+ Nueva unidad" al final de la lista y lo centra.
- Al crear una unidad: hace scrollIntoView, la unidad queda expandida, el título entra en modo edición y recibe focus.
- Al crear un subtema: nombre y horas quedan editables y el input del nombre recibe focus.
- Click en título de unidad o en un subtema inicia la edición y pone focus en el campo correspondiente.
- Elimina el botón "Listo": los cambios se guardan al pulsar Enter o perder el foco (onBlur).
- Presionar Esc cancela la edición y restaura el valor anterior.
- Evita el bug donde pulsar Enter tras crear una unidad añadía unidades extra (se desenfoca el botón y se dirige el foco al input correspondiente).
- Persistencia inmediata: las modificaciones se guardan vía useUpdateSubjectContenido en los puntos de commit.
- Conserva el estado de unidades expandidas tras las actualizaciones para evitar colapsos inesperados.
---
.../asignaturas/detalle/ContenidoTematico.tsx | 542 ++++++++++++------
1 file changed, 355 insertions(+), 187 deletions(-)
diff --git a/src/components/asignaturas/detalle/ContenidoTematico.tsx b/src/components/asignaturas/detalle/ContenidoTematico.tsx
index 9297b1e..8ef2d1c 100644
--- a/src/components/asignaturas/detalle/ContenidoTematico.tsx
+++ b/src/components/asignaturas/detalle/ContenidoTematico.tsx
@@ -8,9 +8,10 @@ import {
Trash2,
Clock,
} from 'lucide-react'
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
+import type { FocusEvent, KeyboardEvent } from 'react'
import {
AlertDialog,
@@ -167,16 +168,29 @@ export function ContenidoTematico() {
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
const [unidades, setUnidades] = useState>([])
const [expandedUnits, setExpandedUnits] = useState>(new Set())
+ const unitContainerRefs = useRef>(new Map())
+ const unitTitleInputRef = useRef(null)
+ const temaNombreInputElRef = useRef(null)
+ const [pendingScrollUnitId, setPendingScrollUnitId] = useState(
+ null,
+ )
+ const cancelNextBlurRef = useRef(false)
const [deleteDialog, setDeleteDialog] = useState<{
type: 'unidad' | 'tema'
id: string
parentId?: string
} | null>(null)
const [editingUnit, setEditingUnit] = useState(null)
+ const [unitDraftNombre, setUnitDraftNombre] = useState('')
+ const [unitOriginalNombre, setUnitOriginalNombre] = useState('')
const [editingTema, setEditingTema] = useState<{
unitId: string
temaId: string
} | null>(null)
+ const [temaDraftNombre, setTemaDraftNombre] = useState('')
+ const [temaOriginalNombre, setTemaOriginalNombre] = useState('')
+ const [temaDraftHoras, setTemaDraftHoras] = useState('')
+ const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
const persistUnidades = async (nextUnidades: Array) => {
const payload = serializeUnidadesToApi(nextUnidades)
@@ -186,18 +200,116 @@ export function ContenidoTematico() {
})
}
+ const beginEditUnit = (unitId: string) => {
+ const unit = unidades.find((u) => u.id === unitId)
+ const nombre = unit?.nombre ?? ''
+ setEditingUnit(unitId)
+ setUnitDraftNombre(nombre)
+ setUnitOriginalNombre(nombre)
+ setExpandedUnits((prev) => {
+ const next = new Set(prev)
+ next.add(unitId)
+ return next
+ })
+ }
+
+ const commitEditUnit = () => {
+ if (!editingUnit) return
+ const next = unidades.map((u) =>
+ u.id === editingUnit ? { ...u, nombre: unitDraftNombre } : u,
+ )
+ setUnidades(next)
+ setEditingUnit(null)
+ void persistUnidades(next)
+ }
+
+ const cancelEditUnit = () => {
+ setEditingUnit(null)
+ setUnitDraftNombre(unitOriginalNombre)
+ }
+
+ const beginEditTema = (unitId: string, temaId: string) => {
+ const unit = unidades.find((u) => u.id === unitId)
+ const tema = unit?.temas.find((t) => t.id === temaId)
+ const nombre = tema?.nombre ?? ''
+ const horas = tema?.horasEstimadas ?? 0
+
+ setEditingTema({ unitId, temaId })
+ setTemaDraftNombre(nombre)
+ setTemaOriginalNombre(nombre)
+ setTemaDraftHoras(String(horas))
+ setTemaOriginalHoras(horas)
+ setExpandedUnits((prev) => {
+ const next = new Set(prev)
+ next.add(unitId)
+ return next
+ })
+ }
+
+ const commitEditTema = () => {
+ if (!editingTema) return
+ const parsedHoras = Number.parseInt(temaDraftHoras, 10)
+ const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
+
+ const next = unidades.map((u) => {
+ if (u.id !== editingTema.unitId) return u
+ return {
+ ...u,
+ temas: u.temas.map((t) =>
+ t.id === editingTema.temaId
+ ? { ...t, nombre: temaDraftNombre, horasEstimadas }
+ : t,
+ ),
+ }
+ })
+
+ setUnidades(next)
+ setEditingTema(null)
+ void persistUnidades(next)
+ }
+
+ const cancelEditTema = () => {
+ setEditingTema(null)
+ setTemaDraftNombre(temaOriginalNombre)
+ setTemaDraftHoras(String(temaOriginalHoras))
+ }
+
+ const handleTemaEditorBlurCapture = (e: FocusEvent) => {
+ if (cancelNextBlurRef.current) {
+ cancelNextBlurRef.current = false
+ return
+ }
+ const nextFocus = e.relatedTarget as Node | null
+ if (nextFocus && e.currentTarget.contains(nextFocus)) return
+ commitEditTema()
+ }
+
+ const handleTemaEditorKeyDownCapture = (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ if (e.target instanceof HTMLElement) e.target.blur()
+ return
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ cancelNextBlurRef.current = true
+ cancelEditTema()
+ if (e.target instanceof HTMLElement) e.target.blur()
+ }
+ }
+
useEffect(() => {
const contenido = mapContenidoTematicoFromDb(
data ? data.contenido_tematico : undefined,
)
const transformed = contenido.map((u, idx) => ({
- id: `u-${idx}`,
+ id: `u-${u.unidad || idx + 1}`,
numero: u.unidad || idx + 1,
nombre: u.titulo || 'Sin título',
temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({
- id: `t-${idx}-${tidx}`,
+ id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
horasEstimadas: t?.horasEstimadas || 0,
}))
@@ -216,6 +328,25 @@ export function ContenidoTematico() {
})
}, [data])
+ useEffect(() => {
+ if (!editingUnit) return
+ // Foco controlado (evitamos autoFocus por lint/a11y)
+ setTimeout(() => unitTitleInputRef.current?.focus(), 0)
+ }, [editingUnit])
+
+ useEffect(() => {
+ if (!editingTema) return
+ setTimeout(() => temaNombreInputElRef.current?.focus(), 0)
+ }, [editingTema])
+
+ useEffect(() => {
+ if (!pendingScrollUnitId) return
+ const el = unitContainerRefs.current.get(pendingScrollUnitId)
+ if (!el) return
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ setPendingScrollUnitId(null)
+ }, [pendingScrollUnitId, unidades.length])
+
if (isLoading)
return Cargando contenido...
@@ -234,60 +365,57 @@ export function ContenidoTematico() {
}
const addUnidad = () => {
- const newId = `u-${Date.now()}`
+ const newNumero = unidades.length + 1
+ const newId = `u-${newNumero}`
const newUnidad: UnidadTematica = {
id: newId,
nombre: 'Nueva Unidad',
- numero: unidades.length + 1,
+ numero: newNumero,
temas: [],
}
const next = [...unidades, newUnidad]
setUnidades(next)
- setExpandedUnits(new Set([...expandedUnits, newId]))
- setEditingUnit(newId)
- }
+ setExpandedUnits((prev) => {
+ const n = new Set(prev)
+ n.add(newId)
+ return n
+ })
+ setPendingScrollUnitId(newId)
- const updateUnidadNombre = (id: string, nombre: string) => {
- setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u)))
+ // Abrir edición del título inmediatamente
+ setEditingUnit(newId)
+ setUnitDraftNombre(newUnidad.nombre)
+ setUnitOriginalNombre(newUnidad.nombre)
}
// --- Lógica de Temas ---
const addTema = (unidadId: string) => {
- setUnidades(
- unidades.map((u) => {
- if (u.id === unidadId) {
- const newTemaId = `t-${Date.now()}`
- const newTema: Tema = {
- id: newTemaId,
- nombre: 'Nuevo tema',
- horasEstimadas: 2,
- }
- setEditingTema({ unitId: unidadId, temaId: newTemaId })
- return { ...u, temas: [...u.temas, newTema] }
- }
- return u
- }),
- )
- }
+ const unit = unidades.find((u) => u.id === unidadId)
+ const unitNumero = unit?.numero ?? 0
+ const newTemaIndex = (unit?.temas.length ?? 0) + 1
+ const newTemaId = `t-${unitNumero}-${newTemaIndex}`
+ const newTema: Tema = {
+ id: newTemaId,
+ nombre: 'Nuevo tema',
+ horasEstimadas: 2,
+ }
- const updateTema = (
- unidadId: string,
- temaId: string,
- updates: Partial,
- ) => {
- setUnidades(
- unidades.map((u) => {
- if (u.id === unidadId) {
- return {
- ...u,
- temas: u.temas.map((t) =>
- t.id === temaId ? { ...t, ...updates } : t,
- ),
- }
- }
- return u
- }),
+ const next = unidades.map((u) =>
+ u.id === unidadId ? { ...u, temas: [...u.temas, newTema] } : u,
)
+ setUnidades(next)
+
+ // Expandir unidad y poner el subtema en edición con foco en el nombre
+ setExpandedUnits((prev) => {
+ const n = new Set(prev)
+ n.add(unidadId)
+ return n
+ })
+ setEditingTema({ unitId: unidadId, temaId: newTemaId })
+ setTemaDraftNombre(newTema.nombre)
+ setTemaOriginalNombre(newTema.nombre)
+ setTemaDraftHoras(String(newTema.horasEstimadas ?? 0))
+ setTemaOriginalHoras(newTema.horasEstimadas ?? 0)
}
const handleDelete = () => {
@@ -321,136 +449,161 @@ export function ContenidoTematico() {
{unidades.length} unidades • {totalHoras} horas estimadas totales
-
{unidades.map((unidad) => (
-
{
+ if (el) unitContainerRefs.current.set(unidad.id, el)
+ else unitContainerRefs.current.delete(unidad.id)
+ }}
>
- toggleUnit(unidad.id)}
- >
-
-
-
-
-
- {expandedUnits.has(unidad.id) ? (
-
- ) : (
-
- )}
-
-
-
- Unidad {unidad.numero}
-
+
+ toggleUnit(unidad.id)}
+ >
+
+
+
+
+
+ {expandedUnits.has(unidad.id) ? (
+
+ ) : (
+
+ )}
+
+
+
+ Unidad {unidad.numero}
+
- {editingUnit === unidad.id ? (
-
- updateUnidadNombre(unidad.id, e.target.value)
- }
- onBlur={() => {
- setEditingUnit(null)
- void persistUnidades(unidades)
- }}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- setEditingUnit(null)
- void persistUnidades(unidades)
- }
- }}
- className="h-8 max-w-md bg-white"
- />
- ) : (
-
setEditingUnit(unidad.id)}
- >
- {unidad.nombre}
-
- )}
-
-
-
- {' '}
- {unidad.temas.reduce(
- (sum, t) => sum + (t.horasEstimadas || 0),
- 0,
- )}
- h
-
-
- setDeleteDialog({ type: 'unidad', id: unidad.id })
- }
- >
-
-
-
-
-
-
-
-
- {unidad.temas.map((tema, idx) => (
-
- setEditingTema({ unitId: unidad.id, temaId: tema.id })
- }
- onStopEditing={() => {
- setEditingTema(null)
- void persistUnidades(unidades)
+ {editingUnit === unidad.id ? (
+ setUnitDraftNombre(e.target.value)}
+ onBlur={() => {
+ if (cancelNextBlurRef.current) {
+ cancelNextBlurRef.current = false
+ return
+ }
+ commitEditUnit()
}}
- onUpdate={(updates) =>
- updateTema(unidad.id, tema.id, updates)
- }
- onDelete={() =>
- setDeleteDialog({
- type: 'tema',
- id: tema.id,
- parentId: unidad.id,
- })
- }
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ e.currentTarget.blur()
+ return
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ cancelNextBlurRef.current = true
+ cancelEditUnit()
+ e.currentTarget.blur()
+ }
+ }}
+ className="h-8 max-w-md bg-white"
/>
- ))}
- addTema(unidad.id)}
- >
- Añadir subtema
-
+ ) : (
+ beginEditUnit(unidad.id)}
+ >
+ {unidad.nombre}
+
+ )}
+
+
+
+ {' '}
+ {unidad.temas.reduce(
+ (sum, t) => sum + (t.horasEstimadas || 0),
+ 0,
+ )}
+ h
+
+
+ setDeleteDialog({ type: 'unidad', id: unidad.id })
+ }
+ >
+
+
+
-
-
-
-
+
+
+
+
+ {unidad.temas.map((tema, idx) => (
+
beginEditTema(unidad.id, tema.id)}
+ onDraftNombreChange={setTemaDraftNombre}
+ onDraftHorasChange={setTemaDraftHoras}
+ onEditorBlurCapture={handleTemaEditorBlurCapture}
+ onEditorKeyDownCapture={
+ handleTemaEditorKeyDownCapture
+ }
+ onNombreInputRef={(el) => {
+ temaNombreInputElRef.current = el
+ }}
+ onDelete={() =>
+ setDeleteDialog({
+ type: 'tema',
+ id: tema.id,
+ parentId: unidad.id,
+ })
+ }
+ />
+ ))}
+ addTema(unidad.id)}
+ >
+ Añadir subtema
+
+
+
+
+
+
+
))}
+
+
{
+ // Evita que Enter vuelva a disparar el click sobre el botón.
+ e.currentTarget.blur()
+ addUnidad()
+ }}
+ >
+ Nueva unidad
+
+
+
void
- onStopEditing: () => void
- onUpdate: (updates: Partial) => void
+ draftNombre: string
+ draftHoras: string
+ onBeginEdit: () => void
+ onDraftNombreChange: (value: string) => void
+ onDraftHorasChange: (value: string) => void
+ onEditorBlurCapture: (e: FocusEvent) => void
+ onEditorKeyDownCapture: (e: KeyboardEvent) => void
+ onNombreInputRef: (el: HTMLInputElement | null) => void
onDelete: () => void
}
@@ -475,9 +633,14 @@ function TemaRow({
tema,
index,
isEditing,
- onEdit,
- onStopEditing,
- onUpdate,
+ draftNombre,
+ draftHoras,
+ onBeginEdit,
+ onDraftNombreChange,
+ onDraftHorasChange,
+ onEditorBlurCapture,
+ onEditorKeyDownCapture,
+ onNombreInputRef,
onDelete,
}: TemaRowProps) {
return (
@@ -489,47 +652,49 @@ function TemaRow({
>
{index}.
{isEditing ? (
-
+
onUpdate({ nombre: e.target.value })}
+ ref={onNombreInputRef}
+ value={draftNombre}
+ onChange={(e) => onDraftNombreChange(e.target.value)}
className="h-8 flex-1 bg-white"
placeholder="Nombre"
/>
- onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })
- }
+ value={draftHoras}
+ onChange={(e) => onDraftHorasChange(e.target.value)}
className="h-8 w-16 bg-white"
/>
-
- Listo
-
) : (
<>
{
+ e.stopPropagation()
+ onBeginEdit()
+ }}
>
{tema.nombre}
+
+ {tema.horasEstimadas}h
+
-
- {tema.horasEstimadas}h
-
{
+ e.stopPropagation()
+ onBeginEdit()
+ }}
>
@@ -537,7 +702,10 @@ function TemaRow({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-red-500"
- onClick={onDelete}
+ onClick={(e) => {
+ e.stopPropagation()
+ onDelete()
+ }}
>
--
2.49.1