From 4320ecbf2870a1022d05f1ad86be343467ee6ca1 Mon Sep 17 00:00:00 2001 From: Mariia Madarash Date: Thu, 25 Jun 2026 17:19:22 +0200 Subject: [PATCH 1/3] Solution --- cypress/integration/page.spec.js | 6 +- package-lock.json | 26 ++- package.json | 3 +- src/App.tsx | 267 +++++++++++++++++++++++++-- src/api/todos.ts | 24 +++ src/components/Error/Error.tsx | 31 ++++ src/components/Error/index.tsx | 1 + src/components/Footer/Footer.tsx | 80 ++++++++ src/components/Footer/index.tsx | 1 + src/components/TodoItem/TodoItem.tsx | 119 ++++++++++++ src/components/TodoItem/index.tsx | 1 + src/components/TodoList/TodoList.tsx | 54 ++++++ src/components/TodoList/index.tsx | 1 + src/constants/errorMessages.ts | 7 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++ 16 files changed, 652 insertions(+), 21 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Error/Error.tsx create mode 100644 src/components/Error/index.tsx create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.tsx create mode 100644 src/constants/errorMessages.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/cypress/integration/page.spec.js b/cypress/integration/page.spec.js index fcac2e3d12..1d1c697657 100644 --- a/cypress/integration/page.spec.js +++ b/cypress/integration/page.spec.js @@ -519,7 +519,7 @@ describe('', () => { }); // this test may be flaky - it.skip('should replace loader with a created todo', () => { + it('should replace loader with a created todo', () => { page.flushJSTimers(); todos.assertCount(6); todos.assertNotLoading(5); @@ -1515,7 +1515,7 @@ describe('', () => { }); // It depend on your implementation - it.skip('should stay while waiting', () => { + it('should stay while waiting', () => { page.mockUpdate(257334); todos.title(0).trigger('dblclick'); @@ -1694,7 +1694,7 @@ describe('', () => { }); // this test may be unstable - it.skip('should hide loader on fail', () => { + it('should hide loader on fail', () => { // to prevent Cypress from failing the test on uncaught exception cy.once('uncaught:exception', () => false); diff --git a/package-lock.json b/package-lock.json index 19701e8788..c230d21ea3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,12 @@ "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.80.0", "react-transition-group": "^4.4.5" }, "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 +1184,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", @@ -8720,6 +8722,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.80.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.80.0.tgz", + "integrity": "sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index b6062525ab..12578ac157 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,12 @@ "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.80.0", "react-transition-group": "^4.4.5" }, "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..a14f079c39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,267 @@ /* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { useForm } from 'react-hook-form'; +import { Todo } from './types/Todo'; +import classNames from 'classnames'; +import { + getTodos, + USER_ID, + addTodo, + deleteTodo, + updateTodo, +} from './api/todos'; +import { Footer } from './components/Footer'; +import { Error } from './components/Error'; +import { TodoList } from './components/TodoList'; +import { errorMessages } from './constants/errorMessages'; -const USER_ID = 0; +type FormValues = { + title: string; +}; + +const FILTERS = { + ALL: 'all', + ACTIVE: 'active', + COMPLETED: 'completed', +}; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState(FILTERS.ALL); + const [error, setError] = useState(''); + const { register, handleSubmit, reset } = useForm(); + const inputRef = useRef(null); + const [loadingIds, setLoadingIds] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [isLoadingTodos, setIsLoadingTodos] = useState(true); + const [editingTodoId, setEditingTodoId] = useState(null); + + const isLoading = (id: number) => loadingIds.includes(id); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const showError = (message: string) => { + setError(message); + + setTimeout(() => { + setError(''); + }, 3000); + }; + + const onSubmit = handleSubmit(async data => { + if (!data.title || data.title.trim() === '') { + showError(errorMessages.TITLE_NOT_EMPTY); + + return; + } + + const title = data.title.trim(); + + setTempTodo({ + id: 0, + title, + completed: false, + userId: USER_ID, + }); + try { + setError(''); + + const newTodo = await addTodo({ title }); + + setTodos(current => [...current, newTodo]); + + reset(); + } catch { + showError(errorMessages.ADD_TODO); + } finally { + setTempTodo(null); + } + }); + + const allCompleted = todos.length > 0 && todos.every(todo => todo.completed); + + const visibleTodos = todos.filter(todo => { + switch (filter) { + case FILTERS.ACTIVE: + return !todo.completed; + + case FILTERS.COMPLETED: + return todo.completed; + + default: + return true; + } + }); + + useEffect(() => { + setIsLoadingTodos(true); + + getTodos() + .then(setTodos) + .catch(() => showError(errorMessages.LOAD_TODOS)) + .finally(() => setIsLoadingTodos(false)); + }, []); + + useEffect(() => { + if (tempTodo === null && inputRef.current) { + inputRef.current.focus(); + } + }, [tempTodo]); + if (!USER_ID) { return ; } + const toggleTodo = (id: number) => { + const todo = todos.find(t => t.id === id); + + if (!todo) { + return; + } + + setLoadingIds(prev => (prev.includes(id) ? prev : [...prev, id])); + + updateTodo(id, { completed: !todo.completed }) + .then(updatedTodo => { + setTodos(current => current.map(t => (t.id === id ? updatedTodo : t))); + }) + .catch(() => { + showError(errorMessages.UPDATE_TODO); + }) + .finally(() => { + setLoadingIds(ids => ids.filter(todoId => todoId !== id)); + }); + }; + + const toggleAll = async () => { + const shouldCompleteAll = !allCompleted; + + const updates = todos.map(todo => ({ + ...todo, + completed: shouldCompleteAll, + })); + + setTodos(updates); + + const todosToUpdate = todos.filter( + todo => todo.completed !== shouldCompleteAll, + ); + + try { + await Promise.all( + todosToUpdate.map(todo => + updateTodo(todo.id, { completed: shouldCompleteAll }), + ), + ); + } catch { + showError(errorMessages.UPDATE_TODO); + } + }; + + const removeTodo = (id: number) => { + setLoadingIds(prev => [...prev, id]); + + deleteTodo(id) + .then(() => { + setTodos(current => current.filter(todo => todo.id !== id)); + }) + .catch(() => { + showError(errorMessages.DELETE_TODO); + }) + .finally(() => { + setLoadingIds(ids => ids.filter(todoId => todoId !== id)); + inputRef.current?.focus(); + }); + }; + + const clearCompleted = () => { + const completedTodo = todos.filter(todo => todo.completed); + + completedTodo.forEach(todo => { + removeTodo(todo.id); + }); + }; + + const saveTodo = async (id: number, title: string) => { + setLoadingIds(prev => [...prev, id]); + + const trimmedTitle = title?.trim() ?? ''; + + try { + await updateTodo(id, { title: trimmedTitle }); + + setTodos(current => + current.map(t => (t.id === id ? { ...t, title: trimmedTitle } : t)), + ); + + setEditingTodoId(null); + } catch { + showError(errorMessages.UPDATE_TODO); + } finally { + setLoadingIds(ids => ids.filter(todoId => todoId !== id)); + } + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {!isLoadingTodos && todos.length > 0 && ( +
+ + + + {todos.length > 0 && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..8e61e60020 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,24 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const updateTodo = (id: number, data: Partial) => { + return client.patch(`/todos/${id}`, data); +}; + +export const USER_ID = 4326; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (data: { title: string }) => { + return client.post('/todos', { + userId: USER_ID, + title: data.title, + completed: false, + }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; diff --git a/src/components/Error/Error.tsx b/src/components/Error/Error.tsx new file mode 100644 index 0000000000..bc1d53411f --- /dev/null +++ b/src/components/Error/Error.tsx @@ -0,0 +1,31 @@ +//DON'T use conditional rendering to hide the notification +//Add the 'hidden' class to hide the message smoothly + +import classNames from 'classnames'; + +interface Props { + error: string; + setError: React.Dispatch>; +} + +export const Error: React.FC = ({ error, setError }: Props) => { + return ( +
+
+ ); +}; diff --git a/src/components/Error/index.tsx b/src/components/Error/index.tsx new file mode 100644 index 0000000000..ae6e95d01d --- /dev/null +++ b/src/components/Error/index.tsx @@ -0,0 +1 @@ +export * from './Error'; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..a42e43ddf9 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,80 @@ +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; + +const FILTERS = { + ALL: 'all', + ACTIVE: 'active', + COMPLETED: 'completed', +}; + +interface Props { + todos: Todo[]; + filter: string; + setFilter: React.Dispatch>; + clearCompleted: () => void; +} + +export const Footer: React.FC = ({ + todos, + filter, + setFilter, + clearCompleted, +}: Props) => { + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const hasCompletedTodos = todos.some(todo => todo.completed); + + return ( + + ); +}; diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx new file mode 100644 index 0000000000..ddcc5a9cd1 --- /dev/null +++ b/src/components/Footer/index.tsx @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..3a4c8f792c --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,119 @@ +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; +import { useState } from 'react'; + +interface Props { + todo: Todo; + toggleTodo: (id: number) => void; + removeTodo: (id: number) => void; + isLoading: (id: number) => boolean; + editingTodoId: number | null; + setEditingTodoId: (id: number | null) => void; + saveTodo: (id: number, title: string) => Promise; +} + +export const TodoItem: React.FC = ({ + toggleTodo, + removeTodo, + isLoading, + todo, + editingTodoId, + setEditingTodoId, + saveTodo, +}) => { + const [title, setTitle] = useState(todo.title); + const isEditing = editingTodoId === todo.id; + + const handleSave = () => { + const trimmed = title.trim(); + + if (trimmed === todo.title) { + setEditingTodoId(null); + setTitle(todo.title); + + return; + } + + if (trimmed === '') { + return removeTodo(todo.id); + } + + saveTodo(todo.id, trimmed); + }; + + return ( +
+ + + {!isEditing && ( + setEditingTodoId(todo.id)} + > + {todo.title} + + )} + + {isEditing && ( + setTitle(e.target.value)} + onBlur={handleSave} + onKeyUp={e => { + if (e.key === 'Enter') { + handleSave(); + } + + if (e.key === 'Escape') { + setEditingTodoId(null); + setTitle(todo.title); + } + }} + /> + )} + + {!isEditing && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.tsx b/src/components/TodoItem/index.tsx new file mode 100644 index 0000000000..21f4abac39 --- /dev/null +++ b/src/components/TodoItem/index.tsx @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..874495a61e --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,54 @@ +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem'; + +interface Props { + visibleTodos: Todo[]; + tempTodo: Todo | null; + toggleTodo: (id: number) => void; + removeTodo: (id: number) => void; + isLoading: (id: number) => boolean; + editingTodoId: number | null; + setEditingTodoId: (id: number | null) => void; + saveTodo: (id: number, title: string) => Promise; +} + +export const TodoList: React.FC = ({ + visibleTodos, + toggleTodo, + removeTodo, + isLoading, + tempTodo, + editingTodoId, + setEditingTodoId, + saveTodo, +}: Props) => { + return ( + <> +
+ {visibleTodos.map(todo => ( + + ))} +
+ {tempTodo && ( + {}} + removeTodo={() => {}} + isLoading={() => true} + editingTodoId={editingTodoId} + setEditingTodoId={setEditingTodoId} + saveTodo={saveTodo} + /> + )} + + ); +}; diff --git a/src/components/TodoList/index.tsx b/src/components/TodoList/index.tsx new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/components/TodoList/index.tsx @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/constants/errorMessages.ts b/src/constants/errorMessages.ts new file mode 100644 index 0000000000..6dfd9faacc --- /dev/null +++ b/src/constants/errorMessages.ts @@ -0,0 +1,7 @@ +export const errorMessages = { + TITLE_NOT_EMPTY: 'Title should not be empty', + ADD_TODO: 'Unable to add a todo', + UPDATE_TODO: 'Unable to update a todo', + LOAD_TODOS: 'Unable to load todos', + DELETE_TODO: 'Unable to delete a todo', +}; 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 ffaaa5370e3bd348ccd5ca61ae2c0ad26e2c68bc Mon Sep 17 00:00:00 2001 From: Mariia Madarash Date: Thu, 25 Jun 2026 17:21:06 +0200 Subject: [PATCH 2/3] Solution --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 47a1add059..9aa28d0e92 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://mariiamad.github.io/react_todo-app-with-api/) and add it to the PR description. From 6176658d5f23893b89558ae1e41de91e579435af Mon Sep 17 00:00:00 2001 From: Mariia Madarash Date: Fri, 26 Jun 2026 11:56:28 +0200 Subject: [PATCH 3/3] Solution --- src/App.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index a14f079c39..9b6b2dce97 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -151,6 +151,8 @@ export const App: React.FC = () => { todo => todo.completed !== shouldCompleteAll, ); + setLoadingIds(todosToUpdate.map(todo => todo.id)); + try { await Promise.all( todosToUpdate.map(todo => @@ -159,6 +161,8 @@ export const App: React.FC = () => { ); } catch { showError(errorMessages.UPDATE_TODO); + } finally { + setLoadingIds([]); } };