- 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
539 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|