diff --git a/README.md b/README.md index af7dae81f6..3455fdb3d5 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://DenisGurskiy.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 5749bdf784..d52ae97a73 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,12 @@ -/* 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 { TodosProvider } from './TodosContext'; +import { TodoApp } from './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/TodoApp/TodoApp.tsx b/src/TodoApp/TodoApp.tsx new file mode 100644 index 0000000000..6bcb604c70 --- /dev/null +++ b/src/TodoApp/TodoApp.tsx @@ -0,0 +1,302 @@ +/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { + useContext, useEffect, useState, +} from 'react'; +import classNames from 'classnames'; + +import { Filter } from '../types/Filter'; +import { TodosFilter } from '../components/TodosFilter/TodosFilter'; +import { TodosList } from '../components/TodosList/TodosList'; +import { TodosContext } from '../TodosContext'; +import { USER_ID } from '../utils/userId'; +import { addTodos, deleteTodo, updateTodo } from '../api/todos'; +import { Todo } from '../types/Todo'; + +export const TodoApp: React.FC = () => { + const { + todos, + setTodos, + errorMessage, + setErrorMessage, + errorDiv, + inputTitle, + tempTodo, + setTempTodo, + } = useContext(TodosContext); + const [title, setTitle] = useState(''); + const [filter, setFilter] = useState(Filter.ALL); + const [isDeleteCompleted, setIsDeleteCompleted] = useState(false); + const [toggleCompletedArray, setToggleCompletedArray] = useState([]); + + const noCompleteTodos = todos.filter(elem => !elem.completed); + const completeTodos = todos.filter(elem => elem.completed); + const isSomeComplete = todos.some(todo => todo.completed === true); + const allCompleted = todos.every(todo => todo.completed === true); + + useEffect(() => { + if (inputTitle.current !== null) { + inputTitle.current.focus(); + } + }, []); + + const handlerFilterChange = (newFilter: string) => { + setFilter(newFilter); + }; + + const handlerChangeTitle = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const handlerAddTodo = (event: React.FormEvent) => { + event.preventDefault(); + if (title.trim() !== '') { + const newTask = { + userId: USER_ID, + title: title.trim(), + completed: false, + }; + + if (inputTitle.current !== null) { + inputTitle.current.disabled = true; + } + + setTempTodo({ + ...newTask, + id: 0, + }); + addTodos(newTask) + .then(newTodo => { + setTitle(''); + setTodos((prevTodos) => [...prevTodos, newTodo]); + }) + .catch(() => { + setErrorMessage('Unable to add a todo'); + setTempTodo(null); + if (errorDiv.current !== null) { + errorDiv.current.classList.remove('hidden'); + setTimeout(() => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }, 3000); + } + }) + .finally(() => { + if (inputTitle.current !== null) { + inputTitle.current.disabled = false; + inputTitle.current.focus(); + } + + setTempTodo(null); + }); + } else { + setErrorMessage('Title should not be empty'); + if (errorDiv.current !== null) { + errorDiv.current.classList.remove('hidden'); + setTimeout(() => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }, 3000); + } + } + }; + + const handlerCompleteAll = () => { + todos.forEach((eachTodo) => { + if (allCompleted) { + setToggleCompletedArray([-1]); + updateTodo({ ...eachTodo, completed: false }) + .then(() => { + setTodos((currentTodos: Todo[]) => currentTodos + .map(elem => ({ ...elem, completed: false }))); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + if (errorDiv.current !== null) { + errorDiv.current.classList.remove('hidden'); + setTimeout(() => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }, 3000); + } + }) + .finally(() => setToggleCompletedArray([])); + } else if (!eachTodo.completed) { + setToggleCompletedArray(currentIds => [...currentIds, eachTodo.id]); + updateTodo({ ...eachTodo, completed: true }) + .then(() => { + setTodos((currentTodos: Todo[]) => currentTodos + .map(elem => ({ ...elem, completed: true }))); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + if (errorDiv.current !== null) { + errorDiv.current.classList.remove('hidden'); + setTimeout(() => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }, 3000); + } + }) + .finally(() => setToggleCompletedArray([])); + } + }); + if (todos.some(todo => todo.completed === false)) { + const updatedTodos = todos.map((todo) => ({ + ...todo, + completed: true, + })); + + setTodos(updatedTodos); + } else { + const updatedTodos = todos.map((todo) => ({ + ...todo, + completed: false, + })); + + setTodos(updatedTodos); + } + }; + + const handlerClearCompletes = () => { + completeTodos.forEach((eachTodo) => { + setIsDeleteCompleted(true); + + deleteTodo(eachTodo.id) + .then(() => { + setTodos((currentTodos: Todo[]) => currentTodos + .filter(elem => elem.id !== eachTodo.id)); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + if (errorDiv.current !== null) { + errorDiv.current.classList.remove('hidden'); + setTimeout(() => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }, 3000); + } + }) + .finally(() => setIsDeleteCompleted(false)); + }); + }; + + const filteredTodos = todos.filter((todo) => { + if (filter === Filter.ALL) { + return true; + } + + if (filter === Filter.ACTIVE) { + return !todo.completed; + } + + return todo.completed; + }); + + const handlerCloseError = () => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }; + + return ( +
+

todos

+ +
+
+ {todos.length !== 0 && ( +
+ + {(todos.length !== 0 || tempTodo !== null) && ( + + )} + + {(todos.length !== 0 || tempTodo !== null) && ( +
+ + {`${noCompleteTodos.length} items left`} + + + + + {/* don't show this button if there are no completed todos */} + +
+ )} +
+ + {/* Notification is shown in case of any error */} + {/* Add the 'hidden' class to hide the message smoothly */} +
+
+ +
+ + ); +}; diff --git a/src/TodosContext.tsx b/src/TodosContext.tsx new file mode 100644 index 0000000000..c00633382f --- /dev/null +++ b/src/TodosContext.tsx @@ -0,0 +1,72 @@ +import React, { + createContext, useEffect, useRef, useState, +} from 'react'; +import { Todo } from './types/Todo'; +import { getTodos } from './api/todos'; +import { USER_ID } from './utils/userId'; + +interface TodosContextProps { + children: React.ReactNode; +} + +export const TodosContext = createContext<{ + todos: Todo[]; + setTodos: React.Dispatch>; + errorMessage: string, + setErrorMessage: React.Dispatch>; + errorDiv: React.RefObject; + inputTitle: React.RefObject; + tempTodo: Todo | null; + setTempTodo: React.Dispatch> +}>({ + todos: [], + setTodos: () => { }, + errorMessage: '', + setErrorMessage: () => { }, + errorDiv: { current: null }, + inputTitle: { current: null }, + tempTodo: null, + setTempTodo: () => { }, +}); + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + + const inputTitle = useRef(null); + const errorDiv = useRef(null); + + useEffect(() => { + getTodos(USER_ID) + .then(setTodos) + .catch(() => { + setErrorMessage('Unable to load todos'); + if (errorDiv.current !== null) { + errorDiv.current.classList.remove('hidden'); + setTimeout(() => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }, 3000); + } + }); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..b498d6c595 --- /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 addTodos = ({ userId, title, completed }: Omit) => { + return client.post('/todos', { userId, title, completed }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = ({ + id, userId, title, completed, +}: Todo) => { + return client.patch(`/todos/${id}`, { userId, title, completed }); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..5a20da2100 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,267 @@ +import classNames from 'classnames'; +import { useContext, useRef, useState } from 'react'; + +import { Todo } from '../../types/Todo'; +import { deleteTodo, updateTodo } from '../../api/todos'; +import { TodosContext } from '../../TodosContext'; + +type Props = { + todo: Todo, + isProcessed?: boolean, +}; + +export const TodoItem: React.FC = ({ + todo, + isProcessed = false, +}) => { + const { + todos, + setTodos, + setErrorMessage, + errorDiv, + inputTitle, + } = useContext(TodosContext); + const [isLoading, setIsLoading] = useState(false); + const [newTitle, setNewTitle] = useState(''); + const [isEditing, setIsEditing] = useState(false); + + const editInput = useRef(null); + + const handlerCompleteTodo = () => { + const updatedTodos = [...todos]; + const currentTodoIndex = updatedTodos + .findIndex((elem: Todo) => elem.id === todo.id); + + if (currentTodoIndex !== -1) { + const newCompleted = !updatedTodos[currentTodoIndex].completed; + + updatedTodos[currentTodoIndex] = { + ...updatedTodos[currentTodoIndex], + completed: newCompleted, + }; + + updatedTodos + .splice(currentTodoIndex, 1, updatedTodos[currentTodoIndex]); + } + + setIsLoading(true); + updateTodo(updatedTodos[currentTodoIndex]) + .then(() => { + setTodos(updatedTodos); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + if (errorDiv.current !== null) { + errorDiv.current.classList.remove('hidden'); + setTimeout(() => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }, 3000); + } + }) + .finally(() => setIsLoading(false)); + }; + + const handlerDeleteTodo = () => { + setIsLoading(true); + deleteTodo(todo.id) + .then(() => { + setTodos((currentTodos: Todo[]) => currentTodos + .filter(elem => elem.id !== todo.id)); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + if (errorDiv.current !== null) { + errorDiv.current.classList.remove('hidden'); + setTimeout(() => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }, 3000); + } + }) + .finally(() => setIsLoading(false)); + }; + + const handlerEditTitle = (event: React.ChangeEvent) => { + setNewTitle(event.target.value); + }; + + const handlerEndEditTodoOnBlur = () => { + if (isEditing) { + if (newTitle.trim() !== '') { + if (newTitle.trim() !== todo.title) { + const updatedTodos = [...todos]; + const currentTodoIndex = updatedTodos + .findIndex((elem: Todo) => elem.id === todo.id); + + if (currentTodoIndex !== -1) { + updatedTodos[currentTodoIndex] = { + ...updatedTodos[currentTodoIndex], + title: newTitle.trim(), + }; + updatedTodos + .splice(currentTodoIndex, 1, updatedTodos[currentTodoIndex]); + } + + setTodos(updatedTodos); + setIsLoading(true); + updateTodo(updatedTodos[currentTodoIndex]) + .then(() => { + setIsEditing(false); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + if (errorDiv.current !== null) { + errorDiv.current.classList.remove('hidden'); + setTimeout(() => { + if (errorDiv.current !== null) { + errorDiv.current.classList.add('hidden'); + setErrorMessage(''); + } + }, 3000); + } + + if (editInput.current !== null) { + editInput.current.focus(); + } + + setTodos(todos); + }) + .finally(() => setIsLoading(false)); + } else { + setIsEditing(false); + setNewTitle(''); + } + } else { + handlerDeleteTodo(); + if (inputTitle.current !== null) { + inputTitle.current.focus(); + } + + setIsEditing(false); + } + } + }; + + const handlerKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape' && isEditing) { + setIsEditing(false); + } else if (event.key === 'Enter' && isEditing) { + handlerEndEditTodoOnBlur(); + } + }; + + const handlerEditTodo = () => { + setNewTitle(todo.title); + setIsEditing(true); + + setTimeout(() => { + if (editInput.current !== null) { + editInput.current.focus(); + } + }, 0); + }; + + const handlerSaveEditTodo = (event: React.ChangeEvent) => { + event.preventDefault(); + }; + + return ( +
+ + + {isEditing + ? ( +
+ +
+ ) : ( + <> + + {todo.title} + + + + + )} + +
+
+
+
+
+ // ) + ); +}; + +// {/* This todo is being edited */ } +// < div data-cy="Todo" className = "todo" > +// + +// {/* This form is shown instead of the title and remove button */ } +//
+// +//
+ +//
+//
+//
+//
+//
diff --git a/src/components/TodosFilter/TodosFilter.tsx b/src/components/TodosFilter/TodosFilter.tsx new file mode 100644 index 0000000000..099ed5ed01 --- /dev/null +++ b/src/components/TodosFilter/TodosFilter.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; +import { Filter } from '../../types/Filter'; + +type Props = { + currentFilter: string, + onFilterChange?: (filter: string) => void +}; + +export const TodosFilter: React.FC = ({ + currentFilter, + onFilterChange = () => { }, +}) => { + return ( + + ); +}; diff --git a/src/components/TodosList/TodosList.tsx b/src/components/TodosList/TodosList.tsx new file mode 100644 index 0000000000..66ce6161c5 --- /dev/null +++ b/src/components/TodosList/TodosList.tsx @@ -0,0 +1,57 @@ +import { useContext } from 'react'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { TodosContext } from '../../TodosContext'; + +type Props = { + todos: Todo[], + isDeleting: boolean, + ToggleComplete: number[], +}; + +export const TodosList: React.FC = ({ + todos, + isDeleting, + ToggleComplete, +}) => { + const { tempTodo } = useContext(TodosContext); + + const isProcessed = tempTodo !== null; + + return ( +
+ + {todos.map((todo: Todo) => ( + + + + ))} + + {tempTodo !== null && ( + + + + )} + + + +
+ ); +}; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..4730e848c9 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export const Filter = { + ALL: '', + ACTIVE: 'active', + COMPLETED: 'completed', +}; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..3f52a5fdde --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..ca588ab63a --- /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(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/userId.ts b/src/utils/userId.ts new file mode 100644 index 0000000000..65e825b8cb --- /dev/null +++ b/src/utils/userId.ts @@ -0,0 +1 @@ +export const USER_ID = 11625;