Repository navigation
Add All, Active and Completed filter tabs to the todo list #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,23 @@ | ||
| 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'; | ||
|
|
||
| export default function App() { | ||
| const [todos, setTodos] = useState<Todo[]>([]); | ||
| const [loading, setLoading] = useState(true); | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [filter, setFilter] = useState<TodoFilter>('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]); | ||
| } | ||
|
Comment on lines
+36
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: set -u
printf '%s\n' '--- revisions ---'
git cat-file -t fc68680dd2032c629f4a9af9d8236a2f68b819b7
git cat-file -t 17fcf93a5b472d2da032ce28b8d0162dc64a8da5
printf '%s\n' '--- diff ---'
git diff --unified=80 17fcf93a5b472d2da032ce28b8d0162dc64a8da5 fc68680dd2032c629f4a9af9d8236a2f68b819b7 -- frontend/src/App.tsx frontend/src frontend
printf '%s\n' '--- App source ---'
nl -ba frontend/src/App.tsx
printf '%s\n' '--- related filter/fetch references ---'
rg -n -F --glob '*.{ts,tsx}' -- 'fetchTodos' frontend/src frontend || test "$?" -eq 1
rg -n -F --glob '*.{ts,tsx}' -- 'matchesFilter' frontend/src frontend || test "$?" -eq 1
rg -n -F --glob '*.{ts,tsx}' -- 'setFilter' frontend/src frontend || test "$?" -eq 1
printf '%s\n' '--- tests ---'
rg -n -F --glob '*.{test,spec}.{ts,tsx}' -- 'App' frontend || test "$?" -eq 1
rg -n -F --glob '*.{test,spec}.{ts,tsx}' -- 'FilterTabs' frontend || test "$?" -eq 1
rg -n -F --glob '*.{test,spec}.{ts,tsx}' -- 'createTodo' frontend || test "$?" -eq 1Repository: coderabbitai/ToDoRabbit Length of output: 15876 🏁 Script executed: printf '%s\n' '--- App ---'
nl -ba frontend/src/App.tsx
printf '%s\n' '--- source files ---'
find frontend/src -maxdepth 3 -type f -print | sort
printf '%s\n' '--- direct symbols ---'
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'fetchTodos' frontend/src || test "$?" -eq 1
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'matchesFilter' frontend/src || test "$?" -eq 1
rg -n -F --glob '*.ts' --glob '*.tsx' -- 'setFilter' frontend/src || test "$?" -eq 1
printf '%s\n' '--- changed App ---'
git diff --unified=40 17fcf93a5b472d2da032ce28b8d0162dc64a8da5 fc68680dd2032c629f4a9af9d8236a2f68b819b7 -- frontend/src/App.tsxRepository: coderabbitai/ToDoRabbit Length of output: 9361 🏁 Script executed: printf '%s\n' '--- AddTodoForm ---'
nl -ba frontend/src/components/AddTodoForm.tsx
printf '%s\n' '--- TodoList ---'
nl -ba frontend/src/components/TodoList.tsx
printf '%s\n' '--- TodoItem ---'
nl -ba frontend/src/components/TodoItem.tsx
printf '%s\n' '--- API ---'
nl -ba frontend/src/api/todos.tsRepository: coderabbitai/ToDoRabbit Length of output: 7938 Read the latest filter in both mutation handlers.
Keep the latest filter in a ref and use it in both handlers. Suggested fix-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
const [filter, setFilter] = useState<TodoFilter>('all');
+ const filterRef = useRef(filter);
+ filterRef.current = filter;
- if (matchesFilter(newTodo, filter)) {
+ if (matchesFilter(newTodo, filterRef.current)) {
setTodos((prev) => [newTodo, ...prev]);
}
- .filter((todo) => matchesFilter(todo, filter))
+ .filter((todo) => matchesFilter(todo, filterRef.current))🤖 Prompt for AI Agents |
||
| } 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() { | |
| </div> | ||
|
|
||
| <div className={styles.card}> | ||
| <FilterTabs value={filter} onChange={setFilter} /> | ||
| <TodoList | ||
| todos={todos} | ||
| loading={loading} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| .tabs { | ||
| display: flex; | ||
| gap: 0.5rem; | ||
| margin-bottom: 1rem; | ||
| } | ||
|
|
||
| .tab { | ||
| padding: 0.375rem 0.875rem; | ||
| font-size: 0.875rem; | ||
| font-weight: 500; | ||
| color: #4b5563; | ||
| background: #f3f4f6; | ||
| border: 1px solid transparent; | ||
| border-radius: 999px; | ||
| cursor: pointer; | ||
| transition: background 0.15s ease, color 0.15s ease; | ||
| } | ||
|
|
||
| .tab:hover { | ||
| background: #e5e7eb; | ||
| } | ||
|
|
||
| .active, | ||
| .active:hover { | ||
| color: #ffffff; | ||
| background: #667eea; | ||
| } | ||
|
Comment on lines
+23
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Check the contrast of the active tab text. White text on 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { renderToStaticMarkup } from 'react-dom/server'; | ||
| import FilterTabs from './FilterTabs'; | ||
|
|
||
| describe('FilterTabs', () => { | ||
| it('renders a tab for each filter', () => { | ||
| const html = renderToStaticMarkup(<FilterTabs value="all" onChange={() => {}} />); | ||
| expect(html).toContain('>All</button>'); | ||
| expect(html).toContain('>Active</button>'); | ||
| expect(html).toContain('>Completed</button>'); | ||
| }); | ||
|
|
||
| it('marks only the selected tab as selected', () => { | ||
| const html = renderToStaticMarkup(<FilterTabs value="active" onChange={() => {}} />); | ||
| expect(html.match(/aria-selected="true"/g)).toHaveLength(1); | ||
| expect(html).toMatch(/aria-selected="true"[^>]*>Active<\/button>/); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className={styles.tabs} role="tablist" aria-label="Filter todos"> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {FILTERS.map((filter) => ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| key={filter.value} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| role="tab" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| aria-selected={filter.value === value} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className={`${styles.tab} ${filter.value === value ? styles.active : ''}`} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onClick={() => onChange(filter.value)} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {filter.label} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+11
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Complete the ARIA tab wiring, or use plain buttons. The component uses Wrap the list in a ♿ Simpler option with native button semantics--- "a/frontend/src/components/FilterTabs.tsx"
+++ "b/frontend/src/components/FilterTabs.tsx"
@@ -8,13 +8,12 @@
export default function FilterTabs({ value, onChange }: FilterTabsProps) {
return (
- <div className={styles.tabs} role="tablist" aria-label="Filter todos">
+ <div className={styles.tabs} role="group" aria-label="Filter todos">
{FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
- role="tab"
- aria-selected={filter.value === value}
+ aria-pressed={filter.value === value}
className={`${styles.tab} ${filter.value === value ? styles.active : ''}`}
onClick={() => onChange(filter.value)}
>📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Learnings |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Fix the race condition between filter changes and
loadTodos.When the user switches filters quickly, two fetches run at once. A slow response for the old filter can finish last. The list then shows todos from the wrong filter under the selected tab. Ignore stale responses. Use a cancellation flag in the effect, or an
AbortController.Also,
loadTodossetsloadinganderrorfor stale requests. Apply the same guard there.🐛 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents