tududi/frontend/components/Task/TaskForm/TaskSubtasksSection.tsx
Chris 1a1b70b5e7
Fix subtask completion not persisting to backend (#920) (#936)
The toggle handler required onSubtaskUpdate callback to make the API
call, but TaskSubtasksCard never provided it. Extract a shared handler
that calls toggleTaskCompletion for persisted subtasks regardless of
whether onSubtaskUpdate is provided, falling back to updating local
state via onSubtasksChange.
2026-03-10 17:30:45 +02:00

295 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef } from 'react';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { useTranslation } from 'react-i18next';
import { Task } from '../../../entities/Task';
import TaskPriorityIcon from '../../Shared/Icons/TaskPriorityIcon';
import { toggleTaskCompletion } from '../../../utils/tasksService';
interface TaskSubtasksSectionProps {
parentTaskId: number;
subtasks: Task[];
onSubtasksChange: (subtasks: Task[]) => void;
onSubtaskUpdate?: (subtask: Task) => Promise<void>;
onSave?: (subtasks: Task[]) => void;
}
const TaskSubtasksSection: React.FC<TaskSubtasksSectionProps> = ({
parentTaskId,
subtasks,
onSubtasksChange,
onSubtaskUpdate,
onSave,
}) => {
const [newSubtaskName, setNewSubtaskName] = useState('');
const [isLoading] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
const { t } = useTranslation();
const subtasksSectionRef = useRef<HTMLDivElement>(null);
const addInputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = () => {
setTimeout(() => {
const modalScrollContainer = document.querySelector(
'.absolute.inset-0.overflow-y-auto'
);
if (modalScrollContainer) {
modalScrollContainer.scrollTo({
top: modalScrollContainer.scrollHeight,
behavior: 'smooth',
});
}
}, 100);
};
const handleCreateSubtask = () => {
if (!newSubtaskName.trim()) return;
const newSubtask: Task = {
name: newSubtaskName.trim(),
status: 'not_started',
priority: 'low',
today: false,
parent_task_id: parentTaskId,
isNew: true,
_isNew: true,
completed_at: null,
} as Task;
const updatedSubtasks = [...subtasks, newSubtask];
onSubtasksChange(updatedSubtasks);
setNewSubtaskName('');
scrollToBottom();
onSave?.(updatedSubtasks);
};
const handleDeleteSubtask = (index: number) => {
const updatedSubtasks = subtasks.filter((_, i) => i !== index);
onSubtasksChange(updatedSubtasks);
onSave?.(updatedSubtasks);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateSubtask();
}
};
const handleEditSubtask = (index: number) => {
setEditingIndex(index);
setEditingName(subtasks[index].name);
};
const handleSaveEdit = () => {
if (!editingName.trim() || editingIndex === null) return;
const updatedSubtasks = subtasks.map((subtask, index) => {
if (index === editingIndex) {
const isNameChanged = subtask.name !== editingName.trim();
const isNew =
(subtask as any)._isNew || (subtask as any).isNew || false;
const isEdited = !isNew && isNameChanged;
return {
...subtask,
name: editingName.trim(),
isNew: isNew,
isEdited: isEdited,
_isNew: isNew,
_isEdited: isEdited,
};
}
return subtask;
});
onSubtasksChange(updatedSubtasks);
setEditingIndex(null);
setEditingName('');
onSave?.(updatedSubtasks);
};
const handleCancelEdit = () => {
setEditingIndex(null);
setEditingName('');
};
const handleToggleNewSubtaskCompletion = (index: number) => {
const updatedSubtasks = subtasks.map((subtask, i) => {
if (i === index) {
const isDone =
subtask.status === 'done' || subtask.status === 2;
const newStatus = isDone
? ('not_started' as const)
: ('done' as const);
const hasId =
subtask.id &&
!((subtask as any)._isNew || (subtask as any).isNew);
return {
...subtask,
status: newStatus,
completed_at: isDone ? null : new Date().toISOString(),
_statusChanged: hasId,
};
}
return subtask;
});
onSubtasksChange(updatedSubtasks);
};
const handleToggleSubtaskCompletion = async (subtask: Task, index: number) => {
const isPersisted = subtask.id && subtask.uid &&
!((subtask as any)._isNew || (subtask as any).isNew);
if (isPersisted) {
try {
const updatedSubtask = await toggleTaskCompletion(subtask.uid!);
if (onSubtaskUpdate) {
await onSubtaskUpdate(updatedSubtask);
} else {
const updatedSubtasks = subtasks.map((s, i) =>
i === index ? updatedSubtask : s
);
onSubtasksChange(updatedSubtasks);
}
} catch (error) {
console.error('Error toggling subtask completion:', error);
}
} else {
handleToggleNewSubtaskCompletion(index);
}
};
return (
<div ref={subtasksSectionRef} className="space-y-3">
{isLoading ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('loading.subtasks', 'Loading subtasks...')}
</div>
) : subtasks.length > 0 ? (
<div className="space-y-1">
{subtasks.map((subtask, index) => (
<div
key={subtask.id || index}
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800"
>
{editingIndex === index ? (
<div className="px-3 py-2.5 flex items-center space-x-3 overflow-hidden">
<div className="flex-shrink-0">
<TaskPriorityIcon
priority={subtask.priority || 'low'}
status={
subtask.status || 'not_started'
}
onToggleCompletion={() => handleToggleSubtaskCompletion(subtask, index)}
/>
</div>
<input
type="text"
value={editingName}
onChange={(e) =>
setEditingName(e.target.value)
}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveEdit();
} else if (e.key === 'Escape') {
handleCancelEdit();
}
}}
onBlur={handleSaveEdit}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:text-white overflow-hidden"
autoFocus
/>
<button
type="button"
onClick={handleCancelEdit}
className="p-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-400"
title={t('actions.cancel', 'Cancel')}
>
×
</button>
</div>
) : (
<div className="px-3 py-2.5 flex items-center justify-between overflow-hidden">
<div className="flex items-center space-x-3 flex-1 min-w-0 overflow-hidden">
<div className="flex-shrink-0">
<TaskPriorityIcon
priority={
subtask.priority || 'low'
}
status={
subtask.status ||
'not_started'
}
onToggleCompletion={() => handleToggleSubtaskCompletion(subtask, index)}
/>
</div>
<span
className={`text-sm flex-1 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 break-all ${
subtask.status === 'done' ||
subtask.status === 2 ||
subtask.status === 'archived' ||
subtask.status === 3
? 'text-gray-500 dark:text-gray-400'
: 'text-gray-900 dark:text-gray-100'
}`}
onClick={() =>
handleEditSubtask(index)
}
title={t(
'actions.clickToEdit',
'Click to edit'
)}
>
{subtask.name}
</span>
</div>
<button
type="button"
onClick={() =>
handleDeleteSubtask(index)
}
className="p-1 text-red-500 hover:text-red-700 dark:hover:text-red-400"
title={t('actions.delete', 'Delete')}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
)}
</div>
))}
</div>
) : (
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('subtasks.noSubtasks', 'No subtasks yet')}
</div>
)}
<div className="flex items-center space-x-2">
<input
ref={addInputRef}
type="text"
value={newSubtaskName}
onChange={(e) => setNewSubtaskName(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={t('subtasks.placeholder', 'Add a subtask...')}
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white overflow-hidden"
/>
<button
type="button"
onClick={handleCreateSubtask}
disabled={!newSubtaskName.trim()}
className="p-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
title={t('actions.add', 'Add')}
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
export default TaskSubtasksSection;