From cdee00c277de03509df6948b7a9ed82cff56d596 Mon Sep 17 00:00:00 2001 From: Horoshko Vitaliy Date: Fri, 6 Mar 2026 23:00:08 +0200 Subject: [PATCH 1/2] task solution --- README.md | 2 +- src/App.tsx | 272 +++++++++++++++++++++++++-- src/api/todos.ts | 8 + src/components/ErrorNotification.tsx | 32 ++++ src/components/Footer.tsx | 69 +++++++ src/components/Header.tsx | 55 ++++++ src/components/TodoItem.tsx | 123 ++++++++++++ src/components/TodoList.tsx | 86 +++++++++ src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++ 10 files changed, 684 insertions(+), 15 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification.tsx create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index 47a1add059..95b5d340d8 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,4 @@ Implement the ability to edit a todo title on double click: - Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). -- Replace `` with your Github username in the [DEMO LINK](https://.github.io/react_todo-app-with-api/) and add it to the PR description. +- Replace `` with your Github username in the [DEMO LINK](https://VitaliyHoroshko.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..d4ea2ca7b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,270 @@ -/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { getTodos, USER_ID } from './api/todos'; +import { Todo } from './types/Todo'; +import { client } from './utils/fetchClient'; -const USER_ID = 0; +import Header from './components/Header'; +import TodoList from './components/TodoList'; +import Footer from './components/Footer'; +import ErrorNotification from './components/ErrorNotification'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [error, setError] = useState(''); + const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all'); + const [loading, setLoading] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const [updatingIds, setUpdatingIds] = useState([]); + const [newTitle, setNewTitle] = useState(''); + const [editingId, setEditingId] = useState(null); + + const newTodoRef = React.useRef(null); + + useEffect(() => { + if (!USER_ID) { + return; + } + + setError(''); + setLoading(true); + getTodos() + .then(todosFromServer => { + setTimeout(() => setTodos(todosFromServer), 150); + }) + .catch(() => setError('Unable to load todos')) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + if (!loading) { + newTodoRef.current?.focus(); + } + }, [loading]); + + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(''), 3000); + + return () => clearTimeout(timer); + } + }, [error]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = newTitle.trim(); + + if (!trimmed) { + setError('Title should not be empty'); + + return; + } + + const optimisticTodo: Todo = { + id: 0, + title: trimmed, + completed: false, + userId: USER_ID, + }; + + setTempTodo(optimisticTodo); + setLoading(true); + + try { + const addedTodo = await client.post('/todos', { + title: trimmed, + userId: USER_ID, + completed: false, + }); + + setTodos(prev => [...prev, addedTodo]); + setTempTodo(null); + setNewTitle(''); + newTodoRef.current?.focus(); + } catch { + setError('Unable to add a todo'); + setTempTodo(null); + newTodoRef.current?.focus(); + } finally { + setLoading(false); + } + }; + + const deleteTodo = async (id: number) => { + setUpdatingIds(prev => [...prev, id]); + try { + await client.delete(`/todos/${id}`); + setTodos(prev => prev.filter(t => t.id !== id)); + + newTodoRef.current?.focus(); + } catch { + setError('Unable to delete a todo'); + } finally { + setUpdatingIds(prev => prev.filter(i => i !== id)); + } + }; + + const toggleTodo = async (id: number) => { + const activeTodo = todos.find(todo => todo.id === id); + + if (!activeTodo) { + return; + } + + const newStatus = !activeTodo.completed; + + setUpdatingIds(prev => [...prev, id]); + + try { + const updatedTodo = await client.patch(`/todos/${id}`, { + completed: newStatus, + }); + + setTodos(prevTodos => + prevTodos.map(todo => { + return todo.id === id ? updatedTodo : todo; + }), + ); + } catch { + setError('Unable to update a todo'); + } finally { + setUpdatingIds(prev => prev.filter(i => i !== id)); + } + }; + + const updateTodo = async (id: number, title: string) => { + setUpdatingIds(prev => [...prev, id]); + + try { + const updatedTodo = await client.patch(`/todos/${id}`, { title }); + + setTodos(prev => prev.map(todo => (todo.id === id ? updatedTodo : todo))); + setEditingId(null); + } catch { + setError('Unable to update a todo'); + } finally { + setUpdatingIds(prev => prev.filter(i => i !== id)); + } + }; + + const toggleAll = async () => { + const newCompleted = !todos.every(todo => todo.completed); + const filtered = todos.filter(todo => todo.completed !== newCompleted); + const idsToUpdate = filtered.map(todo => todo.id); + + setUpdatingIds(prev => [...prev, ...idsToUpdate]); + + try { + const promises = filtered.map(todo => { + return client.patch(`/todos/${todo.id}`, { + completed: newCompleted, + }); + }); + + const updatedTodos = await Promise.all(promises); + + setTodos(prevTodos => + prevTodos.map(todo => { + const updated = updatedTodos.find( + (u: { id: number }) => u.id === todo.id, + ); + + return updated || todo; + }), + ); + } catch { + setError('Unable to toggle all todos'); + } finally { + setUpdatingIds(prev => prev.filter(id => !idsToUpdate.includes(id))); + } + }; + + const clearCompleted = async () => { + const completedTodos = todos.filter(t => t.completed); + + if (completedTodos.length === 0) { + return; + } + + const ids = completedTodos.map(t => t.id); + + setUpdatingIds(ids); + + const successIds: number[] = []; + let hasError = false; + + await Promise.allSettled( + ids.map(async id => { + try { + await client.delete(`/todos/${id}`); + successIds.push(id); + } catch { + hasError = true; + } + }), + ); + + setTodos(prev => prev.filter(t => !successIds.includes(t.id))); + setUpdatingIds([]); + + if (!hasError) { + newTodoRef.current?.focus(); + } else { + setError('Unable to delete a todo'); + } + }; + + const allCompleted = todos.length > 0 && todos.every(todo => todo.completed); + if (!USER_ID) { return ; } return ( -
-

- Copy all you need from the prev task: -
- - React Todo App - Add and Delete - -

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {(loading || todos.length > 0) && ( + <> + + +
!t.completed).length} + filter={filter} + setFilter={setFilter} + hasCompleted={todos.some(t => t.completed)} + onClearCompleted={clearCompleted} + /> + + )} +
+ + setError('')} /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..7b5c4eda5f --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,8 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 3995; + +export const getTodos = async (): Promise => { + return client.get(`/todos?userId=${USER_ID}`); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..fefceb5c04 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import classNames from 'classnames'; + +type ErrorNotificationProps = { + error: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ + error, + onClose, +}) => { + return ( +
+
+ ); +}; + +export default ErrorNotification; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..0db676c0df --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import classNames from 'classnames'; + +type FooterProps = { + activeCount: number; + filter: 'all' | 'active' | 'completed'; + setFilter: (filter: 'all' | 'active' | 'completed') => void; + hasCompleted: boolean; + onClearCompleted: () => void; +}; + +export const Footer: React.FC = ({ + activeCount, + filter, + setFilter, + hasCompleted, + onClearCompleted, +}) => { + return ( + + ); +}; + +export default Footer; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..ebbac9dcfb --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import classNames from 'classnames'; + +type HeaderProps = { + newTitle: string; + setNewTitle: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + isLoading: boolean; + inputRef: React.RefObject; + todosLength: number; + allCompleted: boolean; + onToggleAll: () => void; +}; + +export const Header: React.FC = ({ + newTitle, + setNewTitle, + onSubmit, + isLoading, + inputRef, + todosLength, + allCompleted, + onToggleAll, +}) => { + return ( +
+ {todosLength > 0 && ( +
+ ); +}; + +export default Header; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..131a433793 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,123 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type TodoItemProps = { + todo: Todo; + isUpdating: boolean; + isEditing: boolean; + onEdit: (id: number | null) => void; + onUpdate: (title: string) => void; + onToggle: () => void; + onDelete: () => void; +}; + +export const TodoItem: React.FC = ({ + todo, + isUpdating, + isEditing, + onEdit, + onUpdate, + onToggle, + onDelete, +}) => { + const [newTitle, setNewTitle] = React.useState(todo.title); + const handleSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + + if (isUpdating) { + return; + } + + const trimmedTitle = newTitle.trim(); + + if (trimmedTitle === todo.title) { + onEdit(null); + + return; + } + + if (!trimmedTitle) { + onDelete(); + + return; + } + + onUpdate(trimmedTitle); + }; + + return ( +
+ + + {!isEditing ? ( + onEdit(todo.id)} + > + {todo.title} + + ) : ( + setNewTitle(e.target.value)} + autoFocus + disabled={isUpdating} + onBlur={handleSubmit} + onKeyUp={e => { + if (e.key === 'Enter') { + handleSubmit(); + } + + if (e.key === 'Escape') { + setNewTitle(todo.title); + onEdit(null); + } + }} + /> + )} + + {!isEditing && ( + + )} + +
+
+
+
+
+ ); +}; + +export default TodoItem; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..d9f1c678c9 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,86 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React from 'react'; +import { Todo } from '../types/Todo'; +import TodoItem from './TodoItem'; + +type TodoListProps = { + todos: Todo[]; + filter: 'all' | 'active' | 'completed'; + tempTodo: Todo | null; + updatingIds: number[]; + onToggle: (id: number) => void; + onDelete: (id: number) => void; + isLoading: boolean; + editingId: number | null; + onEdit: (id: number | null) => void; + onUpdate: (id: number, title: string) => void; +}; + +export const TodoList: React.FC = ({ + todos, + filter, + tempTodo, + updatingIds, + onToggle, + onDelete, + editingId, + onEdit, + onUpdate, +}) => { + const visibleTodos = todos.filter(todo => { + switch (filter) { + case 'active': + return !todo.completed; + case 'completed': + return todo.completed; + default: + return true; + } + }); + + return ( +
+ {visibleTodos.map(todo => ( + onToggle(todo.id)} + onDelete={() => onDelete(todo.id)} + isEditing={todo.id === editingId} + onEdit={onEdit} + onUpdate={(title: string) => onUpdate(todo.id, title)} + /> + ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + + +
+
+
+
+
+ )} +
+ ); +}; + +export default TodoList; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..780afae02d --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; + userId: number; +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..708ac4c17b --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; + +// returns a promise resolved after a given delay +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +// To have autocompletion and avoid mistypes +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, // we can send any data to the server +): Promise { + const options: RequestInit = { method }; + + if (data) { + // We add body and Content-Type only for the requests with data + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + // DON'T change the delay it is required for tests + return wait(100) + .then(() => fetch(BASE_URL + url, options)) + .then(response => { + if (!response.ok) { + throw new Error(); + } + + return response.json(); + }); +} + +export const client = { + get: (url: string) => request(url), + post: (url: string, data: any) => request(url, 'POST', data), + patch: (url: string, data: any) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +}; From 6f00e458870ddf929d4caaf6c7497c0adfb3ea9f Mon Sep 17 00:00:00 2001 From: Horoshko Vitaliy Date: Mon, 9 Mar 2026 06:16:18 +0200 Subject: [PATCH 2/2] task solution #2 --- src/App.tsx | 20 +++++++------- src/components/Footer.tsx | 52 ++++++++++++++++--------------------- src/components/TodoItem.tsx | 22 +++++++++------- src/components/TodoList.tsx | 7 ++--- src/types/ErrorMessage.ts | 8 ++++++ src/types/Filter.ts | 5 ++++ 6 files changed, 62 insertions(+), 52 deletions(-) create mode 100644 src/types/ErrorMessage.ts create mode 100644 src/types/Filter.ts diff --git a/src/App.tsx b/src/App.tsx index d4ea2ca7b5..a399fe5bf7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,11 +10,13 @@ import Header from './components/Header'; import TodoList from './components/TodoList'; import Footer from './components/Footer'; import ErrorNotification from './components/ErrorNotification'; +import { ErrorMessage } from './types/ErrorMessage'; +import { Filter } from './types/Filter'; export const App: React.FC = () => { const [todos, setTodos] = useState([]); const [error, setError] = useState(''); - const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all'); + const [filter, setFilter] = useState(Filter.ALL); const [loading, setLoading] = useState(false); const [tempTodo, setTempTodo] = useState(null); const [updatingIds, setUpdatingIds] = useState([]); @@ -34,7 +36,7 @@ export const App: React.FC = () => { .then(todosFromServer => { setTimeout(() => setTodos(todosFromServer), 150); }) - .catch(() => setError('Unable to load todos')) + .catch(() => setError(ErrorMessage.LOAD_TODOS)) .finally(() => setLoading(false)); }, []); @@ -57,7 +59,7 @@ export const App: React.FC = () => { const trimmed = newTitle.trim(); if (!trimmed) { - setError('Title should not be empty'); + setError(ErrorMessage.EMPTY_TITLE); return; } @@ -84,7 +86,7 @@ export const App: React.FC = () => { setNewTitle(''); newTodoRef.current?.focus(); } catch { - setError('Unable to add a todo'); + setError(ErrorMessage.ADD_TODO); setTempTodo(null); newTodoRef.current?.focus(); } finally { @@ -100,7 +102,7 @@ export const App: React.FC = () => { newTodoRef.current?.focus(); } catch { - setError('Unable to delete a todo'); + setError(ErrorMessage.DELETE_TODO); } finally { setUpdatingIds(prev => prev.filter(i => i !== id)); } @@ -128,7 +130,7 @@ export const App: React.FC = () => { }), ); } catch { - setError('Unable to update a todo'); + setError(ErrorMessage.UPDATE_TODO); } finally { setUpdatingIds(prev => prev.filter(i => i !== id)); } @@ -143,7 +145,7 @@ export const App: React.FC = () => { setTodos(prev => prev.map(todo => (todo.id === id ? updatedTodo : todo))); setEditingId(null); } catch { - setError('Unable to update a todo'); + setError(ErrorMessage.UPDATE_TODO); } finally { setUpdatingIds(prev => prev.filter(i => i !== id)); } @@ -175,7 +177,7 @@ export const App: React.FC = () => { }), ); } catch { - setError('Unable to toggle all todos'); + setError(ErrorMessage.TOGGLE_ALL); } finally { setUpdatingIds(prev => prev.filter(id => !idsToUpdate.includes(id))); } @@ -212,7 +214,7 @@ export const App: React.FC = () => { if (!hasError) { newTodoRef.current?.focus(); } else { - setError('Unable to delete a todo'); + setError(ErrorMessage.DELETE_TODO); } }; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 0db676c0df..810374a49e 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,14 +1,21 @@ import React from 'react'; import classNames from 'classnames'; +import { Filter } from '../types/Filter'; type FooterProps = { activeCount: number; - filter: 'all' | 'active' | 'completed'; - setFilter: (filter: 'all' | 'active' | 'completed') => void; + filter: Filter; + setFilter: (filter: Filter) => void; hasCompleted: boolean; onClearCompleted: () => void; }; +const filters = [ + { value: Filter.ALL, label: 'All', href: '#/' }, + { value: Filter.ACTIVE, label: 'Active', href: '#/active' }, + { value: Filter.COMPLETED, label: 'Completed', href: '#/completed' }, +]; + export const Footer: React.FC = ({ activeCount, filter, @@ -23,34 +30,19 @@ export const Footer: React.FC = ({