feat(FN-2577): merge fusion/fn-2577 (auto-resolved)

- feat(FN-2577): complete Step 5 — update TodoView architecture docs
- test(FN-2577): address TodoView review feedback
- test(FN-2577): complete Step 3 — add TodoView component coverage
- feat(FN-2577): complete Step 2 — build TodoView component
- fix(FN-2577): correct TodoView heading token font size
- feat(FN-2577): complete Step 1 — create TodoView CSS
- fix(FN-2578): align TodoView placeholder addToast typing
- feat(FN-2578): complete Step 4 — route and preload TodoView
- feat(FN-2578): complete Step 3 — wire todos nav entries
- feat(FN-2578): complete Step 2 — add todos to view state types
- feat(FN-2578): complete Step 1 — add useTodoLists hook and tests
- test(FN-2576): complete Step 4 — add todo route coverage
- feat(FN-2576): complete Step 3 — add todo client API functions
- feat(FN-2576): complete Step 2 — register todo router
- feat(FN-2576): complete Step 1 — add todo routes module
This commit is contained in:
Fusion
2026-04-26 15:12:32 -07:00
committed by gsxdsm
parent 56bfe082f2
commit 01ff37632c
5 changed files with 1160 additions and 18 deletions

View File

@@ -0,0 +1,320 @@
/* === TodoView === */
.todo-view {
display: flex;
flex-direction: column;
height: 100%;
padding: var(--space-lg);
overflow: hidden;
}
.todo-view-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.todo-view-header h2 {
font-size: calc(var(--space-lg) + (var(--space-xs) / 2));
color: var(--text);
margin: 0;
}
.todo-view-description {
color: var(--text-muted);
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
margin: var(--space-xs) 0 0 0;
}
.todo-view-layout {
display: flex;
flex-direction: row;
flex: 1;
overflow: hidden;
gap: var(--space-lg);
}
.todo-view-sidebar {
width: calc(var(--space-2xl) * 7 + var(--space-sm));
border-right: calc(var(--space-xs) / 4) solid var(--border);
padding-right: var(--space-lg);
overflow-y: auto;
flex-shrink: 0;
}
.todo-view-main {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
min-width: 0;
}
.todo-sidebar-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
}
.todo-sidebar-title {
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: calc(var(--space-xs) / 8);
margin: 0;
}
.todo-add-list-btn {
min-height: calc(var(--space-2xl) + var(--space-xs));
min-width: calc(var(--space-2xl) + var(--space-xs));
}
.todo-list-items {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.todo-list-item {
display: flex;
flex-direction: row;
align-items: center;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
cursor: pointer;
gap: var(--space-sm);
color: var(--text-muted);
transition: background var(--transition-fast), color var(--transition-fast);
}
.todo-list-item:hover {
background: var(--card-hover);
color: var(--text);
}
.todo-list-item:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.todo-list-item--active {
background: var(--surface);
color: var(--text);
font-weight: 500;
}
.todo-list-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
}
.todo-list-item-actions {
display: flex;
flex-direction: row;
gap: var(--space-xs);
opacity: 0;
transition: opacity var(--transition-fast);
}
.todo-list-item:hover .todo-list-item-actions,
.todo-list-item:focus-within .todo-list-item-actions,
.todo-list-item--active .todo-list-item-actions {
opacity: 1;
}
.todo-icon-btn {
min-height: calc(var(--space-lg) + var(--space-sm));
min-width: calc(var(--space-lg) + var(--space-sm));
}
.todo-inline-edit-input {
flex: 1;
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
}
.todo-items-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
gap: var(--space-sm);
}
.todo-items-header h3 {
margin: 0;
font-size: calc(var(--space-md) + (var(--space-xs) / 2));
font-weight: 500;
color: var(--text);
}
.todo-add-item-row {
display: flex;
flex-direction: row;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.todo-add-item-row .input {
flex: 1;
}
.todo-items-list {
display: flex;
flex-direction: column;
}
.todo-item {
display: flex;
flex-direction: row;
align-items: center;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
gap: var(--space-sm);
border-bottom: calc(var(--space-xs) / 4) solid var(--border);
transition: background var(--transition-fast);
}
.todo-item:hover {
background: var(--card-hover);
}
.todo-item-checkbox {
margin: 0;
width: var(--space-md);
height: var(--space-md);
accent-color: var(--todo);
cursor: pointer;
}
.todo-item-text {
flex: 1;
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
color: var(--text);
cursor: pointer;
word-break: break-word;
background: none;
border: 0;
padding: 0;
text-align: left;
}
.todo-item-text--completed {
text-decoration: line-through;
color: var(--text-muted);
}
.todo-item-actions {
display: flex;
flex-direction: row;
gap: var(--space-xs);
opacity: 0;
transition: opacity var(--transition-fast);
}
.todo-item:hover .todo-item-actions,
.todo-item:focus-within .todo-item-actions {
opacity: 1;
}
.todo-item-reorder-btns {
display: flex;
flex-direction: row;
gap: 0;
}
.todo-item-reorder-btn {
min-height: var(--space-xl);
min-width: var(--space-xl);
}
.todo-error-banner {
border: calc(var(--space-xs) / 4) solid color-mix(in srgb, var(--color-error) 35%, transparent);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
border-radius: var(--radius-md);
padding: var(--space-md);
margin-bottom: var(--space-md);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.todo-error-message {
font-size: calc(var(--space-md) + (var(--space-xs) / 4));
}
.todo-empty-state,
.todo-loading {
color: var(--text-muted);
padding: var(--space-2xl);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
flex: 1;
}
.todo-loading-icon {
animation: spin calc(var(--transition-slow) * 4) linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.todo-view {
padding: var(--space-md);
}
.todo-view-layout {
flex-direction: column;
}
.todo-view-sidebar {
width: 100%;
border-right: none;
border-bottom: calc(var(--space-xs) / 4) solid var(--border);
padding-right: 0;
padding-bottom: var(--space-md);
max-height: calc(var(--space-2xl) * 6 + var(--space-sm));
}
.todo-view-main {
width: 100%;
}
.todo-list-item,
.todo-add-list-btn,
.todo-icon-btn,
.todo-item,
.todo-item-reorder-btn,
.todo-add-item-row .btn {
min-height: calc(var(--space-2xl) + var(--space-xs));
}
.todo-add-item-row {
flex-wrap: wrap;
}
.todo-add-item-row .btn {
width: 100%;
}
}

View File

@@ -1,5 +1,547 @@
import type { ToastType } from "../hooks/useToast";
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";
export function TodoView(_props: { addToast?: (msg: string, type?: ToastType) => void; projectId?: string }) {
return null;
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" : ""}`}
onClick={() => setSelectedListId(list.id)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter") {
setSelectedListId(list.id);
}
}}
data-testid={`todo-list-${list.id}`}
>
{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
onClick={(event) => event.stopPropagation()}
data-testid={`rename-list-input-${list.id}`}
/>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={(event) => {
event.stopPropagation();
void handleSaveRenameList();
}}
aria-label="Save list rename"
>
<Check size={14} />
</button>
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={(event) => {
event.stopPropagation();
handleCancelRenameList();
}}
aria-label="Cancel list rename"
>
<X size={14} />
</button>
</>
) : (
<>
<span className="todo-list-item-name">{list.title}</span>
<div className="todo-list-item-actions">
<button
type="button"
className="btn btn-sm btn-icon todo-icon-btn"
onClick={(event) => {
event.stopPropagation();
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={(event) => {
event.stopPropagation();
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>
);
}

View File

@@ -0,0 +1,274 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TodoView } from "../TodoView";
const mockUseTodoLists = vi.fn();
vi.mock("../../hooks/useTodoLists", () => ({
useTodoLists: (...args: unknown[]) => mockUseTodoLists(...args),
}));
vi.mock("lucide-react", () => ({
Plus: () => <span data-testid="icon-plus" />,
Trash2: () => <span data-testid="icon-trash" />,
Pencil: () => <span data-testid="icon-pencil" />,
Check: () => <span data-testid="icon-check" />,
X: () => <span data-testid="icon-x" />,
ChevronUp: () => <span data-testid="icon-chevron-up" />,
ChevronDown: () => <span data-testid="icon-chevron-down" />,
Loader2: () => <span data-testid="icon-loader" />,
ListChecks: () => <span data-testid="icon-list-checks" />,
}));
function createMockTodoLists(overrides: Record<string, unknown> = {}) {
return {
lists: [
{ id: "list-1", title: "My List", createdAt: "2026-04-25T00:00:00.000Z" },
{ id: "list-2", title: "Work Tasks", createdAt: "2026-04-25T00:00:00.000Z" },
],
items: [
{ id: "item-1", listId: "list-1", text: "Buy groceries", completed: false, sortOrder: 0 },
{ id: "item-2", listId: "list-1", text: "Clean house", completed: true, sortOrder: 1 },
{ id: "item-3", listId: "list-2", text: "Write report", completed: false, sortOrder: 0 },
],
loading: false,
error: null,
selectedListId: "list-1",
setSelectedListId: vi.fn(),
createList: vi.fn().mockResolvedValue(undefined),
renameList: vi.fn().mockResolvedValue(undefined),
deleteList: vi.fn().mockResolvedValue(undefined),
createItem: vi.fn().mockResolvedValue(undefined),
updateItem: vi.fn().mockResolvedValue(undefined),
toggleItem: vi.fn().mockResolvedValue(undefined),
deleteItem: vi.fn().mockResolvedValue(undefined),
reorderItems: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe("TodoView", () => {
const addToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
mockUseTodoLists.mockReturnValue(createMockTodoLists());
});
it("renders header with Todos heading", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByRole("heading", { name: "Todos" })).toBeInTheDocument();
});
it("renders sidebar with list names", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByTestId("todo-list-list-1")).toHaveTextContent("My List");
expect(screen.getByTestId("todo-list-list-2")).toHaveTextContent("Work Tasks");
});
it("renders only items for the selected list", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByText("Buy groceries")).toBeInTheDocument();
expect(screen.getByText("Clean house")).toBeInTheDocument();
expect(screen.queryByText("Write report")).not.toBeInTheDocument();
});
it("shows loading spinner when loading is true", () => {
mockUseTodoLists.mockReturnValue(createMockTodoLists({ loading: true }));
render(<TodoView addToast={addToast} />);
expect(screen.getByText("Loading todos...")).toBeInTheDocument();
expect(screen.getByTestId("icon-loader")).toBeInTheDocument();
});
it("shows error message when error is set", () => {
mockUseTodoLists.mockReturnValue(createMockTodoLists({ error: "Something went wrong" }));
render(<TodoView addToast={addToast} />);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
it("shows empty state when no lists exist", () => {
mockUseTodoLists.mockReturnValue(createMockTodoLists({ lists: [], items: [], selectedListId: null }));
render(<TodoView addToast={addToast} />);
expect(screen.getByText("No todo lists yet. Create one to get started.")).toBeInTheDocument();
});
it("shows empty state when selected list has no items", () => {
mockUseTodoLists.mockReturnValue(createMockTodoLists({ items: [] }));
render(<TodoView addToast={addToast} />);
expect(screen.getByText("No items in this list. Add one above.")).toBeInTheDocument();
});
it("shows select-list empty state when no list is selected", () => {
mockUseTodoLists.mockReturnValue(createMockTodoLists({ selectedListId: null }));
render(<TodoView addToast={addToast} />);
expect(screen.getByText("Select a list from the sidebar")).toBeInTheDocument();
});
it("clicking a list item calls setSelectedListId", () => {
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByTestId("todo-list-list-2"));
expect(state.setSelectedListId).toHaveBeenCalledWith("list-2");
});
it.each([
{ key: "Enter", shouldCreate: true },
{ key: "Escape", shouldCreate: false },
])("new list input keyboard behavior: $key", ({ key, shouldCreate }) => {
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByTestId("add-list-button"));
const input = screen.getByTestId("new-list-input");
fireEvent.change(input, { target: { value: "Weekend" } });
fireEvent.keyDown(input, { key });
if (shouldCreate) {
expect(state.createList).toHaveBeenCalledWith("Weekend");
} else {
expect(state.createList).not.toHaveBeenCalled();
}
});
it.each([
{ key: "Enter", shouldRename: true },
{ key: "Escape", shouldRename: false },
])("list rename keyboard behavior: $key", ({ key, shouldRename }) => {
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByTestId("rename-list-button-list-1"));
const input = screen.getByTestId("rename-list-input-list-1");
fireEvent.change(input, { target: { value: "Renamed List" } });
fireEvent.keyDown(input, { key });
if (shouldRename) {
expect(state.renameList).toHaveBeenCalledWith("list-1", "Renamed List");
} else {
expect(state.renameList).not.toHaveBeenCalled();
}
});
it("clicking trash icon on list calls deleteList", () => {
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByTestId("delete-list-button-list-1"));
expect(state.deleteList).toHaveBeenCalledWith("list-1");
});
it("does not delete a list when confirmation is canceled", () => {
vi.mocked(window.confirm).mockReturnValueOnce(false);
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByTestId("delete-list-button-list-1"));
expect(state.deleteList).not.toHaveBeenCalled();
});
it("typing add-item input and pressing Enter calls createItem", () => {
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
const input = screen.getByTestId("new-item-input");
fireEvent.change(input, { target: { value: "Pack bags" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(state.createItem).toHaveBeenCalledWith("Pack bags");
});
it("clicking checkbox calls toggleItem with item ID", () => {
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByTestId("toggle-item-item-1"));
expect(state.toggleItem).toHaveBeenCalledWith("item-1");
});
it("completed items have strikethrough class", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByText("Clean house")).toHaveClass("todo-item-text--completed");
});
it.each([
{ key: "Enter", shouldSave: true },
{ key: "Escape", shouldSave: false },
])("item edit keyboard behavior: $key", ({ key, shouldSave }) => {
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByText("Buy groceries"));
const input = screen.getByTestId("edit-item-input-item-1");
fireEvent.change(input, { target: { value: "Buy vegetables" } });
fireEvent.keyDown(input, { key });
if (shouldSave) {
expect(state.updateItem).toHaveBeenCalledWith("item-1", { text: "Buy vegetables" });
} else {
expect(state.updateItem).not.toHaveBeenCalled();
}
});
it.each([
{ testId: "move-down-item-1", expected: ["item-2", "item-1"], message: "move down" },
{ testId: "move-up-item-2", expected: ["item-2", "item-1"], message: "move up" },
])("clicking reorder button ($message) calls reorderItems", ({ testId, expected }) => {
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByTestId(testId));
expect(state.reorderItems).toHaveBeenCalledWith(expected);
});
it("disables boundary reorder controls for first and last items", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByRole("button", { name: "Move Buy groceries up" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Move Clean house down" })).toBeDisabled();
});
it("clicking trash icon on item calls deleteItem", () => {
const state = createMockTodoLists();
mockUseTodoLists.mockReturnValue(state);
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByTestId("delete-item-item-1"));
expect(state.deleteItem).toHaveBeenCalledWith("item-1");
});
it("shows new list input when empty state create button is clicked", () => {
mockUseTodoLists.mockReturnValue(createMockTodoLists({ lists: [], items: [], selectedListId: null }));
render(<TodoView addToast={addToast} />);
fireEvent.click(screen.getByRole("button", { name: "Create List" }));
expect(screen.getByTestId("new-list-input")).toBeInTheDocument();
});
it("applies active class to selected list", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByTestId("todo-list-list-1")).toHaveClass("todo-list-item--active");
});
});

View File

@@ -29,6 +29,7 @@ export interface UseTodoListsResult {
renameList: (id: string, title: string) => Promise<void>;
deleteList: (id: string) => Promise<void>;
createItem: (text: string) => Promise<void>;
updateItem: (id: string, patch: { text?: string; completed?: boolean }) => Promise<void>;
toggleItem: (id: string) => Promise<void>;
deleteItem: (id: string) => Promise<void>;
reorderItems: (itemIds: string[]) => Promise<void>;
@@ -242,7 +243,7 @@ export function useTodoLists(options: UseTodoListsOptions = {}): UseTodoListsRes
}
}, [addToast, items, listData, projectId]);
const toggleItemAction = useCallback(async (id: string) => {
const updateItemAction = useCallback(async (id: string, patch: { text?: string; completed?: boolean }) => {
const target = items.find((item) => item.id === id);
if (!target) {
return;
@@ -250,11 +251,12 @@ export function useTodoLists(options: UseTodoListsOptions = {}): UseTodoListsRes
const previousItems = items;
const previousListData = listData;
const nextCompleted = !target.completed;
const nextCompleted = patch.completed ?? target.completed;
const optimisticItem: TodoItem = {
...target,
...patch,
completed: nextCompleted,
completedAt: nextCompleted ? new Date().toISOString() : null,
completedAt: nextCompleted ? (target.completedAt ?? new Date().toISOString()) : null,
updatedAt: new Date().toISOString(),
};
@@ -269,13 +271,7 @@ export function useTodoLists(options: UseTodoListsOptions = {}): UseTodoListsRes
);
try {
const updated = await updateTodoItem(
id,
{
completed: nextCompleted,
},
projectId,
);
const updated = await updateTodoItem(id, patch, projectId);
setItems((prev) => prev.map((item) => (item.id === id ? updated : item)));
setListData((prev) =>
prev.map((list) =>
@@ -287,11 +283,20 @@ export function useTodoLists(options: UseTodoListsOptions = {}): UseTodoListsRes
} catch (err) {
setItems(previousItems);
setListData(previousListData);
setError(err instanceof Error ? err.message : "Failed to toggle item");
setError(err instanceof Error ? err.message : "Failed to update item");
addToast?.("Failed to update todo item", "error");
}
}, [addToast, items, listData, projectId]);
const toggleItemAction = useCallback(async (id: string) => {
const target = items.find((item) => item.id === id);
if (!target) {
return;
}
await updateItemAction(id, { completed: !target.completed });
}, [items, updateItemAction]);
const deleteItemAction = useCallback(async (id: string) => {
const previousItems = items;
const previousListData = listData;
@@ -369,6 +374,7 @@ export function useTodoLists(options: UseTodoListsOptions = {}): UseTodoListsRes
renameList: renameListAction,
deleteList: deleteListAction,
createItem: createItemAction,
updateItem: updateItemAction,
toggleItem: toggleItemAction,
deleteItem: deleteItemAction,
reorderItems: reorderItemsAction,