diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39693a2..6e33b72 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from 'react'; import type { Todo, TodoCreate } from './types/todo'; import { fetchTodos, createTodo, updateTodo, deleteTodo } from './api/todos'; +import { matchesFilter, toCompletedParam, type TodoFilter } from './filters'; import AddTodoForm from './components/AddTodoForm'; +import FilterTabs from './components/FilterTabs'; import TodoList from './components/TodoList'; import styles from './App.module.css'; @@ -9,12 +11,13 @@ export default function App() { const [todos, setTodos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [filter, setFilter] = useState('all'); - const loadTodos = async () => { + const loadTodos = async (activeFilter: TodoFilter) => { try { setLoading(true); setError(null); - const data = await fetchTodos(); + const data = await fetchTodos(toCompletedParam(activeFilter)); setTodos(data); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load todos'); @@ -24,13 +27,15 @@ export default function App() { }; useEffect(() => { - loadTodos(); - }, []); + loadTodos(filter); + }, [filter]); const handleAddTodo = async (data: TodoCreate) => { try { const newTodo = await createTodo(data); - setTodos((prev) => [newTodo, ...prev]); + if (matchesFilter(newTodo, filter)) { + setTodos((prev) => [newTodo, ...prev]); + } } catch (err) { alert(err instanceof Error ? err.message : 'Failed to create todo'); throw err; @@ -41,7 +46,9 @@ export default function App() { try { const updatedTodo = await updateTodo(id, { completed }); setTodos((prev) => - prev.map((todo) => (todo.id === id ? updatedTodo : todo)) + prev + .map((todo) => (todo.id === id ? updatedTodo : todo)) + .filter((todo) => matchesFilter(todo, filter)) ); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to update todo'); @@ -72,6 +79,7 @@ export default function App() {
+ { + it('renders a tab for each filter', () => { + const html = renderToStaticMarkup( {}} />); + expect(html).toContain('>All'); + expect(html).toContain('>Active'); + expect(html).toContain('>Completed'); + }); + + it('marks only the selected tab as selected', () => { + const html = renderToStaticMarkup( {}} />); + expect(html.match(/aria-selected="true"/g)).toHaveLength(1); + expect(html).toMatch(/aria-selected="true"[^>]*>Active<\/button>/); + }); +}); diff --git a/frontend/src/components/FilterTabs.tsx b/frontend/src/components/FilterTabs.tsx new file mode 100644 index 0000000..0df408b --- /dev/null +++ b/frontend/src/components/FilterTabs.tsx @@ -0,0 +1,26 @@ +import { FILTERS, type TodoFilter } from '../filters'; +import styles from './FilterTabs.module.css'; + +interface FilterTabsProps { + value: TodoFilter; + onChange: (filter: TodoFilter) => void; +} + +export default function FilterTabs({ value, onChange }: FilterTabsProps) { + return ( +
+ {FILTERS.map((filter) => ( + + ))} +
+ ); +} diff --git a/frontend/src/filters.test.ts b/frontend/src/filters.test.ts new file mode 100644 index 0000000..79f8df6 --- /dev/null +++ b/frontend/src/filters.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { matchesFilter, toCompletedParam } from './filters'; +import type { Todo } from './types/todo'; + +function makeTodo(completed: boolean): Todo { + return { + id: 1, + title: 'Water the plants', + description: null, + completed, + archived_at: null, + created_at: '2026-01-05T09:00:00Z', + updated_at: '2026-01-05T09:00:00Z', + }; +} + +describe('toCompletedParam', () => { + it('does not filter when showing all todos', () => { + expect(toCompletedParam('all')).toBeUndefined(); + }); + + it('maps active and completed to the API parameter', () => { + expect(toCompletedParam('active')).toBe(false); + expect(toCompletedParam('completed')).toBe(true); + }); +}); + +describe('matchesFilter', () => { + it('matches every todo for the all filter', () => { + expect(matchesFilter(makeTodo(true), 'all')).toBe(true); + expect(matchesFilter(makeTodo(false), 'all')).toBe(true); + }); + + it('matches only open todos for the active filter', () => { + expect(matchesFilter(makeTodo(false), 'active')).toBe(true); + expect(matchesFilter(makeTodo(true), 'active')).toBe(false); + }); + + it('matches only finished todos for the completed filter', () => { + expect(matchesFilter(makeTodo(true), 'completed')).toBe(true); + expect(matchesFilter(makeTodo(false), 'completed')).toBe(false); + }); +}); diff --git a/frontend/src/filters.ts b/frontend/src/filters.ts new file mode 100644 index 0000000..8575ae8 --- /dev/null +++ b/frontend/src/filters.ts @@ -0,0 +1,23 @@ +import type { Todo } from './types/todo'; + +export type TodoFilter = 'all' | 'active' | 'completed'; + +export const FILTERS: { value: TodoFilter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'active', label: 'Active' }, + { value: 'completed', label: 'Completed' }, +]; + +/** Value for the API's `completed` query parameter, or undefined to fetch everything. */ +export function toCompletedParam(filter: TodoFilter): boolean | undefined { + if (filter === 'all') { + return undefined; + } + return filter === 'completed'; +} + +/** Whether a todo belongs in the list for the given filter. */ +export function matchesFilter(todo: Todo, filter: TodoFilter): boolean { + const completed = toCompletedParam(filter); + return completed === undefined || todo.completed === completed; +}