fix: checkbox de tarea (hecho) no se actualizaba tras guardar

useDragReorder resincronizaba su copia local solo cuando cambiaba el conjunto de ids, no cuando
se editaba un campo de una tarea existente (marcar hecho, renombrar) -- la fila se quedaba
mostrando el snapshot viejo para siempre despues de guardar. Ahora compara el contenido completo.

De paso: draggable/onDragStart pasan a vivir solo en el icono de agarre, nunca en toda la fila --
un ancestro draggable=true le gana el mousedown a los controles interactivos que tenga adentro en
algunos navegadores (no era la causa de este bug puntual, pero es un problema real del mismo
patron que conviene cerrar ya que se reutiliza en cualquier lista futura).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-16 12:33:35 -05:00
parent f8e42d4377
commit c84a07968f
7 changed files with 52 additions and 19 deletions
+15
View File
@@ -1,5 +1,20 @@
# Changelog - project.linkedcorp.net
## [1.15.2] - 2026-09-16
### Fixed
- El checkbox de "hecho" de una tarea (y el renombrado en linea) dejaba de reflejarse tras
guardar: `useDragReorder` (hook de arrastrar-para-reordenar, introducido en v1.14.0) resincronizaba
su copia local solo cuando cambiaba el CONJUNTO de ids, no cuando se editaba un campo de una tarea
ya existente (marcar hecho, renombrar) — la fila se quedaba mostrando el snapshot viejo para
siempre. Reportado por el usuario ("no se marca, debe ser por el drag de mover tareas") — tenia
razon en que era el drag, aunque la causa real no era la interferencia del arrastre con el click
sino esta resincronizacion incompleta. Corregido comparando el contenido completo, no solo los ids.
- De paso, separado `draggable`/`onDragStart` (antes en toda la fila) para que vivan SOLO en el
icono de agarre (`GripVertical`), nunca en la fila completa: un ancestro `draggable=true` le gana
el mousedown a los controles interactivos que tenga adentro en algunos navegadores. No era la
causa de este bug puntual, pero es una corrección real y necesaria para que el patrón sea seguro
de reutilizar en cualquier lista futura con controles interactivos en la fila.
## [1.15.1] - 2026-09-15
### Added
- `scripts/install.sh`: instalador para desplegar en un servidor nuevo. Verifica requisitos
+1 -1
View File
@@ -1 +1 @@
1.15.1
1.15.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.15.1",
"version": "1.15.2",
"private": true,
"type": "module",
"main": "src/server.js",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "1.15.1",
"version": "1.15.2",
"type": "module",
"scripts": {
"dev": "vite",
+3 -3
View File
@@ -31,7 +31,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
}, [filtered, sort, columns]);
const dragEnabled = !!onReorder && !sort.key && !searchValue;
const { order, getDragHandlers, isDragging } = useDragReorder(sorted, (ids) => onReorder?.(ids), getRowKey);
const { order, getHandleProps, getRowProps, isDragging } = useDragReorder(sorted, (ids) => onReorder?.(ids), getRowKey);
const displayRows = dragEnabled ? order : sorted;
function toggleSort(key) {
@@ -58,10 +58,10 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
key={getRowKey(row)}
onClick={() => onRowClick?.(row)}
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
{...(dragEnabled ? getDragHandlers(row) : {})}
{...(dragEnabled ? getRowProps(row) : {})}
>
{dragEnabled && (
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()}>
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()} {...getHandleProps(row)}>
<GripVertical size={15} />
</td>
)}
+25 -10
View File
@@ -5,15 +5,24 @@ import { useEffect, useRef, useState } from 'react';
// mientras se arrastra (estado local optimista); el llamador persiste el orden final.
// Reutilizado por ListView (actividades, y cualquier listado que pase onReorder) y por el listado
// de tareas de una actividad.
//
// `getHandleProps` (draggable + onDragStart/onDragEnd) va SOLO en el icono de arrastre, nunca en
// la fila completa: un ancestro `draggable=true` le gana el mousedown a los controles interactivos
// que tenga adentro (checkboxes, botones) y el navegador deja de dispararles click/change. Los que
// llaman a este hook siempre deben combinar `getHandleProps` en el handle con `getRowProps`
// (onDragOver/onDrop, para poder soltar en cualquier punto de la fila) en la fila.
export function useDragReorder(items, onReorder, getId = (x) => x.id) {
const [order, setOrder] = useState(items);
const orderRef = useRef(items);
const draggingId = useRef(null);
// Se sincroniza por el CONTENIDO (ids), no por la referencia del arreglo: un llamador que pase
// `algo || []` (comun) crea un arreglo nuevo en cada render aunque el contenido no cambie, y
// sincronizar por referencia ahi entra en loop infinito de renders.
const itemsKey = items.map(getId).join(',');
// Se sincroniza por el CONTENIDO completo (JSON), no por la referencia del arreglo ni solo por
// los ids: un llamador que pase `algo || []` (comun) crea un arreglo nuevo en cada render aunque
// el contenido no cambie (sincronizar por referencia ahi entra en loop infinito de renders), pero
// un key basado solo en ids tampoco alcanza — no cambia cuando se edita un campo de un item ya
// existente (ej. marcar una tarea como hecha, renombrarla), y el checkbox/nombre se queda
// mostrando el snapshot viejo para siempre tras guardar.
const itemsKey = JSON.stringify(items);
useEffect(() => {
if (!draggingId.current) { setOrder(items); orderRef.current = items; }
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -21,11 +30,21 @@ export function useDragReorder(items, onReorder, getId = (x) => x.id) {
useEffect(() => { orderRef.current = order; }, [order]);
function getDragHandlers(item) {
function getHandleProps(item) {
const itemId = getId(item);
return {
draggable: true,
onDragStart: (e) => { draggingId.current = itemId; e.dataTransfer.effectAllowed = 'move'; },
onDragEnd: () => {
draggingId.current = null;
onReorder(orderRef.current.map(getId));
},
};
}
function getRowProps(item) {
const itemId = getId(item);
return {
onDragOver: (e) => {
e.preventDefault();
if (draggingId.current === null || draggingId.current === itemId) return;
@@ -40,12 +59,8 @@ export function useDragReorder(items, onReorder, getId = (x) => x.id) {
});
},
onDrop: (e) => e.preventDefault(),
onDragEnd: () => {
draggingId.current = null;
onReorder(orderRef.current.map(getId));
},
};
}
return { order, getDragHandlers, isDragging: (itemId) => draggingId.current === itemId };
return { order, getHandleProps, getRowProps, isDragging: (itemId) => draggingId.current === itemId };
}
@@ -35,7 +35,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
const { data: users } = useAsync(listUsers, []);
const { data: tasks, reload: reloadTasks } = useAsync(() => listTasks(activity.id), [activity.id]);
const { order: orderedTasks, getDragHandlers, isDragging } = useDragReorder(tasks || [], async (ids) => {
const { order: orderedTasks, getHandleProps, getRowProps, isDragging } = useDragReorder(tasks || [], async (ids) => {
await reorderTasks(activity.id, ids);
await reloadTasks();
});
@@ -238,7 +238,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
key={task.id}
className={isDragging(task.id) ? 'drag-row dragging' : 'drag-row'}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}
{...(editingTaskId === task.id ? {} : getDragHandlers(task))}
{...(editingTaskId === task.id ? {} : getRowProps(task))}
>
{editingTaskId === task.id ? (
<>
@@ -252,7 +252,10 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
</>
) : (
<>
<GripVertical size={15} className="drag-handle" style={{ opacity: .6, cursor: 'grab', flexShrink: 0 }} />
<GripVertical
size={15} className="drag-handle" style={{ opacity: .6, cursor: 'grab', flexShrink: 0 }}
{...getHandleProps(task)}
/>
<input type="checkbox" checked={!!task.is_done} onChange={() => toggleTask(task)} />
<span style={{ flex: 1, fontSize: '.8rem', textDecoration: task.is_done ? 'line-through' : 'none', opacity: task.is_done ? .6 : 1 }}>{task.name}</span>
<button className="icon-btn" title={t('common_edit')} onClick={() => startEditTask(task)}><Pencil size={15} /></button>