From 1091178a4b8f393fe566feac45418851015a418d Mon Sep 17 00:00:00 2001 From: Oleh Hurmanchuk Date: Mon, 25 Sep 2023 11:52:54 +0300 Subject: [PATCH 1/6] implement toggling and editing todos, fixed deleting completed todos --- src/App.tsx | 25 +-- src/api/todos.ts | 18 ++ .../ErrorContextProvider.tsx | 49 +++++ src/components/Filter/Filter.tsx | 47 +++++ src/components/NewTodo/NewTodo.tsx | 75 +++++++ src/components/TodoApp/TodoApp.tsx | 155 ++++++++++++++ src/components/TodoError/TodoError.tsx | 28 +++ src/components/TodoItem/TodoItem.tsx | 190 ++++++++++++++++++ src/components/TodoList/TodoList.tsx | 20 ++ .../TodosContextProvider.tsx | 40 ++++ src/styles/todo.scss | 63 +----- src/styles/todoapp.scss | 5 - src/types/FilterKey.ts | 5 + src/types/Todo.ts | 6 + src/utils/UserId.js | 1 + src/utils/fetchClient.ts | 46 +++++ 16 files changed, 701 insertions(+), 72 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorContextProvider/ErrorContextProvider.tsx create mode 100644 src/components/Filter/Filter.tsx create mode 100644 src/components/NewTodo/NewTodo.tsx create mode 100644 src/components/TodoApp/TodoApp.tsx create mode 100644 src/components/TodoError/TodoError.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodosContextProvider/TodosContextProvider.tsx create mode 100644 src/types/FilterKey.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/UserId.js create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 5749bdf784..98f48b0d72 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,14 @@ -/* 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 { TodoApp } from './components/TodoApp/TodoApp'; +import { + TodosContextProvider, +} from './components/TodosContextProvider/TodosContextProvider'; +import { USER_ID } from './utils/UserId'; +import { + ErrorContextProvider, +} from './components/ErrorContextProvider/ErrorContextProvider'; export const App: React.FC = () => { if (!USER_ID) { @@ -11,14 +16,10 @@ export const App: React.FC = () => { } return ( -
-

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

- -

Styles are already copied

-
+ + + + + ); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..d243734a2e --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const postTodo = (data: Omit) => { + return client.post('/todos', data); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, changes: object) => { + return client.patch(`/todos/${todoId}`, changes); +}; diff --git a/src/components/ErrorContextProvider/ErrorContextProvider.tsx b/src/components/ErrorContextProvider/ErrorContextProvider.tsx new file mode 100644 index 0000000000..ccd3860ed2 --- /dev/null +++ b/src/components/ErrorContextProvider/ErrorContextProvider.tsx @@ -0,0 +1,49 @@ +import React, { useState } from 'react'; + +export const ErrorContext = React.createContext({ + errorMessage: '', + setErrorMessage: () => {}, + hasError: false, + setHasError: () => {}, + onNewError: () => {}, +} as ErrorContextProps); + +type ErrorContextProps = { + errorMessage: string, + setErrorMessage: React.Dispatch>, + hasError: boolean, + setHasError: React.Dispatch>, + onNewError: (error: string) => void, +}; + +type Props = { + children: React.ReactNode, +}; + +export const ErrorContextProvider: React.FC = ({ children }) => { + const [errorMessage, setErrorMessage] = useState(''); + const [hasError, setHasError] = useState(false); + + const onNewError = (error: string) => { + setErrorMessage(error); + setHasError(true); + + setTimeout(() => { + setHasError(false); + }, 3000); + }; + + const initialValue = { + errorMessage, + setErrorMessage, + hasError, + setHasError, + onNewError, + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx new file mode 100644 index 0000000000..83ccfd37c6 --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import classNames from 'classnames'; +import { FilterKey } from '../../types/FilterKey'; + +type Props = { + filterKey: FilterKey, + onClick: (key: FilterKey) => void +}; + +export const Filter: React.FC = ({ filterKey, onClick }) => { + return ( + + ); +}; diff --git a/src/components/NewTodo/NewTodo.tsx b/src/components/NewTodo/NewTodo.tsx new file mode 100644 index 0000000000..3f0b2c8f46 --- /dev/null +++ b/src/components/NewTodo/NewTodo.tsx @@ -0,0 +1,75 @@ +import React, { + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { Todo } from '../../types/Todo'; +import { postTodo } from '../../api/todos'; +import { TodosContext } from '../TodosContextProvider/TodosContextProvider'; +import { USER_ID } from '../../utils/UserId'; +import { ErrorContext } from '../ErrorContextProvider/ErrorContextProvider'; + +type Props = { + setTempTodo: (todo: Todo | null) => void, + tempTodo: Todo | null, +}; + +export const NewTodo: React.FC = ({ + setTempTodo, tempTodo, +}) => { + const { onNewError } = useContext(ErrorContext); + const { setTodos, todos } = useContext(TodosContext); + const [newTodoTitle, setNewTodoTitle] = useState(''); + const titleInput = useRef(null); + + useEffect(() => { + if (titleInput.current) { + titleInput.current.focus(); + } + }, [todos, tempTodo]); + + const handleAddingTodo = (event: React.FormEvent) => { + event.preventDefault(); + + if (!newTodoTitle.trim()) { + onNewError('Title should not be empty'); + + return; + } + + const newTodo: Omit = { + userId: USER_ID, + title: newTodoTitle.trim(), + completed: false, + }; + + postTodo(newTodo) + .then((response) => { + setNewTodoTitle(''); + setTodos((prevTodos) => [...prevTodos, response]); + }) + .catch(() => onNewError('Unable to add a todo')) + .finally(() => setTempTodo(null)); + + setTempTodo({ + ...newTodo, + id: 0, + }); + }; + + return ( +
+ setNewTodoTitle(event.target.value)} + disabled={!!tempTodo} + /> +
+ ); +}; diff --git a/src/components/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx new file mode 100644 index 0000000000..f5d0e37f15 --- /dev/null +++ b/src/components/TodoApp/TodoApp.tsx @@ -0,0 +1,155 @@ +import { useContext, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { Filter } from '../Filter/Filter'; +import { NewTodo } from '../NewTodo/NewTodo'; +import { TodoList } from '../TodoList/TodoList'; +import { + TodosContext, +} from '../TodosContextProvider/TodosContextProvider'; +import { TodoError } from '../TodoError/TodoError'; +import { deleteTodo, getTodos, updateTodo } from '../../api/todos'; +import { FilterKey } from '../../types/FilterKey'; +import { Todo } from '../../types/Todo'; +import { USER_ID } from '../../utils/UserId'; +import { ErrorContext } from '../ErrorContextProvider/ErrorContextProvider'; + +function getFilteredTodos(key: FilterKey, todos: Todo[]) { + switch (key) { + case FilterKey.All: + return todos; + case FilterKey.Active: + return todos.filter(({ completed }) => !completed); + case FilterKey.Completed: + return todos.filter(({ completed }) => completed); + default: + return todos; + } +} + +export const TodoApp = () => { + const { onNewError } = useContext(ErrorContext); + const { todos, setTodos, setTodoIdsWithLoader } = useContext(TodosContext); + const [filterKey, setFilterKey] = useState(FilterKey.All); + const [tempTodo, setTempTodo] = useState(null); + + const areAllTodosCompleted = todos.every(({ completed }) => completed); + const visibleTodos = getFilteredTodos(filterKey, todos); + const activeTodos = todos.filter(({ completed }) => !completed); + const hasCompletedTodo = todos.some(({ completed }) => completed); + + useEffect(() => { + getTodos(USER_ID) + .then(setTodos) + .catch(() => onNewError('Unable to load todos')); + }, []); + + const handleAllTodosToggle = () => { + const todosToUpdate = todos.filter( + ({ completed }) => completed === areAllTodosCompleted, + ); + + setTodoIdsWithLoader( + prevTodoIds => [...prevTodoIds, ...todosToUpdate.map(({ id }) => id)], + ); + + todosToUpdate.forEach(todo => { + updateTodo(todo.id, { completed: !todo.completed }) + .then(() => { + const todosCopy = [...todos]; + const searchedTodo = todos.find( + ({ id }) => todo.id === id, + ) as Todo; + + searchedTodo.completed = !searchedTodo.completed; + + setTodos(todosCopy); + }) + .catch(() => onNewError('Unable to update a todo')) + .finally(() => setTodoIdsWithLoader( + prevTodoIds => prevTodoIds.filter((todoId) => todoId !== todo.id), + )); + }); + }; + + const handleCompletedTodosDelete = () => { + const completedTodos = todos.filter(({ completed }) => completed); + + setTodoIdsWithLoader(prevTodoIds => { + return [...prevTodoIds, ...completedTodos.map(({ id }) => id)]; + }); + + completedTodos.forEach(todo => { + deleteTodo(todo.id) + .then(() => { + setTodos(prevTodos => prevTodos.filter(({ id }) => id !== todo.id)); + }) + .catch(() => onNewError('Unable to delete a todo')) + .finally(() => setTodoIdsWithLoader( + prevTodoIds => prevTodoIds.filter((id) => todo.id !== id), + )); + }); + }; + + return ( +
+

todos

+
+
+ {/* this buttons is active only if there are some active todos */} + {!!todos.length && ( +
+ + + + {/* Hide the footer if there are no todos */} + {!!todos.length && ( +
+ + {`${activeTodos.length} items left`} + + + {/* Active filter should have a 'selected' class */} + + + {/* don't show this button if there are no completed todos */} + +
+ )} + + +
+
+ ); +}; diff --git a/src/components/TodoError/TodoError.tsx b/src/components/TodoError/TodoError.tsx new file mode 100644 index 0000000000..03361b66af --- /dev/null +++ b/src/components/TodoError/TodoError.tsx @@ -0,0 +1,28 @@ +import classNames from 'classnames'; +import { useContext } from 'react'; +import { ErrorContext } from '../ErrorContextProvider/ErrorContextProvider'; + +export const TodoError = () => { + const { hasError, errorMessage, setHasError } = useContext(ErrorContext); + + return ( +
+ {/* eslint-disable */} +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..921e2207e4 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,190 @@ +import React, { + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; +import { deleteTodo, updateTodo } from '../../api/todos'; +import { TodosContext } from '../TodosContextProvider/TodosContextProvider'; +import { ErrorContext } from '../ErrorContextProvider/ErrorContextProvider'; + +type Props = { + todo: Todo, +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { onNewError } = useContext(ErrorContext); + const { + todos, + setTodos, + todoIdsWithLoader, + setTodoIdsWithLoader, + } = useContext(TodosContext); + const { title, completed, id } = todo; + const [isBeingEdited, setIsBeingEdited] = useState(false); + const [newTitle, setNewTitle] = useState(title); + const editedInput = useRef(null); + + useEffect(() => { + if (editedInput.current) { + editedInput.current.focus(); + } + }, [isBeingEdited]); + + const handleTodoDelete = () => { + setTodoIdsWithLoader(prevTodoIds => { + const newTodoIds = [...prevTodoIds, id]; + + return newTodoIds; + }); + deleteTodo(id) + .then(() => { + setTodos(prevTodos => prevTodos + .filter(({ id: todoId }) => id !== todoId)); + setIsBeingEdited(false); + }) + .catch(() => onNewError('Unable to delete a todo')) + .finally(() => setTodoIdsWithLoader( + prevTodoIds => prevTodoIds.filter((todoId) => todoId !== id), + )); + }; + + const handleChangeOfTitle = (( + event: React.ChangeEvent, + ) => { + setNewTitle(event.target.value); + }); + + const handleNewTitleSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + handleTodoDelete(); + + return; + } + + if (trimmedTitle === title) { + setIsBeingEdited(false); + + return; + } + + setTodoIdsWithLoader(prevTodoIds => [...prevTodoIds, id]); + + updateTodo(id, { title: trimmedTitle }) + .then(() => { + const todosCopy = [...todos]; + const searchedTodo = todos.find( + ({ id: todoId }) => todoId === id, + ) as Todo; + + searchedTodo.title = trimmedTitle; + + setTodos(todosCopy); + setIsBeingEdited(false); + }) + .catch(() => onNewError('Unable to update a todo')) + .finally(() => { + setTodoIdsWithLoader( + prevTodoIds => prevTodoIds.filter((todoId) => todoId !== id), + ); + }); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsBeingEdited(false); + setNewTitle(title); + } + }; + + const handleTodoToggle = (event: React.ChangeEvent) => { + setTodoIdsWithLoader(prevTodoIds => [...prevTodoIds, id]); + updateTodo(id, { completed: !completed }) + .then(() => { + const todosCopy = [...todos]; + const searchedTodo = todos.find( + ({ id: todoId }) => todoId === id, + ) as Todo; + + searchedTodo.completed = !event.target.checked; + + setTodos(todosCopy); + }) + .catch(() => onNewError('Unable to update a todo')) + .finally(() => setTodoIdsWithLoader( + prevTodoIds => prevTodoIds.filter((todoId) => todoId !== id), + )); + }; + + return ( +
+ + + {isBeingEdited + ? ( +
+ handleNewTitleSubmit()} + /> +
+ ) : ( + <> + setIsBeingEdited(true)} + > + {title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..6428851358 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { Todo } from '../../types/Todo'; + +type Props = { + todos: Todo[] + tempTodo: Todo | null, +}; + +export const TodoList: React.FC = ({ todos, tempTodo }) => { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && } +
+ ); +}; diff --git a/src/components/TodosContextProvider/TodosContextProvider.tsx b/src/components/TodosContextProvider/TodosContextProvider.tsx new file mode 100644 index 0000000000..6d68b57391 --- /dev/null +++ b/src/components/TodosContextProvider/TodosContextProvider.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import { Todo } from '../../types/Todo'; + +export const TodosContext = React.createContext({ + todos: [], + setTodos: () => {}, + todoIdsWithLoader: [], + setTodoIdsWithLoader: () => {}, +} as TodosContextProps); + +type TodosContextProps = { + todos: Todo[], + setTodos: React.Dispatch>, + todoIdsWithLoader: number[], + setTodoIdsWithLoader: React.Dispatch>, +}; + +type Props = { + children: React.ReactNode, +}; + +export const TodosContextProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [todoIdsWithLoader, setTodoIdsWithLoader] = useState([]); + + const initialValue = { + todos, + setTodos, + todoIdsWithLoader, + setTodoIdsWithLoader, + }; + + return ( + + {children} + + ); +}; diff --git a/src/styles/todo.scss b/src/styles/todo.scss index c7f93ff6b9..fc99714ef2 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -8,7 +8,7 @@ font-size: 24px; line-height: 1.4em; border-bottom: 1px solid #ededed; - + &:last-child { border-bottom: 0; } @@ -30,7 +30,7 @@ &__title { padding: 12px 15px; - + word-break: break-all; transition: color 0.4s; } @@ -56,7 +56,7 @@ border: 0; background: none; cursor: pointer; - + transform: translateY(-2px); opacity: 0; transition: color 0.2s ease-out; @@ -65,7 +65,7 @@ color: #af5b5e; } } - + &:hover &__remove { opacity: 1; } @@ -73,13 +73,13 @@ &__title-field { width: 100%; padding: 11px 14px; - + font-size: inherit; line-height: inherit; font-family: inherit; font-weight: inherit; color: inherit; - + border: 1px solid #999; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); @@ -92,58 +92,11 @@ .overlay { position: absolute; - top: 0; left: 0; right: 0; - height: 58px; + bottom: 0; + top: 0; opacity: 0.5; } } - -.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/styles/todoapp.scss b/src/styles/todoapp.scss index 9095f1847f..836166156b 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -121,7 +121,6 @@ appearance: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - transition: opacity 0.3s; &:hover { text-decoration: underline; @@ -130,9 +129,5 @@ &:active { text-decoration: none; } - - &:disabled { - visibility: hidden; - } } } diff --git a/src/types/FilterKey.ts b/src/types/FilterKey.ts new file mode 100644 index 0000000000..1e42321ab7 --- /dev/null +++ b/src/types/FilterKey.ts @@ -0,0 +1,5 @@ +export enum FilterKey { + 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/UserId.js b/src/utils/UserId.js new file mode 100644 index 0000000000..09999070d0 --- /dev/null +++ b/src/utils/UserId.js @@ -0,0 +1 @@ +export const USER_ID = 11492; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..42421feae0 --- /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', + }; + } + + // we wait for testing purpose to see loaders + return wait(300) + .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 ddb1cc620754a23308f68a903752576c3d3476a0 Mon Sep 17 00:00:00 2001 From: Oleh Hurmanchuk Date: Mon, 25 Sep 2023 12:01:45 +0300 Subject: [PATCH 2/6] delete unnecessary comments, improve some functions --- src/components/TodoApp/TodoApp.tsx | 19 ++++++++++--------- src/components/TodoError/TodoError.tsx | 3 +-- src/components/TodoItem/TodoItem.tsx | 6 +----- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/components/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx index f5d0e37f15..f8708bdd9d 100644 --- a/src/components/TodoApp/TodoApp.tsx +++ b/src/components/TodoApp/TodoApp.tsx @@ -1,4 +1,9 @@ -import { useContext, useEffect, useState } from 'react'; +import { + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import classNames from 'classnames'; import { Filter } from '../Filter/Filter'; import { NewTodo } from '../NewTodo/NewTodo'; @@ -33,7 +38,10 @@ export const TodoApp = () => { const [tempTodo, setTempTodo] = useState(null); const areAllTodosCompleted = todos.every(({ completed }) => completed); - const visibleTodos = getFilteredTodos(filterKey, todos); + const visibleTodos = useMemo( + () => getFilteredTodos(filterKey, todos), + [todos, filterKey], + ); const activeTodos = todos.filter(({ completed }) => !completed); const hasCompletedTodo = todos.some(({ completed }) => completed); @@ -95,7 +103,6 @@ export const TodoApp = () => {

todos

- {/* this buttons is active only if there are some active todos */} {!!todos.length && (
); diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index 921e2207e4..4653854629 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -34,11 +34,7 @@ export const TodoItem: React.FC = ({ todo }) => { }, [isBeingEdited]); const handleTodoDelete = () => { - setTodoIdsWithLoader(prevTodoIds => { - const newTodoIds = [...prevTodoIds, id]; - - return newTodoIds; - }); + setTodoIdsWithLoader(prevTodoIds => [...prevTodoIds, id]); deleteTodo(id) .then(() => { setTodos(prevTodos => prevTodos From 4b7d9413bc736267c4c720bfa71a7586b4e03c4e Mon Sep 17 00:00:00 2001 From: Oleh Hurmanchuk Date: Mon, 25 Sep 2023 12:07:11 +0300 Subject: [PATCH 3/6] add readme file --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index af7dae81f6..5f15529178 100644 --- a/README.md +++ b/README.md @@ -41,4 +41,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://Koliras.github.io/react_todo-app-with-api/) and add it to the PR description. From c6ed7e74f5dffd4305c487b51ccf9c74262c2b86 Mon Sep 17 00:00:00 2001 From: Oleh Hurmanchuk Date: Wed, 27 Sep 2023 11:36:50 +0300 Subject: [PATCH 4/6] add const for errors, fix visual bug, change error message showing --- .../ErrorContextProvider.tsx | 25 ++++++--------- src/components/NewTodo/NewTodo.tsx | 11 ++++--- src/components/TodoApp/TodoApp.tsx | 21 ++++++++----- src/components/TodoError/TodoError.tsx | 17 +++++----- src/components/TodoItem/TodoItem.tsx | 31 ++++++++++++------- src/styles/index.scss | 1 + src/types/ErrorMessage.ts | 8 +++++ 7 files changed, 68 insertions(+), 46 deletions(-) create mode 100644 src/types/ErrorMessage.ts diff --git a/src/components/ErrorContextProvider/ErrorContextProvider.tsx b/src/components/ErrorContextProvider/ErrorContextProvider.tsx index ccd3860ed2..47f6f34070 100644 --- a/src/components/ErrorContextProvider/ErrorContextProvider.tsx +++ b/src/components/ErrorContextProvider/ErrorContextProvider.tsx @@ -1,19 +1,16 @@ import React, { useState } from 'react'; +import { ErrorMessage } from '../../types/ErrorMessage'; export const ErrorContext = React.createContext({ - errorMessage: '', + errorMessage: ErrorMessage.None, setErrorMessage: () => {}, - hasError: false, - setHasError: () => {}, onNewError: () => {}, } as ErrorContextProps); type ErrorContextProps = { - errorMessage: string, - setErrorMessage: React.Dispatch>, - hasError: boolean, - setHasError: React.Dispatch>, - onNewError: (error: string) => void, + errorMessage: ErrorMessage, + setErrorMessage: React.Dispatch>, + onNewError: (error: ErrorMessage) => void, }; type Props = { @@ -21,23 +18,21 @@ type Props = { }; export const ErrorContextProvider: React.FC = ({ children }) => { - const [errorMessage, setErrorMessage] = useState(''); - const [hasError, setHasError] = useState(false); + const [errorMessage, setErrorMessage] = useState( + ErrorMessage.None, + ); - const onNewError = (error: string) => { + const onNewError = (error: ErrorMessage) => { setErrorMessage(error); - setHasError(true); setTimeout(() => { - setHasError(false); + setErrorMessage(ErrorMessage.None); }, 3000); }; const initialValue = { errorMessage, setErrorMessage, - hasError, - setHasError, onNewError, }; diff --git a/src/components/NewTodo/NewTodo.tsx b/src/components/NewTodo/NewTodo.tsx index 3f0b2c8f46..8ef5bd2f2f 100644 --- a/src/components/NewTodo/NewTodo.tsx +++ b/src/components/NewTodo/NewTodo.tsx @@ -9,6 +9,7 @@ import { postTodo } from '../../api/todos'; import { TodosContext } from '../TodosContextProvider/TodosContextProvider'; import { USER_ID } from '../../utils/UserId'; import { ErrorContext } from '../ErrorContextProvider/ErrorContextProvider'; +import { ErrorMessage } from '../../types/ErrorMessage'; type Props = { setTempTodo: (todo: Todo | null) => void, @@ -18,7 +19,7 @@ type Props = { export const NewTodo: React.FC = ({ setTempTodo, tempTodo, }) => { - const { onNewError } = useContext(ErrorContext); + const { onNewError, setErrorMessage } = useContext(ErrorContext); const { setTodos, todos } = useContext(TodosContext); const [newTodoTitle, setNewTodoTitle] = useState(''); const titleInput = useRef(null); @@ -33,7 +34,7 @@ export const NewTodo: React.FC = ({ event.preventDefault(); if (!newTodoTitle.trim()) { - onNewError('Title should not be empty'); + onNewError(ErrorMessage.EmptyTitleRecieved); return; } @@ -44,12 +45,14 @@ export const NewTodo: React.FC = ({ completed: false, }; + setErrorMessage(ErrorMessage.None); + postTodo(newTodo) .then((response) => { setNewTodoTitle(''); setTodos((prevTodos) => [...prevTodos, response]); }) - .catch(() => onNewError('Unable to add a todo')) + .catch(() => onNewError(ErrorMessage.UnableAdd)) .finally(() => setTempTodo(null)); setTempTodo({ @@ -67,7 +70,7 @@ export const NewTodo: React.FC = ({ className="todoapp__new-todo" placeholder="What needs to be done?" value={newTodoTitle} - onChange={(event) => setNewTodoTitle(event.target.value)} + onChange={({ target }) => setNewTodoTitle(target.value)} disabled={!!tempTodo} /> diff --git a/src/components/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx index f8708bdd9d..8b22f708ed 100644 --- a/src/components/TodoApp/TodoApp.tsx +++ b/src/components/TodoApp/TodoApp.tsx @@ -17,6 +17,7 @@ import { FilterKey } from '../../types/FilterKey'; import { Todo } from '../../types/Todo'; import { USER_ID } from '../../utils/UserId'; import { ErrorContext } from '../ErrorContextProvider/ErrorContextProvider'; +import { ErrorMessage } from '../../types/ErrorMessage'; function getFilteredTodos(key: FilterKey, todos: Todo[]) { switch (key) { @@ -32,12 +33,12 @@ function getFilteredTodos(key: FilterKey, todos: Todo[]) { } export const TodoApp = () => { - const { onNewError } = useContext(ErrorContext); + const { onNewError, setErrorMessage } = useContext(ErrorContext); const { todos, setTodos, setTodoIdsWithLoader } = useContext(TodosContext); const [filterKey, setFilterKey] = useState(FilterKey.All); const [tempTodo, setTempTodo] = useState(null); - const areAllTodosCompleted = todos.every(({ completed }) => completed); + const isAllTodosCompleted = todos.every(({ completed }) => completed); const visibleTodos = useMemo( () => getFilteredTodos(filterKey, todos), [todos, filterKey], @@ -48,17 +49,18 @@ export const TodoApp = () => { useEffect(() => { getTodos(USER_ID) .then(setTodos) - .catch(() => onNewError('Unable to load todos')); + .catch(() => onNewError(ErrorMessage.UnableLoad)); }, []); const handleAllTodosToggle = () => { const todosToUpdate = todos.filter( - ({ completed }) => completed === areAllTodosCompleted, + ({ completed }) => completed === isAllTodosCompleted, ); setTodoIdsWithLoader( prevTodoIds => [...prevTodoIds, ...todosToUpdate.map(({ id }) => id)], ); + setErrorMessage(ErrorMessage.None); todosToUpdate.forEach(todo => { updateTodo(todo.id, { completed: !todo.completed }) @@ -72,7 +74,7 @@ export const TodoApp = () => { setTodos(todosCopy); }) - .catch(() => onNewError('Unable to update a todo')) + .catch(() => onNewError(ErrorMessage.UnableUpdate)) .finally(() => setTodoIdsWithLoader( prevTodoIds => prevTodoIds.filter((todoId) => todoId !== todo.id), )); @@ -85,13 +87,14 @@ export const TodoApp = () => { setTodoIdsWithLoader(prevTodoIds => { return [...prevTodoIds, ...completedTodos.map(({ id }) => id)]; }); + setErrorMessage(ErrorMessage.None); completedTodos.forEach(todo => { deleteTodo(todo.id) .then(() => { setTodos(prevTodos => prevTodos.filter(({ id }) => id !== todo.id)); }) - .catch(() => onNewError('Unable to delete a todo')) + .catch(() => onNewError(ErrorMessage.UnableDelete)) .finally(() => setTodoIdsWithLoader( prevTodoIds => prevTodoIds.filter((id) => todo.id !== id), )); @@ -108,7 +111,7 @@ export const TodoApp = () => { type="button" data-cy="ToggleAllButton" className={classNames('todoapp__toggle-all', { - active: areAllTodosCompleted, + active: isAllTodosCompleted, })} aria-label="toggle_all_todos" onClick={handleAllTodosToggle} @@ -129,7 +132,9 @@ export const TodoApp = () => { {!!todos.length && (
- {`${activeTodos.length} items left`} + {activeTodos.length === 1 + ? '1 item left' + : `${activeTodos.length} items left`} { - const { hasError, errorMessage, setHasError } = useContext(ErrorContext); + const { setErrorMessage, errorMessage } = useContext(ErrorContext); return (
+
+ ); +}; diff --git a/src/components/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx index 8b22f708ed..e02a6cf8c9 100644 --- a/src/components/TodoApp/TodoApp.tsx +++ b/src/components/TodoApp/TodoApp.tsx @@ -5,32 +5,20 @@ import { useState, } from 'react'; import classNames from 'classnames'; -import { Filter } from '../Filter/Filter'; import { NewTodo } from '../NewTodo/NewTodo'; import { TodoList } from '../TodoList/TodoList'; import { TodosContext, } from '../TodosContextProvider/TodosContextProvider'; import { TodoError } from '../TodoError/TodoError'; -import { deleteTodo, getTodos, updateTodo } from '../../api/todos'; +import { getTodos, updateTodo } from '../../api/todos'; import { FilterKey } from '../../types/FilterKey'; import { Todo } from '../../types/Todo'; import { USER_ID } from '../../utils/UserId'; import { ErrorContext } from '../ErrorContextProvider/ErrorContextProvider'; import { ErrorMessage } from '../../types/ErrorMessage'; - -function getFilteredTodos(key: FilterKey, todos: Todo[]) { - switch (key) { - case FilterKey.All: - return todos; - case FilterKey.Active: - return todos.filter(({ completed }) => !completed); - case FilterKey.Completed: - return todos.filter(({ completed }) => completed); - default: - return todos; - } -} +import { getFilteredTodos } from '../../utils/getFilteredTodos'; +import { Footer } from '../Footer/Footer'; export const TodoApp = () => { const { onNewError, setErrorMessage } = useContext(ErrorContext); @@ -43,8 +31,6 @@ export const TodoApp = () => { () => getFilteredTodos(filterKey, todos), [todos, filterKey], ); - const activeTodos = todos.filter(({ completed }) => !completed); - const hasCompletedTodo = todos.some(({ completed }) => completed); useEffect(() => { getTodos(USER_ID) @@ -62,8 +48,8 @@ export const TodoApp = () => { ); setErrorMessage(ErrorMessage.None); - todosToUpdate.forEach(todo => { - updateTodo(todo.id, { completed: !todo.completed }) + Promise.all(todosToUpdate.map(todo => { + return updateTodo(todo.id, { completed: !todo.completed }) .then(() => { const todosCopy = [...todos]; const searchedTodo = todos.find( @@ -78,27 +64,7 @@ export const TodoApp = () => { .finally(() => setTodoIdsWithLoader( prevTodoIds => prevTodoIds.filter((todoId) => todoId !== todo.id), )); - }); - }; - - const handleCompletedTodosDelete = () => { - const completedTodos = todos.filter(({ completed }) => completed); - - setTodoIdsWithLoader(prevTodoIds => { - return [...prevTodoIds, ...completedTodos.map(({ id }) => id)]; - }); - setErrorMessage(ErrorMessage.None); - - completedTodos.forEach(todo => { - deleteTodo(todo.id) - .then(() => { - setTodos(prevTodos => prevTodos.filter(({ id }) => id !== todo.id)); - }) - .catch(() => onNewError(ErrorMessage.UnableDelete)) - .finally(() => setTodoIdsWithLoader( - prevTodoIds => prevTodoIds.filter((id) => todo.id !== id), - )); - }); + })); }; return ( @@ -130,28 +96,7 @@ export const TodoApp = () => { /> {!!todos.length && ( -
- - {activeTodos.length === 1 - ? '1 item left' - : `${activeTodos.length} items left`} - - - -
+