From a2ab6b258708d24a3793ed8c566baed16260a718 Mon Sep 17 00:00:00 2001 From: Dmytro Yakovchuk Date: Sun, 28 Jun 2026 17:57:33 +0300 Subject: [PATCH] react_todo-app-with-api --- README.md | 2 +- package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 277 +++++++++++++++++++++++++-- src/api/todos.ts | 29 +++ src/api/types/Filters.ts | 5 + src/api/types/Todo.ts | 6 + src/api/types/utils/fetchClient.ts | 46 +++++ src/components/ErrorNotification.tsx | 21 ++ src/components/Footer.tsx | 65 +++++++ src/components/Header.tsx | 53 +++++ src/components/TempTodo.tsx | 22 +++ src/components/TodoForm.tsx | 22 +++ src/components/TodoItem.tsx | 130 +++++++++++++ src/components/TodoList.tsx | 45 +++++ 15 files changed, 711 insertions(+), 23 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/api/types/Filters.ts create mode 100644 src/api/types/Todo.ts create mode 100644 src/api/types/utils/fetchClient.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/TempTodo.tsx create mode 100644 src/components/TodoForm.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx diff --git a/README.md b/README.md index 47a1add059..9254ef5af7 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://DmytroYakovchuk.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index 19701e8788..0dbc74a8e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1183,10 +1183,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index b6062525ab..6d0f20adcc 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..833a53331f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,269 @@ /* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { ErrorNotification } from './components/ErrorNotification'; +import { TodoList } from './components/TodoList'; +import { Filter } from './api/types/Filters'; +import { TempTodo } from './components/TempTodo'; +import { updateTodo } from './api/todos'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; -const USER_ID = 0; +import { Todo } from './api/types/Todo'; +import { addTodo, deleteTodo, getTodos, USER_ID } from './api/todos'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(Filter.All); + const [newTitle, setNewTitle] = useState(''); + const [isAdding, setIsAdding] = useState(false); + + const [tempTodo, setTempTodo] = useState(null); + const [deletingIds, setDeletingIds] = useState([]); + const [loadingIds, setLoadingIds] = useState([]); + const inputRef = useRef(null); + const [editingTodoId, setEditingTodoId] = useState(null); + + const showError = (message: string) => { + setError(message); + + setTimeout(() => { + setError(null); + }, 3000); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + showError('Title should not be empty'); + + return; + } + + setIsAdding(true); + + const newTodo = { + userId: USER_ID, + title: trimmedTitle, + completed: false, + }; + + setTempTodo({ + id: 0, + ...newTodo, + }); + + const promise = addTodo(newTodo); + + try { + const createdTodo = await promise; + + setTodos(prev => [...prev, createdTodo]); + setNewTitle(''); + } catch { + showError('Unable to add a todo'); + } finally { + setTempTodo(null); + setIsAdding(false); + inputRef.current?.focus(); + } + }; + + const loadTodos = useCallback(async () => { + try { + const data = await getTodos(); + + setTodos(data); + } catch { + showError('Unable to load todos'); + } + }, []); + + useEffect(() => { + loadTodos(); + }, [loadTodos]); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleToggle = async (id: number) => { + const todo = todos.find(item => item.id === id); + + if (!todo) { + return; + } + + setLoadingIds(prev => [...prev, id]); + + try { + const updatedTodo = await updateTodo(id, { + completed: !todo.completed, + }); + + setTodos(prev => prev.map(item => (item.id === id ? updatedTodo : item))); + } catch { + showError('Unable to update a todo'); + } finally { + setLoadingIds(prev => prev.filter(itemId => itemId !== id)); + } + }; + + const handleToggleAll = async () => { + const allCompleted = + todos.length > 0 && todos.every(todo => todo.completed); + + const targetCompleted = !allCompleted; + + const todosToUpdate = todos.filter( + todo => todo.completed !== targetCompleted, + ); + + const idsToUpdate = todosToUpdate.map(todo => todo.id); + + setLoadingIds(prev => [...prev, ...idsToUpdate]); + + try { + const updatedTodos = await Promise.all( + todosToUpdate.map(todo => + updateTodo(todo.id, { + completed: targetCompleted, + }), + ), + ); + + setTodos(current => + current.map(todo => { + const updated = updatedTodos.find(item => item.id === todo.id); + + return updated || todo; + }), + ); + } catch { + showError('Unable to update a todo'); + } finally { + setLoadingIds(prev => prev.filter(id => !idsToUpdate.includes(id))); + } + }; + + const handleRename = async (id: number, title: string) => { + setLoadingIds(prev => [...prev, id]); + + try { + const updatedTodo = await updateTodo(id, { title }); + + setTodos(prev => prev.map(todo => (todo.id === id ? updatedTodo : todo))); + + setEditingTodoId(null); + } catch { + showError('Unable to update a todo'); + } finally { + setLoadingIds(prev => prev.filter(item => item !== id)); + } + }; + + const handleDelete = async (id: number) => { + try { + setDeletingIds(prev => [...prev, id]); + + await deleteTodo(id); + + setTodos(prev => prev.filter(todo => todo.id !== id)); + } catch { + showError('Unable to delete a todo'); + } finally { + setDeletingIds(prev => prev.filter(item => item !== id)); + inputRef.current?.focus(); + } + }; + + const handleClearCompleted = async () => { + const completed = todos.filter(todo => todo.completed); + + const results = await Promise.allSettled( + completed.map(todo => deleteTodo(todo.id)), + ); + + const successIds = completed + .filter((_, i) => results[i].status === 'fulfilled') + .map(todo => todo.id); + + const hasError = results.some(r => r.status === 'rejected'); + + setTodos(prev => prev.filter(todo => !successIds.includes(todo.id))); + + if (hasError) { + showError('Unable to delete a todo'); + } + + inputRef.current?.focus(); + }; + + const visibleTodos = todos.filter(todo => { + switch (filter) { + case Filter.Active: + return !todo.completed; + + case Filter.Completed: + return todo.completed; + + case Filter.All: + default: + return true; + } + }); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
0} + allCompleted={todos.every(t => t.completed)} + onToggleAll={handleToggleAll} + /> + + {(todos.length > 0 || tempTodo) && ( +
+ setEditingTodoId(null)} + editingTodoId={editingTodoId} + /> + + {tempTodo && } +
+ )} + + {todos.length > 0 && ( +
+ )} +
+ + setError(null)} /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..c70a8084e8 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,29 @@ +import { Todo } from './types/Todo'; +import { client } from '../api/types/utils/fetchClient'; + +export const USER_ID = 1; + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export const getTodos = async () => { + await wait(150); + + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = async (todo: Omit) => { + return client.post('/todos', todo); +}; + +export const deleteTodo = async (id: number) => { + await wait(150); + + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = async (id: number, data: Partial) => { + await wait(150); + + return client.patch(`/todos/${id}`, data); +}; +// Add more methods here diff --git a/src/api/types/Filters.ts b/src/api/types/Filters.ts new file mode 100644 index 0000000000..174408fd69 --- /dev/null +++ b/src/api/types/Filters.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/api/types/Todo.ts b/src/api/types/Todo.ts new file mode 100644 index 0000000000..3f52a5fdde --- /dev/null +++ b/src/api/types/Todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} diff --git a/src/api/types/utils/fetchClient.ts b/src/api/types/utils/fetchClient.ts new file mode 100644 index 0000000000..708ac4c17b --- /dev/null +++ b/src/api/types/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'), +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..ffafda99c1 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,21 @@ +type Props = { + error: string | null; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ error, onClose }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..c1925bf129 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,65 @@ +import { Todo } from '../api/types/Todo'; +import { Filter } from '../api/types/Filters'; + +type Props = { + todos: Todo[]; + filter: Filter; + setFilter: (value: Filter) => void; + onClearCompleted: () => void; +}; + +export const Footer: React.FC = ({ + todos, + filter, + setFilter, + onClearCompleted, +}) => { + const activeCount = todos.filter(t => !t.completed).length; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..21c0844f52 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useEffect } from 'react'; + +type Props = { + newTitle: string; + setNewTitle: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + isAdding: boolean; + allCompleted: boolean; + hasTodos: boolean; + onToggleAll: () => void; + inputRef: React.RefObject; +}; + +export const Header: React.FC = ({ + newTitle, + setNewTitle, + onSubmit, + isAdding, + allCompleted, + hasTodos, + onToggleAll, + inputRef, +}) => { + useEffect(() => { + inputRef.current?.focus(); + }, [isAdding, inputRef]); + + return ( +
+ {hasTodos && ( +
+ ); +}; diff --git a/src/components/TempTodo.tsx b/src/components/TempTodo.tsx new file mode 100644 index 0000000000..7483d9dd5e --- /dev/null +++ b/src/components/TempTodo.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +type Props = { + todo: { + title: string; + }; +}; + +export const TempTodo: React.FC = ({ todo }) => { + return ( +
+ + {todo.title} + + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoForm.tsx b/src/components/TodoForm.tsx new file mode 100644 index 0000000000..566d53320e --- /dev/null +++ b/src/components/TodoForm.tsx @@ -0,0 +1,22 @@ +import React, { useState } from 'react'; + +type Props = { + onAdd: (title: string) => void; +}; + +export const TodoForm: React.FC = ({ onAdd }) => { + const [title, setTitle] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onAdd(title); + setTitle(''); + }; + + return ( +
+ setTitle(e.target.value)} /> + +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..85f838536c --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,130 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../api/types/Todo'; + +type Props = { + todo: Todo; + isDeleting: boolean; + isLoading: boolean; + isEditing: boolean; + onStartEditing: () => void; + onCancelEditing: () => void; + onRename: (id: number, title: string) => void; + onDelete: (id: number) => void; + onToggle: (id: number) => void; +}; + +export const TodoItem: React.FC = ({ + todo, + isDeleting, + isLoading, + isEditing, + onRename, + onStartEditing, + onCancelEditing, + onDelete, + onToggle, +}) => { + const [editedTitle, setEditedTitle] = useState(todo.title); + const titleFieldRef = useRef(null); + + useEffect(() => { + if (isEditing) { + titleFieldRef.current?.focus(); + } + }, [isEditing]); + + return ( + // {loadingIds.includes(todo.id) && } +
+ + + {isEditing ? ( + setEditedTitle(event.target.value)} + onBlur={() => { + const newTitle = editedTitle.trim(); + + if (newTitle === todo.title) { + onCancelEditing(); + return; + } + + if (!newTitle) { + onDelete(todo.id); + return; + } + + onRename(todo.id, newTitle); + }} + onKeyUp={event => { + if (event.key === 'Escape') { + onCancelEditing(); + return; + } + + if (event.key === 'Enter') { + const newTitle = editedTitle.trim(); + + if (newTitle === todo.title) { + onCancelEditing(); + return; + } + + if (!newTitle) { + onDelete(todo.id); + return; + } + + onRename(todo.id, newTitle); + } + }} + /> + ) : ( + <> + + {todo.title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..cfb3d9ec41 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,45 @@ +import { Todo } from '../api/types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + deletingIds: number[]; + loadingIds: number[]; + editingTodoId: number | null; + onStartEditing: (id: number) => void; + onDelete: (id: number) => void; + onToggle: (id: number) => void; + onCancelEditing: () => void; + onRename: (id: number, title: string) => void; +}; + +export const TodoList: React.FC = ({ + todos, + deletingIds, + loadingIds, + editingTodoId, + onStartEditing, + onRename, + onCancelEditing, + onDelete, + onToggle, +}) => { + return ( + <> + {todos.map(todo => ( + onStartEditing(todo.id)} + onCancelEditing={onCancelEditing} + /> + ))} + + ); +};