From dfd4b168dedf324734abe41e6c5266ec3a9bc828 Mon Sep 17 00:00:00 2001 From: Dmytro Vovk Date: Mon, 11 Sep 2023 14:46:03 +0300 Subject: [PATCH 1/2] Solution --- README.md | 2 +- src/App.tsx | 23 +- src/api/todos.ts | 20 ++ .../TempoTodoItem/TempoTodoItem.tsx | 32 ++ src/components/TodoApp/TodoApp.tsx | 40 +++ src/components/TodoContext/TodoContext.tsx | 276 ++++++++++++++++++ src/components/TodoFooter/TodoFooter.tsx | 78 +++++ src/components/TodoHeader/TodoHeader.tsx | 92 ++++++ src/components/TodoItem/TodoItem.tsx | 137 +++++++++ src/components/TodoList/TodoList.tsx | 41 +++ .../TodoNotification/TodoNotification.tsx | 34 +++ src/types/Enum.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 42 +++ 14 files changed, 809 insertions(+), 19 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/TempoTodoItem/TempoTodoItem.tsx create mode 100644 src/components/TodoApp/TodoApp.tsx create mode 100644 src/components/TodoContext/TodoContext.tsx create mode 100644 src/components/TodoFooter/TodoFooter.tsx create mode 100644 src/components/TodoHeader/TodoHeader.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoNotification/TodoNotification.tsx create mode 100644 src/types/Enum.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index c7bfa3dd3..7659ea6f4 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://Pa1eOrc.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 5749bdf78..94225e213 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,11 @@ -/* 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 { TodoProvider } from './components/TodoContext/TodoContext'; +import { TodoApp } from './components/TodoApp/TodoApp'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } - 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 000000000..4bd6915c6 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,20 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const createTodo = ({ userId, title, completed }: Omit) => { + return client.post('/todos', { userId, title, completed }); +}; + +export const updateTodo = ({ + id, userId, title, completed, +}: Todo) => { + return client.patch(`/todos/${id}`, { userId, title, completed }); +}; diff --git a/src/components/TempoTodoItem/TempoTodoItem.tsx b/src/components/TempoTodoItem/TempoTodoItem.tsx new file mode 100644 index 000000000..8c5c12bdf --- /dev/null +++ b/src/components/TempoTodoItem/TempoTodoItem.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; + +type Props = { + tempoTodo: Todo; +}; + +export const TempoTodoItem: React.FC = ({ tempoTodo }) => { + return ( +
+ + + {tempoTodo.title} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx new file mode 100644 index 000000000..e3f6e9fc1 --- /dev/null +++ b/src/components/TodoApp/TodoApp.tsx @@ -0,0 +1,40 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useState } from 'react'; +import { TodoHeader } from '../TodoHeader/TodoHeader'; +import { TodoList } from '../TodoList/TodoList'; +import { TodoFooter } from '../TodoFooter/TodoFooter'; +import { TodoNotification } from '../TodoNotification/TodoNotification'; +import { useTodo } from '../TodoContext/TodoContext'; +import { Filter } from '../../types/Enum'; + +export const TodoApp: React.FC = () => { + const { todos, tempoTodo } = useTodo(); + const [selectedFilter, setSelectedFilter] = useState(Filter.All); + + return ( +
+

todos

+ +
+ + + {(todos.length > 0 || tempoTodo) && ( + <> + + + + + )} +
+ + +
+ ); +}; diff --git a/src/components/TodoContext/TodoContext.tsx b/src/components/TodoContext/TodoContext.tsx new file mode 100644 index 000000000..e2261609a --- /dev/null +++ b/src/components/TodoContext/TodoContext.tsx @@ -0,0 +1,276 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { UserWarning } from '../../UserWarning'; +import * as todosService from '../../api/todos'; + +const USER_ID = 11383; + +type Props = { + children: React.ReactNode; +}; + +type TodoContextValue = { + todos: Todo[]; + todosUncompleted: number; + todosCompleted: Todo[]; + addTodo: (inputValue: string) => void; + toggleTodo: (selectedTodo: Todo) => void; + toogleAll: () => void; + deleteTodo: (todo: Todo) => void; + deleteComplitedTodo: () => void; + updateTodo: (updatedTitle: string, todo: Todo) => void; + isError: { isError: boolean, message: string } + setIsError: (isError: { isError: boolean; message: string }) => void; + tempoTodo: Todo | null; + setTempoTodo: (tempoTodo: Todo | null) => void; + inputValue: string; + setInputValue: (inputValue: string) => void; + isOnAdd: boolean; + setIsOnAdd: (isOnAdd: boolean) => void; + isCompliteDeleting: boolean; + isToogleAllClick: boolean; + isTodoOnUpdate: Todo | null; + resetError: () => void; + handleAddTodoError: (errorMessage: string) => void; +}; + +export const TodoContext = React.createContext({ + todos: [], + todosUncompleted: 0, + todosCompleted: [], + addTodo: () => {}, + toggleTodo: () => {}, + toogleAll: () => {}, + deleteTodo: () => { }, + deleteComplitedTodo: () => { }, + updateTodo: () => { }, + isError: { isError: false, message: '' }, + setIsError: () => {}, + tempoTodo: null, + setTempoTodo: () => {}, + inputValue: '', + setInputValue: () => {}, + isOnAdd: false, + setIsOnAdd: () => {}, + isCompliteDeleting: false, + isToogleAllClick: false, + isTodoOnUpdate: null, + resetError: () => {}, + handleAddTodoError: () => {}, +}); + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [isError, setIsError] = useState({ isError: false, message: '' }); + const [tempoTodo, setTempoTodo] = useState(null); + const [isOnAdd, setIsOnAdd] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [isCompliteDeleting, setIsCompliteDeleting] = useState(false); + const [isToogleAllClick, setIsToogleAllClick] = useState(false); + const [isTodoOnUpdate, setIsTodoOnUpdate] = useState(null); + let timeoutId: NodeJS.Timeout | null; + + const resetError = () => { + setIsError({ isError: false, message: '' }); + }; + + const handleAddTodoError = (errorMessage: string) => { + setIsError({ isError: true, message: errorMessage }); + }; + + useEffect(() => { + if (isError.isError) { + timeoutId = setTimeout(() => { + resetError(); + }, 3000); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [isError, resetError]); + + const updateTodoHelper = async ( + updatedTodo: Todo, updateFunction: (todo: Todo) => Promise, + ) => { + setIsTodoOnUpdate(updatedTodo); + + try { + const updatedTodoFromServer = await updateFunction(updatedTodo); + + if (updatedTodoFromServer) { + const updatedTodos = todos.map(todo => ( + todo.id === updatedTodoFromServer.id ? updatedTodoFromServer : todo + )); + + setTodos(updatedTodos); + } + } catch (error) { + handleAddTodoError('Unable to update todo'); + } finally { + setIsTodoOnUpdate(null); + } + }; + + useEffect(() => { + if (USER_ID) { + todosService.getTodos(USER_ID) + .then((fetchedTodos) => { + setTodos(fetchedTodos); + }) + .catch((error) => { + setIsError({ isError: true, message: `${error}` }); + }); + } + }, []); + + const todosUncompleted = useMemo(() => todos.filter( + todo => !todo.completed, + ).length, [todos]); + + const todosCompleted = useMemo(() => todos.filter( + todo => todo.completed, + ), [todos]); + + // #region Add Todo Section + const createTempTodo = () => ({ + id: 0, + userId: USER_ID, + title: inputValue, + completed: false, + }); + + const resetAddTodoState = () => { + setTempoTodo(null); + setInputValue(''); + setIsOnAdd(false); + }; + + const addTodo = async () => { + setIsOnAdd(true); + + try { + const tempTodo = createTempTodo(); + + setTempoTodo(tempTodo); + const newTodo = await todosService.createTodo(tempTodo); + + if (newTodo) { + setTodos([...todos, newTodo]); + } + } catch (error) { + handleAddTodoError('Unable to add a todo'); + } finally { + resetAddTodoState(); + } + }; + // #endregion Add Todo Section + + // #region Update Todo Section + const toggleTodo = async (selectedTodo: Todo) => { + const updatedTodo = { ...selectedTodo, completed: !selectedTodo.completed }; + + await updateTodoHelper(updatedTodo, todosService.updateTodo); + }; + + const toogleAll = async () => { + setIsToogleAllClick(true); + + const allCompleted = todos.every(todo => todo.completed === true); + const updatedTodos = todos.map(todo => ({ + ...todo, + completed: !allCompleted, + })); + + try { + const tooglePromise = updatedTodos.map( + todo => todosService.updateTodo(todo), + ); + + await Promise.all(tooglePromise); + setTodos(updatedTodos); + } catch (error) { + handleAddTodoError('Unable to update todos'); + } finally { + setIsToogleAllClick(false); + } + }; + + const updateTodo = async (updatedTitle: string, selectedTodo: Todo) => { + const updatedTodo = { ...selectedTodo, title: updatedTitle }; + + await updateTodoHelper(updatedTodo, todosService.updateTodo); + }; + // #endregion Update Todo Section + + // #region Delete Todo Section + const deleteTodo = async (selectedTodo: Todo) => { + setIsTodoOnUpdate(selectedTodo); + try { + await todosService.deleteTodo(selectedTodo.id); + setTodos(todos.filter(todo => todo.id !== selectedTodo.id)); + } catch (error) { + handleAddTodoError('Unable to delete a todo'); + } finally { + setIsTodoOnUpdate(null); + } + }; + + const deleteComplitedTodo = async () => { + if (todosCompleted.length > 0) { + setIsCompliteDeleting(true); + try { + const deletionPromises = todosCompleted.map( + todo => todosService.deleteTodo(todo.id), + ); + + await Promise.all(deletionPromises); + setTodos(todos.filter(todo => !todo.completed)); + } catch (error) { + handleAddTodoError('Unable to delete a todo'); + } finally { + setIsCompliteDeleting(false); + } + } + }; + // #endregion Delete Todo Section + + const contextValue: TodoContextValue = { + todos, + todosUncompleted, + todosCompleted, + addTodo, + toggleTodo, + toogleAll, + deleteTodo, + deleteComplitedTodo, + updateTodo, + isError, + setIsError, + resetError, + tempoTodo, + setTempoTodo, + inputValue, + setInputValue, + isOnAdd, + setIsOnAdd, + isCompliteDeleting, + isToogleAllClick, + isTodoOnUpdate, + handleAddTodoError, + }; + + if (!USER_ID) { + return ; + } + + return ( + + {children} + + ); +}; + +export const useTodo = (): TodoContextValue => React.useContext(TodoContext); diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..9fb6db630 --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,78 @@ +import classNames from 'classnames'; + +import React from 'react'; +import { Filter } from '../../types/Enum'; +import { useTodo } from '../TodoContext/TodoContext'; + +type Props = { + selectedFilter: string; + selectTheFilter: (filter: Filter) => void; +}; + +export const TodoFooter: React.FC = ({ + selectedFilter, + selectTheFilter, +}) => { + const { + todosCompleted, + todosUncompleted, + deleteComplitedTodo, + } = useTodo(); + + return ( + + ); +}; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..1c86f18e9 --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,92 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; + +import React, { + ChangeEvent, + useEffect, + useRef, + useState, +} from 'react'; +import { useTodo } from '../TodoContext/TodoContext'; + +export const TodoHeader: React.FC = () => { + const { + todos, + todosUncompleted, + toogleAll, + addTodo, + isOnAdd, + inputValue, + setInputValue, + handleAddTodoError, + resetError, + isError, + } = useTodo(); + const inputRef = useRef(null); + const [inputOnFocus, setInputOnFocus] = useState(false); + let timeoutId: NodeJS.Timeout | null; + + const handleInputChange = (e: ChangeEvent) => { + setInputValue(e.target.value); + setInputOnFocus(true); + }; + + const handleTodoAdd = (e: React.FormEvent) => { + e.preventDefault(); + if (!inputValue.trim()) { + handleAddTodoError("Title can't be empty"); + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + resetError(); + }, 3000); + } else { + addTodo(inputValue); + } + }; + + useEffect(() => { + if (isError.isError && inputValue.trim() !== '') { + resetError(); + if (timeoutId) { + clearTimeout(timeoutId); + } + } + }, [inputValue, isError, resetError]); + + useEffect(() => { + if (inputOnFocus && inputRef.current) { + inputRef.current.focus(); + } + }, [todos]); + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..348a30f23 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,137 @@ +import classNames from 'classnames'; + +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { useTodo } from '../TodoContext/TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { + toggleTodo, + deleteTodo, + updateTodo, + isCompliteDeleting, + isToogleAllClick, + isTodoOnUpdate, + } = useTodo(); + const [isLoading, setIsLoading] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [updatedTitle, setUpdatedTitle] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + useEffect(() => { + if (isCompliteDeleting && todo.completed) { + setIsLoading(true); + } + }, [isCompliteDeleting]); + + useEffect(() => { + if (isTodoOnUpdate?.id === todo.id) { + setIsLoading(true); + } + + if (!isTodoOnUpdate) { + setIsLoading(false); + } + }, [isTodoOnUpdate]); + + const handleToggle = () => { + toggleTodo(todo); + }; + + const handleDeleteTodo = () => { + deleteTodo(todo); + }; + + const handleDoubleClick = () => { + setIsEditing(true); + setUpdatedTitle(todo.title); + }; + + const handleTitleChange = (event: React.ChangeEvent) => { + setUpdatedTitle(event.target.value); + }; + + const handleUpdateOrDelete = () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + !updatedTitle.trim() + ? deleteTodo(todo) + : updateTodo(updatedTitle, todo); + }; + + const handleTodoUpdate = (e: React.FormEvent) => { + e.preventDefault(); + setIsEditing(false); + handleUpdateOrDelete(); + }; + + const handleBlur = () => { + setIsEditing(false); + handleUpdateOrDelete(); + }; + + return ( +
+ + + {isEditing ? ( +
+ +
+ ) : ( + <> + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..e51b87910 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import { Filter } from '../../types/Enum'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { TempoTodoItem } from '../TempoTodoItem/TempoTodoItem'; + +type Props = { + todos: Todo[]; + selectedFilter: string; + tempoTodo: Todo | null; +}; + +function filteredTodos(todos: Todo[], selectedFilter: string) { + switch (selectedFilter) { + case Filter.Active: + return todos.filter(item => !item.completed); + case Filter.Complited: + return todos.filter(item => item.completed); + default: + return todos; + } +} + +export const TodoList: React.FC = ({ + todos, + selectedFilter, + tempoTodo, +}) => { + const updatedTodos = filteredTodos(todos, selectedFilter); + + return ( +
+ {updatedTodos.map(todo => ( + + ))} + {tempoTodo !== null && ( + + )} +
+ ); +}; diff --git a/src/components/TodoNotification/TodoNotification.tsx b/src/components/TodoNotification/TodoNotification.tsx new file mode 100644 index 000000000..b020bb4c6 --- /dev/null +++ b/src/components/TodoNotification/TodoNotification.tsx @@ -0,0 +1,34 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; + +import React from 'react'; +import { useTodo } from '../TodoContext/TodoContext'; + +export const TodoNotification: React.FC = () => { + const { + isError, + resetError, + } = useTodo(); + + const handleButtonClick = () => { + resetError(); + }; + + return ( + +
+
+ ); +}; diff --git a/src/types/Enum.ts b/src/types/Enum.ts new file mode 100644 index 000000000..40a7e2815 --- /dev/null +++ b/src/types/Enum.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Complited = 'completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..3f52a5fdd --- /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 000000000..6d5e913e4 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; + +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, +): Promise { + const options: RequestInit = { method }; + + if (data) { + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + 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 33763ea6a102427289152128de913efc1e09e268 Mon Sep 17 00:00:00 2001 From: Dmytro Vovk Date: Mon, 11 Sep 2023 18:45:46 +0300 Subject: [PATCH 2/2] Solution --- src/components/TodoApp/TodoApp.tsx | 2 +- src/components/TodoFooter/TodoFooter.tsx | 2 +- src/components/TodoHeader/TodoHeader.tsx | 4 ++-- src/components/TodoItem/TodoItem.tsx | 11 ++++++----- src/components/TodoList/TodoList.tsx | 2 +- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx index e3f6e9fc1..6efbd86df 100644 --- a/src/components/TodoApp/TodoApp.tsx +++ b/src/components/TodoApp/TodoApp.tsx @@ -18,7 +18,7 @@ export const TodoApp: React.FC = () => {
- {(todos.length > 0 || tempoTodo) && ( + {(!!todos.length || tempoTodo) && ( <> = ({ - {todosCompleted.length > 0 && ( + {!!todosCompleted && (