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:
320
packages/dashboard/app/components/TodoView.css
Normal file
320
packages/dashboard/app/components/TodoView.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
274
packages/dashboard/app/components/__tests__/TodoView.test.tsx
Normal file
274
packages/dashboard/app/components/__tests__/TodoView.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user