From 9dc527182444b13e64df6de93c4aeafbb981d478 Mon Sep 17 00:00:00 2001 From: Vasyl Levitskyi Date: Tue, 12 May 2026 15:41:55 +0300 Subject: [PATCH 01/12] add solution --- src/App.tsx | 331 +++++++++++++++++- src/api/todos.ts | 14 + .../ErrorNotif/ErrorNotification.tsx | 27 ++ src/components/Footer/Footer.tsx | 68 ++++ src/components/Header/Header.tsx | 50 +++ src/components/TodoItem/TodoItem.tsx | 109 ++++++ src/components/TodoList/TodoList.tsx | 47 +++ src/enums/ErrorMessage.ts | 7 + src/enums/Filter.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 45 +++ 11 files changed, 696 insertions(+), 13 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotif/ErrorNotification.tsx create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/enums/ErrorMessage.ts create mode 100644 src/enums/Filter.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..18d0c7fff5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,331 @@ /* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + import { UserWarning } from './UserWarning'; -const USER_ID = 0; +import { + addTodo, + deleteTodo, + getTodos, + updateTodo, + USER_ID, +} from './api/todos'; + +import { Header } from './components/Header/Header'; +import { Footer } from './components/Footer/Footer'; +import { ErrorNotification } from './components/ErrorNotif/ErrorNotification'; +import { TodoList } from './components/TodoList/TodoList'; + +import { ErrorMessage } from './enums/ErrorMessage'; +import { Filter } from './enums/Filter'; + +import { Todo } from './types/Todo'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [processingIds, setProcessingIds] = useState([]); + const [error, setError] = useState(''); + const [title, setTitle] = useState(''); + const [filter, setFilter] = useState(Filter.All); + const [isAdding, setIsAdding] = useState(false); + + const inputRef = useRef(null); + + const loadTodos = async () => { + try { + setError(''); + + const todosFromServer = await getTodos(); + + setTodos(todosFromServer); + } catch { + setError(ErrorMessage.Load); + } + }; + + useEffect(() => { + loadTodos(); + }, []); + + useEffect(() => { + if (!error) { + return; + } + + const timer = setTimeout(() => { + setError(''); + }, 3000); + + return () => { + clearTimeout(timer); + }; + }, [error]); + + useEffect(() => { + if (!isAdding) { + inputRef.current?.focus(); + } + }, [isAdding]); + + const visibleTodos = useMemo(() => { + let filteredTodos = [...todos]; + + if (filter === Filter.Active) { + filteredTodos = filteredTodos.filter(todo => { + return !todo.completed; + }); + } + + if (filter === Filter.Completed) { + filteredTodos = filteredTodos.filter(todo => { + return todo.completed; + }); + } + + return filteredTodos; + }, [todos, filter]); + + const activeCount = todos.filter(todo => { + return !todo.completed; + }).length; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + setError(ErrorMessage.EmptyTitle); + + return; + } + + const newTodo: Omit = { + title: trimmedTitle, + completed: false, + userId: USER_ID, + }; + + setError(''); + setIsAdding(true); + + setTempTodo({ + ...newTodo, + id: 0, + }); + + try { + const createdTodo = await addTodo(newTodo); + + setTodos(currentTodos => { + return [...currentTodos, createdTodo]; + }); + + setTitle(''); + } catch { + setError(ErrorMessage.Add); + } finally { + setTempTodo(null); + setIsAdding(false); + } + }; + + const handleDelete = async (todoId: number) => { + setProcessingIds(currentIds => { + return [...currentIds, todoId]; + }); + + try { + await deleteTodo(todoId); + + setTodos(currentTodos => { + return currentTodos.filter(todo => { + return todo.id !== todoId; + }); + }); + + inputRef.current?.focus(); + } catch { + setError(ErrorMessage.Delete); + } finally { + setProcessingIds(currentIds => { + return currentIds.filter(id => { + return id !== todoId; + }); + }); + } + }; + + const handleToggle = async (todo: Todo) => { + setProcessingIds(currentIds => [...currentIds, todo.id]); + + try { + const updatedTodo = await updateTodo({ + ...todo, + completed: !todo.completed, + }); + + setTodos(currentTodos => + currentTodos.map(currentTodo => { + return currentTodo.id === todo.id ? updatedTodo : currentTodo; + }), + ); + } catch { + setError(ErrorMessage.Update); + } finally { + setProcessingIds(currentIds => currentIds.filter(id => id !== todo.id)); + } + }; + + const handleRename = async ( + todo: Todo, + newTitle: string, + ): Promise => { + const trimmedTitle = newTitle.trim(); + + if (trimmedTitle === todo.title) { + return true; + } + + if (!trimmedTitle) { + await handleDelete(todo.id); + + return true; + } + + setProcessingIds(currentIds => [...currentIds, todo.id]); + + try { + const updatedTodo = await updateTodo({ + ...todo, + title: trimmedTitle, + }); + + setTodos(currentTodos => + currentTodos.map(currentTodo => { + return currentTodo.id === todo.id ? updatedTodo : currentTodo; + }), + ); + + return true; + } catch { + setError(ErrorMessage.Update); + + return false; + } finally { + setProcessingIds(currentIds => currentIds.filter(id => id !== todo.id)); + } + }; + + const handleToggleAll = async () => { + const allCompleted = todos.every(todo => todo.completed); + + const todosToUpdate = todos.filter(todo => { + return todo.completed === allCompleted; + }); + + await Promise.allSettled( + todosToUpdate.map(async todo => { + setProcessingIds(currentIds => [...currentIds, todo.id]); + + try { + const updatedTodo = await updateTodo({ + ...todo, + completed: !allCompleted, + }); + + setTodos(currentTodos => + currentTodos.map(currentTodo => { + return currentTodo.id === todo.id ? updatedTodo : currentTodo; + }), + ); + } catch { + setError(ErrorMessage.Update); + } finally { + setProcessingIds(currentIds => + currentIds.filter(id => id !== todo.id), + ); + } + }), + ); + }; + + const handleClearCompleted = async () => { + const completedTodos = todos.filter(todo => { + return todo.completed; + }); + + await Promise.allSettled( + completedTodos.map(async todo => { + setProcessingIds(currentIds => { + return [...currentIds, todo.id]; + }); + + try { + await deleteTodo(todo.id); + + setTodos(currentTodos => { + return currentTodos.filter(currentTodo => { + return currentTodo.id !== todo.id; + }); + }); + } catch { + setError(ErrorMessage.Delete); + } finally { + setProcessingIds(currentIds => { + return currentIds.filter(id => { + return id !== todo.id; + }); + }); + } + }), + ); + + inputRef.current?.focus(); + }; + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {todos.length > 0 && ( +
+ )} +
+ + setError('')} /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..abaffc7762 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,14 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1; + +export const getTodos = () => client.get(`/todos?userId=${USER_ID}`); + +export const addTodo = (todo: Omit) => + client.post('/todos', todo); + +export const deleteTodo = (id: number) => client.delete(`/todos/${id}`); + +export const updateTodo = (todo: Todo) => + client.patch(`/todos/${todo.id}`, todo); diff --git a/src/components/ErrorNotif/ErrorNotification.tsx b/src/components/ErrorNotif/ErrorNotification.tsx new file mode 100644 index 0000000000..cddad47c8e --- /dev/null +++ b/src/components/ErrorNotif/ErrorNotification.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import cn from 'classnames'; + +type Props = { + error: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ error, onClose }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..82be69800c --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import cn from 'classnames'; +import { Filter } from '../../enums/Filter'; +import { Todo } from '../../types/Todo'; + +type Props = { + todos: Todo[]; + activeCount: number; + filter: Filter; + setFilter: (filter: Filter) => void; + onClearCompleted: () => void; +}; + +const filters = [ + { label: 'All', value: Filter.All, href: '#/', cy: 'FilterLinkAll' }, + { + label: 'Active', + value: Filter.Active, + href: '#/active', + cy: 'FilterLinkActive', + }, + { + label: 'Completed', + value: Filter.Completed, + href: '#/completed', + cy: 'FilterLinkCompleted', + }, +]; + +export const Footer: React.FC = ({ + todos, + activeCount, + filter, + setFilter, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..0663dba579 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import cn from 'classnames'; +import { Todo } from '../../types/Todo'; + +type Props = { + title: string; + onChange: (value: string) => void; + onSubmit: (event: React.FormEvent) => void; + disabled: boolean; + inputRef: React.RefObject; + todos: Todo[]; + onToggleAll: () => void; +}; + +export const Header: React.FC = ({ + title, + onChange, + onSubmit, + disabled, + inputRef, + todos, + onToggleAll, +}) => ( +
+ {todos.length > 0 && ( +
+); diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..2803a381fd --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,109 @@ +import cn from 'classnames'; +import { Todo } from '../../types/Todo'; +import React, { useEffect, useRef, useState } from 'react'; + +type Props = { + todo: Todo; + isProcessing: boolean; + onDelete: (id: number) => void; + onToggle: (todo: Todo) => void; + onRename: (todo: Todo, title: string) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + isProcessing, + onDelete, + onToggle, + onRename, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(todo.title); + + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + } + }, [isEditing]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + await onRename(todo, editedTitle); + + setIsEditing(false); + }; + + const handleBlur = async () => { + await onRename(todo, editedTitle); + + setIsEditing(false); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditedTitle(todo.title); + setIsEditing(false); + } + }; + + return ( +
+ onToggle(todo)} + /> + + {isEditing ? ( +
+ setEditedTitle(event.target.value)} + onBlur={handleBlur} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..3fd5cfb1b9 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + processingIds: number[]; + onDelete: (id: number) => void; + onToggle: (todo: Todo) => void; + onRename: (todo: Todo, title: string) => Promise; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + processingIds, + onDelete, + onToggle, + onRename, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/enums/ErrorMessage.ts b/src/enums/ErrorMessage.ts new file mode 100644 index 0000000000..9d4470f4b0 --- /dev/null +++ b/src/enums/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + Load = 'Unable to load todos', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', + EmptyTitle = 'Title should not be empty', +} diff --git a/src/enums/Filter.ts b/src/enums/Filter.ts new file mode 100644 index 0000000000..174408fd69 --- /dev/null +++ b/src/enums/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..3f52a5fdde --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..73d8ce74cc --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,45 @@ +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: unknown = null, +): 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: unknown) => request(url, 'POST', data), + patch: (url: string, data: unknown) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +}; From 8c1f906055d8c7a872355a602fdb3203395b7cd5 Mon Sep 17 00:00:00 2001 From: Vasyl Levitskyi Date: Tue, 12 May 2026 16:03:53 +0300 Subject: [PATCH 02/12] add solution --- src/components/TodoItem/TodoItem.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index 2803a381fd..b2f0837dda 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -31,15 +31,19 @@ export const TodoItem: React.FC = ({ const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - await onRename(todo, editedTitle); + const wasRenamed = await onRename(todo, editedTitle); - setIsEditing(false); + if (wasRenamed) { + setIsEditing(false); + } }; const handleBlur = async () => { - await onRename(todo, editedTitle); + const wasRenamed = await onRename(todo, editedTitle); - setIsEditing(false); + if (wasRenamed) { + setIsEditing(false); + } }; const handleKeyUp = (event: React.KeyboardEvent) => { From 759361079bfdbd4b1004f9dbd8655e57a764af5f Mon Sep 17 00:00:00 2001 From: Vasyl Levitskyi Date: Tue, 12 May 2026 16:27:17 +0300 Subject: [PATCH 03/12] add solution --- src/App.tsx | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 18d0c7fff5..38aeaed99d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -132,28 +132,24 @@ export const App: React.FC = () => { } }; - const handleDelete = async (todoId: number) => { - setProcessingIds(currentIds => { - return [...currentIds, todoId]; - }); + const handleDelete = async (id: number): Promise => { + setProcessingIds(currentIds => [...currentIds, id]); try { - await deleteTodo(todoId); + await deleteTodo(id); setTodos(currentTodos => { - return currentTodos.filter(todo => { - return todo.id !== todoId; - }); + return currentTodos.filter(todo => todo.id !== id); }); - inputRef.current?.focus(); + return true; } catch { setError(ErrorMessage.Delete); + + return false; } finally { setProcessingIds(currentIds => { - return currentIds.filter(id => { - return id !== todoId; - }); + return currentIds.filter(currentId => currentId !== id); }); } }; @@ -190,9 +186,7 @@ export const App: React.FC = () => { } if (!trimmedTitle) { - await handleDelete(todo.id); - - return true; + return handleDelete(todo.id); } setProcessingIds(currentIds => [...currentIds, todo.id]); From 9f430fa4b63ea6f0c84c8122f12d229531daf648 Mon Sep 17 00:00:00 2001 From: Vasyl Levitskyi Date: Tue, 12 May 2026 16:34:33 +0300 Subject: [PATCH 04/12] add solution --- src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 38aeaed99d..a968b1da07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -142,6 +142,8 @@ export const App: React.FC = () => { return currentTodos.filter(todo => todo.id !== id); }); + inputRef.current?.focus(); + return true; } catch { setError(ErrorMessage.Delete); From 06772032d55a7f72507ce8d1c354ebc1b4efa57d Mon Sep 17 00:00:00 2001 From: Vasyl Levitskyi Date: Wed, 13 May 2026 09:52:52 +0300 Subject: [PATCH 05/12] add solution --- src/components/TodoItem/TodoItem.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index b2f0837dda..278d8a176c 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -56,6 +56,7 @@ export const TodoItem: React.FC = ({ return (
= ({ disabled={isProcessing} onChange={() => onToggle(todo)} /> + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */} +