diff --git a/README.md b/README.md index d3c3756ab9..a189b099ed 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,42 @@ -# React Todo App with API (complete) - -It is the third part of the React Todo App with API. - -Take your code implemented for [Add and Delete](https://github.com/mate-academy/react_todo-app-add-and-delete) -and implement the ability to toggle and rename todos. - -> Here is [the working example](https://mate-academy.github.io/react_todo-app-with-api/) - -## Toggling a todo status - -Toggle the `completed` status on `TodoStatus` change: -- Install Prettier Extention and use this [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. -- covered the todo with a loader overlay while waiting for API response; -- the status should be changed on success; -- show the `Unable to update a todo` notification in case of API error. - -Add the ability to toggle the completed status of all the todos with the `toggleAll` checkbox: - -- `toggleAll` button should have `active` class only if all the todos are completed; -- `toggleAll` click changes its status to the opposite one, and sets this new status to all the todos; -- it should work the same as several individual updates of the todos which statuses were actually changed; -- do send requests for the todos that were not changed; - -## Renaming a todo - -Implement the ability to edit a todo title on double click: - -- show the edit form instead of the title and remove button; -- saves changes on the form submit (just press `Enter`); -- save changes when the field loses focus (`onBlur`); -- if the new title is the same as the old one just cancel editing; -- cancel editing on `Esс` key `keyup` event; -- if the new title is empty delete the todo the same way the `x` button does it; -- if the title was changed show the loader while waiting for the API response; -- update the todo title on success; -- show `Unable to update a todo` in case of API error; -- or the deletion error message if we tried to delete the todo. - -## If you want to enable tests -- open `cypress/integration/page.spec.js` -- replace `describe.skip` with `describe` for the root `describe` - -> ❗❗All tests should pass, even if some behaviour in not well explained in the task❗❗ +## Demo +- Link to Live Demo: [DEMO LINK](https://pikalovandrey.github.io/react_todo-app-with-api/) + +# React Todo App with API + +## Project Description +This project implements the ability to toggle and rename tasks in a React-based Todo application. It focuses on enhancing user interaction with tasks while building upon the previously established functionalities of adding and deleting todos. + +## Achievements and Implemented Features + +### Toggling Todos +- **Toggle Status**: Users can change the `completed` status of each todo item. + - Added a loader overlay to indicate loading while waiting for the API response. + - The todo status is updated upon successful API call. + - Displays an error notification ("Unable to update a todo") in case of an API error. + +- **Toggle All**: A checkbox that allows users to toggle the completion status of all todos simultaneously. + - The checkbox has an `active` class only if all todos are completed. + - Clicking the checkbox changes its status and updates all todos accordingly. + - The API sends requests only for the todos whose statuses actually changed. + +### Renaming Todos +- **Edit on Double Click**: Users can rename a todo by double-clicking on it. + - An edit form appears in place of the title and remove button. + - Changes are saved on form submission (by pressing `Enter`) or when the field loses focus (`onBlur`). + - If the new title matches the old one, editing is canceled. + - Pressing `Esc` cancels the editing process. + - If the new title is empty, the todo is deleted in the same way as with the remove button. + - A loader is displayed while waiting for the API response upon title change. + - Updates the todo title on success, with error notifications for any issues. + +## Technologies Used +- React +- TypeScript +- CSS (Sass) +- Axios for API calls ## Instructions - -- 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. +1. Clone the repository and navigate to the project directory. +2. Install dependencies using `npm install`. +3. Start the development server with `npm start`. +4. Open the application in your browser at `http://localhost:3000`. diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..2b0f077ac4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,87 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import React, { useEffect, useMemo, useState } from 'react'; +import { FilterOptions, ErrorMessages } from './enums/'; +import { filteredTodos, loadTodos } from './utils/'; +import { Header, Footer, TodoList, Errors } from './components/'; +import { useTodos } from './hooks/useTodos'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [filter, setFilter] = useState(FilterOptions.ALL); + const [errorMessage, setErrorMessage] = useState(ErrorMessages.NO_ERRORS); + + const { + todos, + tempTodo, + loadingTodosCount, + inputRef, + inputValue, + handleSubmit, + handleUpdateTodo, + handleTodoDelete, + setTodos, + handleTodosToggle, + handleTodoToggle, + handleCompletedTodosDeleted, + handleInputChange, + } = useTodos(setErrorMessage); + + const todosAfterFiltering = useMemo( + () => filteredTodos(todos, filter), + [todos, filter], + ); + + const completedTodos = useMemo( + () => todos.filter(todo => todo.completed), + [todos], + ); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [todos, inputRef]); + + useEffect(() => { + loadTodos(setTodos, setErrorMessage); + }, [setTodos]); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {!!todos.length && ( +
+ )} +
+ + +
); }; diff --git a/src/UserWarning.tsx b/src/UserWarning.tsx deleted file mode 100644 index fa25838e6a..0000000000 --- a/src/UserWarning.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -export const UserWarning: React.FC = () => ( -
-

- Please get your userId {' '} - - here - {' '} - and save it in the app

const USER_ID = ...
- All requests to the API must be sent with this - userId. -

-
-); diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..336fa2623f --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,22 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; +import { USER_ID } from '../utils/USER_ID'; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const deleteTodos = (todoId: number): Promise => { + return client.delete(`/todos/${todoId}`).then(() => {}); +}; + +export const addTodo = (newTodo: Omit): Promise => { + return client.post('/todos', newTodo); +}; + +export const changeTodo = ( + todoId: number, + updatedFields?: Partial, +): Promise => { + return client.patch(`/todos/${todoId}`, updatedFields); +}; diff --git a/src/components/Errors.tsx b/src/components/Errors.tsx new file mode 100644 index 0000000000..f994a8cf31 --- /dev/null +++ b/src/components/Errors.tsx @@ -0,0 +1,38 @@ +import classNames from 'classnames'; +import { ErrorMessages } from '../enums/ErrorMessages'; +import { Dispatch, FC, SetStateAction, useEffect } from 'react'; + +interface ErrorsProps { + errorMessage: ErrorMessages; + setErrorMessage: Dispatch>; +} + +export const Errors: FC = ({ errorMessage, setErrorMessage }) => { + useEffect(() => { + if (errorMessage) { + const timer = setTimeout(() => { + setErrorMessage(ErrorMessages.NO_ERRORS); + }, 3000); + + return () => clearTimeout(timer); + } + }, [errorMessage, setErrorMessage]); + + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..2af712744d --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,57 @@ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { FilterOptions } from '../enums/FilterOptions'; +import { Dispatch, FC, SetStateAction } from 'react'; + +interface FooterProps { + todos: Todo[]; + completedTodos: Todo[]; + filter: FilterOptions; + setFilter: Dispatch>; + onCompletedTodosDeleted: () => void; +} + +export const Footer: FC = ({ + todos, + completedTodos, + filter, + setFilter, + onCompletedTodosDeleted, +}) => { + const atLeastOneIsActive = todos.some(todo => todo.completed); + const leftTodos = todos.length - completedTodos.length; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..d3053916cb --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { FormEvent } from 'react'; + +interface HeaderProps { + value: string; + todos: Todo[]; + completedTodos: Todo[]; + inputRef: React.RefObject; + loadingTodos: number[]; + onTodosToggle: () => void; + onSubmit: (event: FormEvent) => void; + onInputChange: (event: React.ChangeEvent) => void; +} + +export const Header: React.FC = ({ + value, + todos, + completedTodos, + inputRef, + loadingTodos, + onTodosToggle, + onSubmit, + onInputChange, +}) => { + const isLoading = loadingTodos.length > 0; + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/TodoComponent.tsx b/src/components/TodoComponent.tsx new file mode 100644 index 0000000000..228e59d4ff --- /dev/null +++ b/src/components/TodoComponent.tsx @@ -0,0 +1,165 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useState, useEffect } from 'react'; +import { changeTodo } from '../api/todos'; + +interface TodoComponentProps { + todo: Todo; + isLoading: boolean; + tempTodo: Todo | null; + inputRef: React.MutableRefObject; + onTodoDelete: (todoId: number) => void; + onTodoToggle: (todoId: number, currentCompletedStatus: boolean) => void; + onUpdateTodo: (updatedTodo: Todo) => Promise; +} + +export const TodoComponent: React.FC = ({ + todo, + isLoading, + tempTodo, + inputRef, + onTodoDelete, + onTodoToggle, + onUpdateTodo, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(todo.title); + + const onDoubleClickHandler = () => { + setIsEditing(true); + setInputValue(todo.title); + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const onChangeHandler = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + }; + + const onKeyDownHandler = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setInputValue(todo.title); + setIsEditing(false); + } + }; + + const handleSubmit = (event: React.FormEvent) => { + event?.preventDefault(); + + if (!inputValue.trim()) { + onTodoDelete(todo.id); + + return; + } + + if (inputValue === todo.title) { + setIsEditing(false); + + return; + } else { + const updatedTodo = { + ...todo, + title: inputValue.trim(), + }; + + onUpdateTodo(updatedTodo) + .then(() => setIsEditing(false)) + .catch(() => {}); + } + }; + + const handleBlur = () => { + const preparedInputValue = inputValue.trim(); + + if (!preparedInputValue) { + onTodoDelete(todo.id); + } + + if (preparedInputValue === todo.title) { + setIsEditing(false); + + return; + } + + if (preparedInputValue) { + onUpdateTodo({ ...todo, title: preparedInputValue }); + changeTodo(todo.id, { title: preparedInputValue }); + } + + setIsEditing(false); + }; + + const isTempTodo = tempTodo && tempTodo.id === todo.id; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing, inputRef]); + + return ( +
+ + + {isEditing ? ( +
+ +
+ ) : ( + + {todo.title} + + )} + + {!isEditing && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..560a12919c --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,70 @@ +import { TransitionGroup, CSSTransition } from 'react-transition-group'; +import { Todo } from '../types/Todo'; +import { TodoComponent } from './TodoComponent'; + +interface TodoListProps { + todosAfterFiltering: Todo[]; + tempTodo?: Todo | null; + loadingTodos: number[]; + inputRef: React.MutableRefObject; + onTodoDelete: (todoId: number) => void; + onTodoToggle: (todoId: number, currentCompletedStatus: boolean) => void; + onUpdateTodo: (updatedTodo: Todo) => Promise; +} + +export const TodoList: React.FC = ({ + todosAfterFiltering, + tempTodo, + loadingTodos, + inputRef, + onTodoDelete, + onTodoToggle, + onUpdateTodo, +}) => { + const handleEndListener = (node: HTMLElement, done: () => void) => { + node.addEventListener('transitionend', done, false); + }; + + return ( +
+ + {todosAfterFiltering.map(todo => ( + + + + ))} + {tempTodo && ( + + + + )} + +
+ ); +}; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000000..661def99be --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,5 @@ +export * from '../components/Errors'; +export * from '../components/Footer'; +export * from '../components/Header'; +export * from '../components/TodoComponent'; +export * from '../components/TodoList'; diff --git a/src/enums/ErrorMessages.ts b/src/enums/ErrorMessages.ts new file mode 100644 index 0000000000..f9f848396d --- /dev/null +++ b/src/enums/ErrorMessages.ts @@ -0,0 +1,8 @@ +export enum ErrorMessages { + NO_ERRORS = '', + LOADING_ERROR = 'Unable to load todos', + DELETING_ERROR = 'Unable to delete a todo', + ADDING_ERROR = 'Unable to add a todo', + EMPTY_TITLE = 'Title should not be empty', + UPDATING_ERROR = 'Unable to update a todo', +} diff --git a/src/enums/FilterOptions.ts b/src/enums/FilterOptions.ts new file mode 100644 index 0000000000..d15ed01d13 --- /dev/null +++ b/src/enums/FilterOptions.ts @@ -0,0 +1,5 @@ +export enum FilterOptions { + ALL = 'all', + ACTIVE = 'active', + COMPLETED = 'completed', +} diff --git a/src/enums/index.ts b/src/enums/index.ts new file mode 100644 index 0000000000..5509488d39 --- /dev/null +++ b/src/enums/index.ts @@ -0,0 +1,2 @@ +export * from './FilterOptions'; +export * from './ErrorMessages'; diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts new file mode 100644 index 0000000000..4160b018a7 --- /dev/null +++ b/src/hooks/useTodos.ts @@ -0,0 +1,177 @@ +import { useState, useCallback, FormEvent, useRef } from 'react'; +import { Todo } from '../types/Todo'; +import { addTodo, changeTodo, deleteTodos } from '../api/todos'; +import { ErrorMessages } from '../enums/ErrorMessages'; +import { USER_ID } from '../utils/USER_ID'; +import { updateTodos } from '../utils/updateTodo'; + +export const useTodos = ( + setErrorMessage: React.Dispatch>, +) => { + const [todos, setTodos] = useState([]); + const [loadingTodosCount, setLoadingTodosCount] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (!inputValue.trim().length) { + setErrorMessage(ErrorMessages.EMPTY_TITLE); + + return; + } + + const temporaryTodo: Todo = { + id: 0, + title: inputValue.trim(), + userId: USER_ID, + completed: false, + }; + + setTempTodo(temporaryTodo); + setLoadingTodosCount(currentCount => [...currentCount, 0]); + + addTodo(temporaryTodo) + .then((createdTodo: Todo) => { + setTodos(currentTodos => [...currentTodos, createdTodo]); + setInputValue(''); + }) + .catch(() => { + setErrorMessage(ErrorMessages.ADDING_ERROR); + }) + .finally(() => { + if (inputRef.current) { + inputRef.current.disabled = false; + inputRef.current.focus(); + } + + setTempTodo(null); + setLoadingTodosCount(currentCount => + currentCount.filter(todoId => todoId !== 0), + ); + }); + }; + + const handleUpdateTodo = useCallback( + (updatedTodo: Todo): Promise => { + setLoadingTodosCount(current => [...current, updatedTodo.id]); + + return changeTodo(updatedTodo.id, { title: updatedTodo.title }) + .then(() => { + setTodos(currentTodos => + currentTodos.map(todo => + todo.id === updatedTodo.id ? updatedTodo : todo, + ), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessages.UPDATING_ERROR); + throw new Error(ErrorMessages.UPDATING_ERROR); + }) + .finally(() => { + setLoadingTodosCount(current => + current.filter(id => id !== updatedTodo.id), + ); + }); + }, + [setLoadingTodosCount, setTodos, setErrorMessage], + ); + + const handleTodosToggle = useCallback(() => { + const hasIncompleteTodos = todos.some(todo => !todo.completed); + const todosForToggling = todos + .filter(todo => todo.completed !== hasIncompleteTodos) + .map(todo => todo.id); + + updateTodos( + todosForToggling, + hasIncompleteTodos, + setLoadingTodosCount, + setTodos, + setErrorMessage, + changeTodo, + deleteTodos, + ); + }, [todos, setLoadingTodosCount, setTodos, setErrorMessage]); + + const handleTodoToggle = useCallback( + (todoId: number, currentCompletedStatus: boolean) => { + updateTodos( + [todoId], + !currentCompletedStatus, + setLoadingTodosCount, + setTodos, + setErrorMessage, + changeTodo, + deleteTodos, + ); + }, + [setLoadingTodosCount, setTodos, setErrorMessage], + ); + + const handleTodoDelete = useCallback( + (todoId: number): Promise => { + setLoadingTodosCount(current => [...current, todoId]); + + return updateTodos( + [todoId], + null, + setLoadingTodosCount, + setTodos, + setErrorMessage, + changeTodo, + deleteTodos, + ).finally(() => { + setLoadingTodosCount(current => current.filter(id => id !== todoId)); + }); + }, + [setLoadingTodosCount, setTodos, setErrorMessage], + ); + + const handleCompletedTodosDeleted = useCallback(() => { + const todosForDeleting = todos + .filter(todo => todo.completed) + .map(todo => todo.id); + + setLoadingTodosCount(current => [...current, ...todosForDeleting]); + + updateTodos( + todosForDeleting, + null, + setLoadingTodosCount, + setTodos, + setErrorMessage, + changeTodo, + deleteTodos, + ).finally(() => { + setLoadingTodosCount(current => + current.filter(id => !todosForDeleting.includes(id)), + ); + }); + }, [todos, setLoadingTodosCount, setTodos, setErrorMessage]); + + const handleInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + }; + + return { + todos, + tempTodo, + loadingTodosCount, + inputRef, + inputValue, + setErrorMessage, + handleSubmit, + handleUpdateTodo, + handleTodoDelete, + setLoadingTodosCount, + setTodos, + setInputValue, + handleTodosToggle, + handleTodoToggle, + handleCompletedTodosDeleted, + handleInputChange, + }; +}; 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/USER_ID.ts b/src/utils/USER_ID.ts new file mode 100644 index 0000000000..dedfb464b8 --- /dev/null +++ b/src/utils/USER_ID.ts @@ -0,0 +1 @@ +export const USER_ID = 1592; 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'), +}; diff --git a/src/utils/filteringTodos.ts b/src/utils/filteringTodos.ts new file mode 100644 index 0000000000..6bd3c1dea9 --- /dev/null +++ b/src/utils/filteringTodos.ts @@ -0,0 +1,14 @@ +import { FilterOptions } from '../enums/FilterOptions'; +import { Todo } from '../types/Todo'; + +export const filteredTodos = (todos: Todo[], filter: FilterOptions) => + todos.filter(todo => { + switch (filter) { + case FilterOptions.ACTIVE: + return !todo.completed; + case FilterOptions.COMPLETED: + return todo.completed; + default: + return true; + } + }); diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000000..72dc529085 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,4 @@ +export * from './USER_ID'; +export * from './fetchClient'; +export * from './filteringTodos'; +export * from './loadTodos'; diff --git a/src/utils/loadTodos.ts b/src/utils/loadTodos.ts new file mode 100644 index 0000000000..1df6cedeff --- /dev/null +++ b/src/utils/loadTodos.ts @@ -0,0 +1,17 @@ +import { getTodos } from '../api/todos'; +import { ErrorMessages } from '../enums/ErrorMessages'; +import { Todo } from '../types/Todo'; + +export const loadTodos = ( + setTodos: React.Dispatch>, + setErrorMessage: React.Dispatch>, +) => { + getTodos() + .then(loadedTodos => { + setTodos(loadedTodos); + }) + .catch(error => { + setErrorMessage(ErrorMessages.LOADING_ERROR); + throw new Error(error); + }); +}; diff --git a/src/utils/updateTodo.ts b/src/utils/updateTodo.ts new file mode 100644 index 0000000000..a48b36119d --- /dev/null +++ b/src/utils/updateTodo.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/indent */ +import { ErrorMessages } from '../enums'; +import { Todo } from '../types/Todo'; + +export const updateTodos = ( + todoIds: number[], + newStatus: boolean | null, + setLoadingTodosCount: React.Dispatch>, + setTodos: React.Dispatch>, + setErrorMessage: React.Dispatch>, + operation: (id: number, changes?: Partial) => Promise, + deleteOperation?: (id: number) => Promise, +): Promise => { + setLoadingTodosCount(current => [...current, ...todoIds]); + + const promises = todoIds.map(id => { + let action; + + if (newStatus !== null) { + action = operation(id, { completed: newStatus }); + } else if (deleteOperation) { + action = deleteOperation(id); + } else { + action = operation(id); + } + + return action + .then(() => { + setTodos(currentTodos => + newStatus !== null + ? currentTodos.map(todo => + todo.id === id ? { ...todo, completed: newStatus } : todo, + ) + : currentTodos.filter(todo => todo.id !== id), + ); + }) + .catch(() => { + setErrorMessage( + newStatus !== null + ? ErrorMessages.UPDATING_ERROR + : ErrorMessages.DELETING_ERROR, + ); + }) + .finally(() => { + setLoadingTodosCount(current => + current.filter(loadingId => loadingId !== id), + ); + }); + }); + + return Promise.all(promises).then(() => {}); +};