From 0ebc5322fd11bc617398f0c98fe268c781623ee1 Mon Sep 17 00:00:00 2001 From: Artem Nosachenko Date: Tue, 16 Jun 2026 18:10:08 +0200 Subject: [PATCH 1/3] add task P3 --- README.md | 2 +- src/App.tsx | 432 +++++++++++++++++++++++++++++++++++++-- src/README.md | 155 ++++++++++++++ src/api/todos.ts | 20 ++ src/types/FilterType.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++ 7 files changed, 651 insertions(+), 15 deletions(-) create mode 100644 src/README.md create mode 100644 src/api/todos.ts create mode 100644 src/types/FilterType.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index 47a1add059..8c4f490b61 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://ArtemNosachenko.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..5000629d75 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,430 @@ -/* 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 { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { + USER_ID, + getTodos, + addTodo, + deleteTodo, + updateTodo, +} from './api/todos'; +import { Todo } from './types/Todo'; +import classNames from 'classnames'; +import { FilterType } from './types/FilterType'; export const App: React.FC = () => { + const [todos, setTodos] = React.useState([]); + const [errorMessage, setErrorMessage] = React.useState(''); + const [filter, setFilter] = React.useState(FilterType.All); + const activeTodos = todos.filter(todo => !todo.completed); + const visibleTodos = todos.filter(todo => { + switch (filter) { + case FilterType.Active: + return !todo.completed; + + case FilterType.Completed: + return todo.completed; + + default: + return true; + } + }); + const [query, setQuery] = React.useState(''); + const inputRef = React.useRef(null); + const [tempTodo, setTempTodo] = React.useState(null); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [loadingTodoIds, setLoadingTodoIds] = React.useState([]); + const [editingTodoId, setEditingTodoId] = React.useState(null); + const [editingTitle, setEditingTitle] = React.useState(''); + const [, setIsTodosLoading] = React.useState(true); + + React.useEffect(() => { + setIsTodosLoading(true); + + getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage('Unable to load todos'); + }) + .finally(() => { + setIsTodosLoading(false); + }); + }, []); + + React.useEffect(() => { + if (!errorMessage) { + return; + } + + const timer = setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return () => clearTimeout(timer); + }, [errorMessage]); + + React.useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = query.trim(); + + if (!trimmedTitle) { + setErrorMessage('Title should not be empty'); + + return; + } + + const temporaryTodo: Todo = { + id: 0, + title: trimmedTitle, + completed: false, + userId: USER_ID, + }; + + setTempTodo(temporaryTodo); + setIsSubmitting(true); + + try { + const createdTodo = await addTodo({ + title: trimmedTitle, + completed: false, + userId: USER_ID, + }); + + setTodos(current => [...current, createdTodo]); + setQuery(''); + } catch { + setErrorMessage('Unable to add a todo'); + } finally { + setTempTodo(null); + setIsSubmitting(false); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + }; + + const preparedTodos = tempTodo ? [...visibleTodos, tempTodo] : visibleTodos; + const completedTodos = todos.filter(todo => todo.completed); + + const handleDelete = async (id: number) => { + setLoadingTodoIds(prev => [...prev, id]); + + try { + await deleteTodo(id); + + setTodos(current => current.filter(todo => todo.id !== id)); + } catch { + setErrorMessage('Unable to delete a todo'); + } finally { + setLoadingTodoIds(prev => prev.filter(todoId => todoId !== id)); + + inputRef.current?.focus(); + } + }; + + const handleClearCompleted = async () => { + const completed = todos.filter(todo => todo.completed); + + const promises = completed.map(todo => { + setLoadingTodoIds(prev => [...prev, todo.id]); + + return deleteTodo(todo.id) + .then(() => { + setTodos(current => current.filter(t => t.id !== todo.id)); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }) + .finally(() => { + setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); + }); + }); + + await Promise.allSettled(promises); + + inputRef.current?.focus(); + }; + + const handleToggle = async (todo: Todo) => { + setLoadingTodoIds(prev => [...prev, todo.id]); + + try { + const updatedTodo = await updateTodo(todo.id, { + completed: !todo.completed, + }); + + setTodos(current => + current.map(t => (t.id === todo.id ? updatedTodo : t)), + ); + } catch { + setErrorMessage('Unable to update a todo'); + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + const handleToggleAll = async () => { + const newStatus = activeTodos.length > 0; + + const todosToUpdate = todos.filter(todo => todo.completed !== newStatus); + + await Promise.all( + todosToUpdate.map(async todo => { + setLoadingTodoIds(prev => [...prev, todo.id]); + + try { + const updatedTodo = await updateTodo(todo.id, { + completed: newStatus, + }); + + setTodos(current => + current.map(t => (t.id === todo.id ? updatedTodo : t)), + ); + } catch { + setErrorMessage('Unable to update a todo'); + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); + } + }), + ); + }; + + const handleRename = async (todo: Todo) => { + const trimmedTitle = editingTitle.trim(); + + if (trimmedTitle === todo.title) { + setEditingTodoId(null); + + return; + } + + if (!trimmedTitle) { + handleDelete(todo.id); + + return; + } + + setLoadingTodoIds(prev => [...prev, todo.id]); + + try { + const updatedTodo = await updateTodo(todo.id, { + title: trimmedTitle, + }); + + setTodos(current => + current.map(t => (t.id === todo.id ? updatedTodo : t)), + ); + + setEditingTodoId(null); + } catch { + setErrorMessage('Unable to update a todo'); + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); + } + }; + 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 && ( +
+ +
+ {preparedTodos.map(todo => { + const isLoading = todo.id === 0 || loadingTodoIds.includes(todo.id); + + return ( +
+ + + {editingTodoId === todo.id ? ( +
{ + e.preventDefault(); + handleRename(todo); + }} + > + setEditingTitle(e.target.value)} + onBlur={() => handleRename(todo)} + onKeyUp={e => { + if (e.key === 'Escape') { + setEditingTodoId(null); + } + }} + autoFocus + /> +
+ ) : ( + { + setEditingTodoId(todo.id); + setEditingTitle(todo.title); + }} + > + {todo.title} + + )} + + {editingTodoId !== todo.id && ( + + )} + +
+
+
+
+ ); + })} +
+ + {/* Hide the footer if there are no todos */} + {todos.length > 0 && ( + + )} +
+ + {/* DON'T use conditional rendering to hide the notification */} + {/* Add the 'hidden' class to hide the message smoothly */} +
+
+
); }; diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000000..c9ceb4a997 --- /dev/null +++ b/src/README.md @@ -0,0 +1,155 @@ +# React Todo App Add and Delete + +It is the second part of the React Todo App with API. + +Take your code implemented for [Loading todos](https://github.com/mate-academy/react_todo-app-loading-todos) +and implement the ability to add and remove todos. + +> Here is [the working example](https://mate-academy.github.io/react_todo-app-with-api/) +# ❗️❗️❗️
Please implement only adding and deleting todos in addition to what was already implemented.

All the other features from the working version will be implemented in the next task.
❗️❗️❗️ + +> Check the [API Documentation](https://mate-academy.github.io/fe-students-api/) + +## Adding a todo + +Add a todo with the entered title on the form submit: + +- text field should be focused by default; +- if the title is empty show the `Title should not be empty` notification at the bottom; +- trim the title when checked or saved; +- use your `userId` for the new todo; +- send a POST request to the API (check the [API Documentation](https://mate-academy.github.io/fe-students-api/)) +- disable the input until receiving a response from the API; +- immediately after sending a request create a todo with `id: 0` and save it to the `tempTodo` variable in the state (NOT to the `todos` array); +- show an independent `TodoItem` **after** the list if `tempTodo` is not `null`; +- temp TodoItem should have the loader (check the original markup); +- in case of success add the todo created by the API to the array (take it from the POST response); +- in case of an API error showing `Unable to add a todo` notification at the bottom; +- set `tempTodo` to `null` to hide the extra `TodoItem`; +- focus the text field after receiving a response; +- clear the text in case of success; +- keep the text in case of error; + +> Don't try to implement animations for adding or removing Todos (at least until you finish everything else). +> If you really feel confident to try, there is a hint at the end of the description. + +## Deleting todos + +Remove a todo on the `TodoDeleteButton` click: + +- covered the todo with the loader while waiting for the API response; +- remove the todo from the list on success; +- in case of API error show `Unable to delete a todo` notification at the bottom (the todo must stay in the list); + +Remove all the completed todos after the `Clear completed` button click: + +- the button should be enabled only if there is at least 1 completed todo; +- the deletion should work as several individual deletions running at the same time; +- in case of any error show error message but process success deletions; + +## If you want to enable tests +- open `cypress/integration/page.spec.js` +- replace `describe.skip` with `describe` for the root `describe` + +## Instructions +- Install Prettier Extention and use this [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. +- 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://ArtemNosachenko.github.io/react_todo-app-add-and-delete/) and add it to the PR description. + +## IF you want to implement smooth animations + +
+ Click here to see the hint + + Use [React Transition Group](https://reactcommunity.org/react-transition-group/transition-group) + + ```tsx +
+ + {visibleTodos.map(todo => ( + + deleteTodo(todo.id)} + onUpdate={updateTodo} + /> + + ))} + + {creating && ( + + + + )} + +
+ ``` + + Here are the styles used in this example + ```css + .item-enter { + max-height: 0; + } + + .item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; + } + + .item-exit { + max-height: 58px; + } + + .item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; + } + + .temp-item-enter { + max-height: 0; + } + + .temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; + } + + .temp-item-exit { + max-height: 58px; + } + + .temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; + } + + .has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; + } + ``` +
diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..879fbb8525 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,20 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4303; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (todo: Omit) => { + return client.post('/todos', todo); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (todoId: number, data: Partial) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/types/FilterType.ts b/src/types/FilterType.ts new file mode 100644 index 0000000000..5b306c57d0 --- /dev/null +++ b/src/types/FilterType.ts @@ -0,0 +1,5 @@ +export enum FilterType { + 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..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 faeb4f83c3c06fdc3b096350e0bc7c523bb0505e Mon Sep 17 00:00:00 2001 From: Artem Nosachenko Date: Tue, 23 Jun 2026 17:52:44 +0200 Subject: [PATCH 2/3] add task correction --- src/App.tsx | 126 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 53 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5000629d75..9ce0381ab5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,14 @@ import { Todo } from './types/Todo'; import classNames from 'classnames'; import { FilterType } from './types/FilterType'; +export enum ErrorMessage { + LOAD_TODOS = 'Unable to load todos', + ADD_TODO = 'Unable to add a todo', + DELETE_TODO = 'Unable to delete a todo', + UPDATE_TODO = 'Unable to update a todo', + EMPTY_TITLE = 'Title should not be empty', +} + export const App: React.FC = () => { const [todos, setTodos] = React.useState([]); const [errorMessage, setErrorMessage] = React.useState(''); @@ -30,6 +38,7 @@ export const App: React.FC = () => { return true; } }); + const [query, setQuery] = React.useState(''); const inputRef = React.useRef(null); const [tempTodo, setTempTodo] = React.useState(null); @@ -45,7 +54,7 @@ export const App: React.FC = () => { getTodos() .then(setTodos) .catch(() => { - setErrorMessage('Unable to load todos'); + setErrorMessage(ErrorMessage.LOAD_TODOS); }) .finally(() => { setIsTodosLoading(false); @@ -74,7 +83,7 @@ export const App: React.FC = () => { const trimmedTitle = query.trim(); if (!trimmedTitle) { - setErrorMessage('Title should not be empty'); + setErrorMessage(ErrorMessage.EMPTY_TITLE); return; } @@ -99,7 +108,7 @@ export const App: React.FC = () => { setTodos(current => [...current, createdTodo]); setQuery(''); } catch { - setErrorMessage('Unable to add a todo'); + setErrorMessage(ErrorMessage.ADD_TODO); } finally { setTempTodo(null); setIsSubmitting(false); @@ -120,7 +129,7 @@ export const App: React.FC = () => { setTodos(current => current.filter(todo => todo.id !== id)); } catch { - setErrorMessage('Unable to delete a todo'); + setErrorMessage(ErrorMessage.DELETE_TODO); } finally { setLoadingTodoIds(prev => prev.filter(todoId => todoId !== id)); @@ -139,7 +148,7 @@ export const App: React.FC = () => { setTodos(current => current.filter(t => t.id !== todo.id)); }) .catch(() => { - setErrorMessage('Unable to delete a todo'); + setErrorMessage(ErrorMessage.DELETE_TODO); }) .finally(() => { setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); @@ -163,7 +172,7 @@ export const App: React.FC = () => { current.map(t => (t.id === todo.id ? updatedTodo : t)), ); } catch { - setErrorMessage('Unable to update a todo'); + setErrorMessage(ErrorMessage.UPDATE_TODO); } finally { setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); } @@ -187,7 +196,7 @@ export const App: React.FC = () => { current.map(t => (t.id === todo.id ? updatedTodo : t)), ); } catch { - setErrorMessage('Unable to update a todo'); + setErrorMessage(ErrorMessage.UPDATE_TODO); } finally { setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); } @@ -223,7 +232,7 @@ export const App: React.FC = () => { setEditingTodoId(null); } catch { - setErrorMessage('Unable to update a todo'); + setErrorMessage(ErrorMessage.UPDATE_TODO); } finally { setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); } @@ -233,6 +242,46 @@ export const App: React.FC = () => { return ; } + const handleRenameSubmit = ( + event: React.FormEvent, + todo: Todo, + ) => { + event.preventDefault(); + handleRename(todo); + }; + + const handleEditKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditingTodoId(null); + } + }; + + const handleStartEditing = (todo: Todo) => { + setEditingTodoId(todo.id); + setEditingTitle(todo.title); + }; + + const FILTERS = [ + { + label: 'All', + value: FilterType.All, + href: '#/', + dataCy: 'FilterLinkAll', + }, + { + label: 'Active', + value: FilterType.Active, + href: '#/active', + dataCy: 'FilterLinkActive', + }, + { + label: 'Completed', + value: FilterType.Completed, + href: '#/completed', + dataCy: 'FilterLinkCompleted', + }, + ]; + return (

todos

@@ -288,12 +337,7 @@ export const App: React.FC = () => { {editingTodoId === todo.id ? ( -
{ - e.preventDefault(); - handleRename(todo); - }} - > + handleRenameSubmit(e, todo)}> { value={editingTitle} onChange={e => setEditingTitle(e.target.value)} onBlur={() => handleRename(todo)} - onKeyUp={e => { - if (e.key === 'Escape') { - setEditingTodoId(null); - } - }} + onKeyUp={handleEditKeyUp} autoFocus />
@@ -314,8 +354,7 @@ export const App: React.FC = () => { data-cy="TodoTitle" className="todo__title" onDoubleClick={() => { - setEditingTodoId(todo.id); - setEditingTitle(todo.title); + handleStartEditing(todo); }} > {todo.title} @@ -355,38 +394,19 @@ export const App: React.FC = () => { - +
)}
- {/* DON'T use conditional rendering to hide the notification */} - {/* Add the 'hidden' class to hide the message smoothly */} -
-
+ setErrorMessage('')} + /> ); }; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..d40b7d7bb2 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,35 @@ +import classNames from 'classnames'; + +type Props = { + errorMessage: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + onClose, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..abe5e673a6 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import classNames from 'classnames'; +import { FilterType } from '../types/FilterType'; + +type Props = { + activeCount: number; + completedCount: number; + filter: FilterType; + onFilterChange: (filter: FilterType) => void; + onClearCompleted: () => void; +}; + +export const Footer: React.FC = ({ + activeCount, + completedCount, + filter, + onFilterChange, + onClearCompleted, +}) => { + const FILTERS = [ + { + label: 'All', + value: FilterType.All, + href: '#/', + dataCy: 'FilterLinkAll', + }, + { + label: 'Active', + value: FilterType.Active, + href: '#/active', + dataCy: 'FilterLinkActive', + }, + { + label: 'Completed', + value: FilterType.Completed, + href: '#/completed', + dataCy: 'FilterLinkCompleted', + }, + ]; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..bba1d9621e --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + hasTodos: boolean; + allCompleted: boolean; + query: string; + isSubmitting: boolean; + inputRef: React.RefObject; + onSubmit: (event: React.FormEvent) => void; + onQueryChange: (value: string) => void; + onToggleAll: () => void; +}; + +export const Header: React.FC = ({ + hasTodos, + allCompleted, + query, + isSubmitting, + inputRef, + onSubmit, + onQueryChange, + onToggleAll, +}) => { + return ( +
+ {hasTodos && ( +
+ ); +};