refactor: migrate TodoList component to a new file with improved structure and normalization logic

This commit is contained in:
Haileyesus
2026-02-27 22:26:03 +03:00
parent a78bff874b
commit 5b7995751e
3 changed files with 186 additions and 108 deletions

View File

@@ -1,91 +0,0 @@
import React from 'react';
import { Badge } from './ui/badge';
import { CheckCircle2, Clock, Circle } from 'lucide-react';
const TodoList = ({ todos, isResult = false }) => {
if (!todos || !Array.isArray(todos)) {
return null;
}
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="w-3.5 h-3.5 text-green-500 dark:text-green-400" />;
case 'in_progress':
return <Clock className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" />;
case 'pending':
default:
return <Circle className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />;
}
};
const getStatusColor = (status) => {
switch (status) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800';
case 'in_progress':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800';
case 'pending':
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700';
}
};
const getPriorityColor = (priority) => {
switch (priority) {
case 'high':
return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800';
case 'medium':
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800';
case 'low':
default:
return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700';
}
};
return (
<div className="space-y-1.5">
{isResult && (
<div className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
</div>
)}
{todos.map((todo, index) => (
<div
key={todo.id || `todo-${index}`}
className="flex items-start gap-2 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded transition-colors"
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(todo.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-0.5">
<p className={`text-xs font-medium ${todo.status === 'completed' ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100'}`}>
{todo.content}
</p>
<div className="flex gap-1 flex-shrink-0">
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-px ${getPriorityColor(todo.priority)}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-px ${getStatusColor(todo.status)}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
))}
</div>
);
};
export default TodoList;

View File

@@ -0,0 +1,152 @@
import { memo, useMemo } from 'react';
import { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react';
import { Badge } from '../../../../ui/badge';
type TodoStatus = 'completed' | 'in_progress' | 'pending';
type TodoPriority = 'high' | 'medium' | 'low';
export type TodoItem = {
id?: string;
content: string;
status: string;
priority?: string;
};
type NormalizedTodoItem = {
id?: string;
content: string;
status: TodoStatus;
priority: TodoPriority;
};
type StatusConfig = {
icon: LucideIcon;
iconClassName: string;
badgeClassName: string;
textClassName: string;
};
// Centralized visual config keeps rendering logic compact and easier to scan.
const STATUS_CONFIG: Record<TodoStatus, StatusConfig> = {
completed: {
icon: CheckCircle2,
iconClassName: 'w-3.5 h-3.5 text-green-500 dark:text-green-400',
badgeClassName:
'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800',
textClassName: 'line-through text-gray-500 dark:text-gray-400',
},
in_progress: {
icon: Clock,
iconClassName: 'w-3.5 h-3.5 text-blue-500 dark:text-blue-400',
badgeClassName:
'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800',
textClassName: 'text-gray-900 dark:text-gray-100',
},
pending: {
icon: Circle,
iconClassName: 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500',
badgeClassName:
'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
textClassName: 'text-gray-900 dark:text-gray-100',
},
};
const PRIORITY_BADGE_CLASS: Record<TodoPriority, string> = {
high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',
medium:
'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800',
low: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',
};
// Incoming tool payloads can vary; normalize to supported UI states.
const normalizeStatus = (status: string): TodoStatus => {
if (status === 'completed' || status === 'in_progress') {
return status;
}
return 'pending';
};
const normalizePriority = (priority?: string): TodoPriority => {
if (priority === 'high' || priority === 'medium') {
return priority;
}
return 'low';
};
const TodoRow = memo(
({ todo }: { todo: NormalizedTodoItem }) => {
const statusConfig = STATUS_CONFIG[todo.status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex items-start gap-2 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded transition-colors">
<div className="flex-shrink-0 mt-0.5">
<StatusIcon className={statusConfig.iconClassName} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-0.5">
<p className={`text-xs font-medium ${statusConfig.textClassName}`}>
{todo.content}
</p>
<div className="flex gap-1 flex-shrink-0">
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-px ${PRIORITY_BADGE_CLASS[todo.priority]}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-px ${statusConfig.badgeClassName}`}
>
{todo.status.replace('_', ' ')}
</Badge>
</div>
</div>
</div>
</div>
);
}
);
const TodoList = memo(
({
todos,
isResult = false,
}: {
todos: TodoItem[];
isResult?: boolean;
}) => {
// Memoize normalization to avoid recomputing list metadata on every render.
const normalizedTodos = useMemo<NormalizedTodoItem[]>(
() =>
todos.map((todo) => ({
id: todo.id,
content: todo.content,
status: normalizeStatus(todo.status),
priority: normalizePriority(todo.priority),
})),
[todos]
);
if (normalizedTodos.length === 0) {
return null;
}
return (
<div className="space-y-1.5">
{isResult && (
<div className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Todo List ({normalizedTodos.length}{' '}
{normalizedTodos.length === 1 ? 'item' : 'items'})
</div>
)}
{normalizedTodos.map((todo, index) => (
<TodoRow key={todo.id ?? `${todo.content}-${index}`} todo={todo} />
))}
</div>
);
}
);
export default TodoList;

View File

@@ -1,23 +1,40 @@
import React from 'react'; import { memo, useMemo } from 'react';
import TodoList from '../../../../TodoList'; import TodoList, { type TodoItem } from './TodoList';
interface TodoListContentProps { const isTodoItem = (value: unknown): value is TodoItem => {
todos: Array<{ if (typeof value !== 'object' || value === null) {
id?: string; return false;
content: string; }
status: string;
priority?: string; const todo = value as Record<string, unknown>;
}>; return typeof todo.content === 'string' && typeof todo.status === 'string';
isResult?: boolean; };
}
/** /**
* Renders a todo list * Renders a todo list
* Used by: TodoWrite, TodoRead * Used by: TodoWrite, TodoRead
*/ */
export const TodoListContent: React.FC<TodoListContentProps> = ({ export const TodoListContent = memo(
todos, ({
isResult = false todos,
}) => { isResult = false,
return <TodoList todos={todos} isResult={isResult} />; }: {
}; todos: unknown;
isResult?: boolean;
}) => {
const safeTodos = useMemo<TodoItem[]>(() => {
if (!Array.isArray(todos)) {
return [];
}
// Tool payloads are runtime data; render only validated todo objects.
return todos.filter(isTodoItem);
}, [todos]);
if (safeTodos.length === 0) {
return null;
}
return <TodoList todos={safeTodos} isResult={isResult} />;
}
);