Files
fusion/packages/dashboard/app/components/TodoView.tsx
Fusion c009427401 fix(FN-2577): preserve Todos access and TodoView interaction behavior
- Keep the view overflow trigger available when Todos is supported so the Todos view remains reachable
- Update TodoView list rows to use an explicit select button and align active-state assertions with the new structure
- Restore keyboard focus styling and mobile action visibility in TodoView CSS for accessibility and usability
- Harden CLI extension test cleanup with retry logic for transient ENOTEMPTY/EBUSY tempdir removal errors
2026-04-26 22:07:43 -07:00

539 lines
18 KiB
TypeScript

import { useMemo, useState } from "react";
import {
Plus,
Trash2,
Pencil,
Check,
X,
ChevronUp,
ChevronDown,
Loader2,
ListChecks,
} from "lucide-react";
import type { TodoItem, TodoList } from "@fusion/core";
import { useTodoLists } from "../hooks/useTodoLists";
import "./TodoView.css";
interface TodoViewProps {
projectId?: string;
addToast: (message: string, type?: "success" | "error" | "info") => void;
}
function sortItems(items: TodoItem[]): TodoItem[] {
return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
}
export function TodoView({ projectId, addToast }: TodoViewProps) {
const {
lists,
items,
loading,
error,
selectedListId,
setSelectedListId,
createList,
renameList,
deleteList,
createItem,
updateItem,
toggleItem,
deleteItem,
reorderItems,
} = useTodoLists({
projectId,
addToast: (message, type) => {
if (type === "success" || type === "error" || type === "info" || type === undefined) {
addToast(message, type);
return;
}
addToast(message, "info");
},
});
const [editingListId, setEditingListId] = useState<string | null>(null);
const [editingListTitle, setEditingListTitle] = useState("");
const [editingItemId, setEditingItemId] = useState<string | null>(null);
const [editingItemText, setEditingItemText] = useState("");
const [newListTitle, setNewListTitle] = useState("");
const [isAddingList, setIsAddingList] = useState(false);
const [newItemText, setNewItemText] = useState("");
const selectedList = useMemo(
() => lists.find((list) => list.id === selectedListId) ?? null,
[lists, selectedListId],
);
const sortedItems = useMemo(
() => sortItems(items.filter((item) => item.listId === selectedListId)),
[items, selectedListId],
);
function handleStartRenameList(list: TodoList): void {
setEditingListId(list.id);
setEditingListTitle(list.title);
setIsAddingList(false);
}
async function handleSaveRenameList(): Promise<void> {
if (!editingListId) {
return;
}
const trimmedTitle = editingListTitle.trim();
if (!trimmedTitle) {
setEditingListId(null);
setEditingListTitle("");
return;
}
await renameList(editingListId, trimmedTitle);
setEditingListId(null);
setEditingListTitle("");
}
function handleCancelRenameList(): void {
setEditingListId(null);
setEditingListTitle("");
}
function handleStartEditItem(item: TodoItem): void {
setEditingItemId(item.id);
setEditingItemText(item.text);
}
async function handleSaveEditItem(): Promise<void> {
if (!editingItemId) {
return;
}
const trimmedText = editingItemText.trim();
if (!trimmedText) {
setEditingItemId(null);
setEditingItemText("");
return;
}
await updateItem(editingItemId, { text: trimmedText });
setEditingItemId(null);
setEditingItemText("");
}
function handleCancelEditItem(): void {
setEditingItemId(null);
setEditingItemText("");
}
async function handleAddList(): Promise<void> {
const trimmedTitle = newListTitle.trim();
if (!trimmedTitle) {
return;
}
await createList(trimmedTitle);
setNewListTitle("");
setIsAddingList(false);
}
async function handleAddItem(): Promise<void> {
if (!selectedListId) {
return;
}
const trimmedText = newItemText.trim();
if (!trimmedText) {
return;
}
await createItem(trimmedText);
setNewItemText("");
}
async function handleDeleteList(id: string): Promise<void> {
const shouldDelete = window.confirm("Delete this list and all its items?");
if (!shouldDelete) {
return;
}
await deleteList(id);
}
async function handleDeleteItem(id: string): Promise<void> {
await deleteItem(id);
}
async function handleMoveItem(itemId: string, direction: "up" | "down"): Promise<void> {
const ids = sortedItems.map((item) => item.id);
const index = ids.findIndex((id) => id === itemId);
if (index < 0) {
return;
}
const targetIndex = direction === "up" ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= ids.length) {
return;
}
[ids[index], ids[targetIndex]] = [ids[targetIndex], ids[index]];
await reorderItems(ids);
}
if (loading) {
return (
<div className="todo-view">
<div className="todo-loading">
<Loader2 className="todo-loading-icon" aria-hidden="true" />
<p>Loading todos...</p>
</div>
</div>
);
}
return (
<div className="todo-view">
<div className="todo-view-header">
<div>
<h2>Todos</h2>
<p className="todo-view-description">Manage reusable todo lists for your project.</p>
</div>
</div>
<div className="todo-view-layout">
<aside className="todo-view-sidebar" aria-label="Todo lists sidebar">
<div className="todo-sidebar-header">
<h3 className="todo-sidebar-title">Lists</h3>
<button
type="button"
className="btn btn-sm btn-icon todo-add-list-btn"
onClick={() => {
setIsAddingList(true);
setEditingListId(null);
}}
aria-label="Add list"
data-testid="add-list-button"
>
<Plus size={14} />
</button>
</div>
{isAddingList && (
<div className="todo-list-item">
<input
className="input todo-inline-edit-input"
placeholder="New list title"
value={newListTitle}
onChange={(event) => setNewListTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleAddList();
}
if (event.key === "Escape") {
setNewListTitle("");
setIsAddingList(false);
}
}}
autoFocus
data-testid="new-list-input"
/>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
void handleAddList();
}}
aria-label="Save list"
>
<Check size={14} />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
setNewListTitle("");
setIsAddingList(false);
}}
aria-label="Cancel list"
>
<X size={14} />
</button>
</div>
)}
{lists.length === 0 ? (
<div className="todo-empty-state">
<ListChecks aria-hidden="true" />
<p>No todo lists yet. Create one to get started.</p>
<button
type="button"
className="btn btn-sm"
onClick={() => setIsAddingList(true)}
>
Create List
</button>
</div>
) : (
<div className="todo-list-items">
{lists.map((list) => {
const isActive = list.id === selectedListId;
const isEditing = list.id === editingListId;
return (
<div
key={list.id}
className={`todo-list-item${isActive ? " todo-list-item--active" : ""}`}
>
{isEditing ? (
<>
<input
className="input todo-inline-edit-input"
value={editingListTitle}
onChange={(event) => setEditingListTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleSaveRenameList();
}
if (event.key === "Escape") {
handleCancelRenameList();
}
}}
autoFocus
data-testid={`rename-list-input-${list.id}`}
/>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
void handleSaveRenameList();
}}
aria-label="Save list rename"
>
<Check size={14} />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={handleCancelRenameList}
aria-label="Cancel list rename"
>
<X size={14} />
</button>
</>
) : (
<>
<button
type="button"
className="todo-list-select-btn"
onClick={() => setSelectedListId(list.id)}
data-testid={`todo-list-${list.id}`}
>
<span className="todo-list-item-name">{list.title}</span>
</button>
<div className="todo-list-item-actions">
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
handleStartRenameList(list);
}}
aria-label={`Rename ${list.title}`}
data-testid={`rename-list-button-${list.id}`}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn btn-sm btn-icon btn-danger todo-icon-btn"
onClick={() => {
void handleDeleteList(list.id);
}}
aria-label={`Delete ${list.title}`}
data-testid={`delete-list-button-${list.id}`}
>
<Trash2 size={14} />
</button>
</div>
</>
)}
</div>
);
})}
</div>
)}
</aside>
<section className="todo-view-main" aria-label="Todo items">
{error && (
<div className="todo-error-banner" role="alert">
<span className="todo-error-message">{error}</span>
<button type="button" className="btn btn-sm" onClick={() => window.location.reload()}>
Retry
</button>
</div>
)}
{!selectedList ? (
<div className="todo-empty-state">
<ListChecks aria-hidden="true" />
<p>Select a list from the sidebar</p>
</div>
) : (
<>
<div className="todo-items-header">
<h3>{selectedList.title}</h3>
</div>
<div className="todo-add-item-row">
<input
className="input"
placeholder="Add a todo item"
value={newItemText}
onChange={(event) => setNewItemText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleAddItem();
}
}}
data-testid="new-item-input"
/>
<button
type="button"
className="btn btn-primary"
onClick={() => {
void handleAddItem();
}}
>
Add
</button>
</div>
{sortedItems.length === 0 ? (
<div className="todo-empty-state">
<p>No items in this list. Add one above.</p>
</div>
) : (
<div className="todo-items-list">
{sortedItems.map((item, index) => {
const isEditing = item.id === editingItemId;
return (
<div className="todo-item" key={item.id} data-testid={`todo-item-${item.id}`}>
<input
type="checkbox"
checked={item.completed}
onChange={() => {
void toggleItem(item.id);
}}
className="todo-item-checkbox"
aria-label={`Toggle ${item.text}`}
data-testid={`toggle-item-${item.id}`}
/>
{isEditing ? (
<input
className="input todo-inline-edit-input"
value={editingItemText}
onChange={(event) => setEditingItemText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleSaveEditItem();
}
if (event.key === "Escape") {
handleCancelEditItem();
}
}}
autoFocus
data-testid={`edit-item-input-${item.id}`}
/>
) : (
<button
type="button"
className={`todo-item-text${item.completed ? " todo-item-text--completed" : ""}`}
onClick={() => handleStartEditItem(item)}
>
{item.text}
</button>
)}
<div className="todo-item-actions">
{isEditing ? (
<>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => {
void handleSaveEditItem();
}}
aria-label="Save item edit"
>
<Check size={14} />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={handleCancelEditItem}
aria-label="Cancel item edit"
>
<X size={14} />
</button>
</>
) : (
<>
<div className="todo-item-reorder-btns">
<button
type="button"
className="btn btn-sm btn-icon todo-item-reorder-btn"
onClick={() => {
void handleMoveItem(item.id, "up");
}}
disabled={index === 0}
aria-label={`Move ${item.text} up`}
data-testid={`move-up-${item.id}`}
>
<ChevronUp size={14} />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-item-reorder-btn"
onClick={() => {
void handleMoveItem(item.id, "down");
}}
disabled={index === sortedItems.length - 1}
aria-label={`Move ${item.text} down`}
data-testid={`move-down-${item.id}`}
>
<ChevronDown size={14} />
</button>
</div>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={() => handleStartEditItem(item)}
aria-label={`Edit ${item.text}`}
data-testid={`edit-item-${item.id}`}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn btn-sm btn-icon btn-danger todo-icon-btn"
onClick={() => {
void handleDeleteItem(item.id);
}}
aria-label={`Delete ${item.text}`}
data-testid={`delete-item-${item.id}`}
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</>
)}
</section>
</div>
</div>
);
}