diff --git a/README.md b/README.md index d3c3756ab9..3303569c99 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,4 @@ Implement the ability to edit a todo title on double click: - Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). -- Replace `` with your Github username in the [DEMO LINK](https://.github.io/react_todo-app-with-api/) and add it to the PR description. +- Replace `` with your Github username in the [DEMO LINK](https://imondok03.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 81e011f432..20858a862c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,164 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import * as todoService from './api/todos'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { Todo } from './types/Todo'; +import { Filter } from './types/Filter'; +import { Header } from './components/Header/Header'; +import { TodoList } from './components/TodoList/TodoList'; +import classNames from 'classnames'; +import { Footer } from './components/Footer/Footer'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState(Filter.all); + const [errorMessage, setErrorMessage] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [todosInProcess, setTodosInProcess] = useState([]); + + useEffect(() => { + todoService + .getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage('Unable to load todos'); + setTimeout(() => setErrorMessage(''), 3000); + }); + }, []); + const addTodo = ({ userId, title, completed }: Omit) => { + setErrorMessage(''); + + return todoService + .postTodos({ userId, title, completed }) + .then(newTitle => { + setTodos(currentTodo => [...currentTodo, newTitle]); + }) + .catch(error => { + setErrorMessage('Unable to add a todo'); + setTimeout(() => setErrorMessage(''), 3000); + throw error; + }) + .finally(() => { + setTempTodo(null); + }); + }; + + const deleteTodo = (postId: number) => { + setTodosInProcess(currentId => [...currentId, postId]); + + return todoService + .deleteTodos(postId) + .then(() => { + setTodos(currentTodo => currentTodo.filter(todo => todo.id !== postId)); + }) + .catch(error => { + setErrorMessage('Unable to delete a todo'); + setTimeout(() => setErrorMessage(''), 3000); + throw error; + }) + .finally(() => { + setTodosInProcess(currentId => currentId.filter(id => id !== postId)); + }); + }; + + const updateTodo = ( + todoId: number, + newTitle: string, + completed?: boolean, + ) => { + const todoToUpdate = todos.find(todo => todo.id === todoId); + + if (!todoToUpdate) { + return; + } + + const trimmedTitle = newTitle.trim(); + const updatedTodo = { + ...todoToUpdate, + title: trimmedTitle, + completed: completed ?? todoToUpdate.completed, + }; + + setTodosInProcess(currentId => [...currentId, todoId]); + + return todoService + .updateTodos(todoId, updatedTodo) + .then(() => { + setTodos(currentTodos => + currentTodos.map(todo => (todo.id === todoId ? updatedTodo : todo)), + ); + }) + .catch(error => { + setErrorMessage('Unable to update a todo'); + setTimeout(() => setErrorMessage(''), 3000); + throw error; + }) + .finally(() => { + setTodosInProcess(currentId => currentId.filter(id => id !== todoId)); + }); + }; + + const filteredTodos = useMemo(() => { + return todos.filter(todo => { + if (filter === Filter.active) { + return !todo.completed; + } + + if (filter === Filter.completed) { + return todo.completed; + } + + return true; + }); + }, [todos, filter]); + + if (!todoService.USER_ID) { return ; } 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/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..7239e4fe09 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,20 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1795; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const postTodos = ({ userId, title, completed }: Omit) => { + return client.post('/todos', { userId, title, completed }); +}; + +export const deleteTodos = (postId: number) => { + return client.delete(`/todos/${postId}`); +}; + +export const updateTodos = (todoId: number, todo: Todo) => { + return client.patch(`/todos/${todoId}`, todo); +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..a6603acc0b --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import cn from 'classnames'; +import { Filter } from '../../types/Filter'; +import { Todo } from '../../types/Todo'; + +type Props = { + todos: Todo[]; + onFilter: React.Dispatch>; + filter: string; + onDelete: (postId: number) => Promise; +}; + +export const Footer: React.FC = ({ + todos, + onFilter, + filter, + onDelete, +}) => { + const hasCompletedTodos = todos + .filter(todo => todo.completed) + .map(todo => todo.id); + + const handleDeleteAllCompleted = (todosId: number[]) => { + todosId.map(id => onDelete(id)); + }; + + return ( +
+ + {todos.filter(todo => !todo.completed).length} items left + + + + +
+ ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..213828d688 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,113 @@ +import cn from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { USER_ID } from '../../api/todos'; + +type Props = { + todos: Todo[]; + onErrorMessage: React.Dispatch>; + onSubmit: ({ userId, title, completed }: Omit) => Promise; + setTempTodo: React.Dispatch>; + todosInProcess: number[]; + updateTodo: ( + todoId: number, + newTitle: string, + completed?: boolean, + ) => Promise | undefined; + errorMessage: string; +}; + +export const Header: React.FC = ({ + todos, + onErrorMessage, + onSubmit, + setTempTodo, + todosInProcess, + updateTodo, + errorMessage, +}) => { + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [isSubmiting, setIsSubmiting] = useState(false); + const titleFiled = useRef(null); + + useEffect(() => { + if (titleFiled.current) { + titleFiled.current.focus(); + } + }, [todos, errorMessage]); + + const allChecked = todos.every(todo => todo.completed); + const handleNewTodoTitle = (event: React.ChangeEvent) => { + setNewTodoTitle(event.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmedTitle = newTodoTitle.trim(); + + if (!trimmedTitle) { + onErrorMessage('Title should not be empty'); + setTimeout(() => onErrorMessage(''), 3000); + + return; + } + + setIsSubmiting(true); + setTempTodo({ + id: 0, + title: trimmedTitle, + completed: false, + userId: USER_ID, + }); + onSubmit({ + userId: USER_ID, + title: trimmedTitle, + completed: false, + }) + .then(() => { + setNewTodoTitle(''); + }) + .finally(() => { + setIsSubmiting(false); + }); + }; + + const handleToggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + const newCompletedStatus = !allCompleted; + const todosToUpdate = todos.filter( + todo => todo.completed !== newCompletedStatus, + ); + + todosToUpdate.forEach(todo => { + updateTodo(todo.id, todo.title, newCompletedStatus); + }); + }; + + const shouldShowToggleAllButton = !!todos.length && !todosInProcess.length; + + return ( +
+ {shouldShowToggleAllButton && ( +
+ ); +}; diff --git a/src/components/TempTodo/TempTodo.tsx b/src/components/TempTodo/TempTodo.tsx new file mode 100644 index 0000000000..0a4e1278f8 --- /dev/null +++ b/src/components/TempTodo/TempTodo.tsx @@ -0,0 +1,37 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { Todo } from '../../types/Todo'; + +type Props = { + tempTitle: Todo; +}; + +export const TempTodo: React.FC = ({ tempTitle }) => { + return ( +
+ + + + {tempTitle.title} + + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..b16d3a9db6 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,112 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import cn from 'classnames'; +import { useState } from 'react'; +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; + onDelete: (postId: number) => Promise; + todosInProcess: number[]; + updateTodo: ( + todoId: number, + newTitle: string, + completed?: boolean, + ) => Promise | undefined; +}; +export const TodoItem: React.FC = ({ + todo: { completed, title, id }, + onDelete, + todosInProcess, + updateTodo, +}) => { + const [selectTitle, setSelectTitle] = useState(title); + const [changeTitle, setChangeTitle] = useState(false); + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmedTitle = selectTitle.trim(); + + if (trimmedTitle === '') { + onDelete(id); + } else { + setChangeTitle(false); + updateTodo(id, trimmedTitle) + ?.then(() => { + setSelectTitle(trimmedTitle); + }) + .catch(() => { + setChangeTitle(true); + }); + } + }; + + const handleChangeChecked = (event: React.ChangeEvent) => { + updateTodo(id, selectTitle, event.target.checked); + }; + + const handleDelete = () => { + onDelete(id); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setSelectTitle(title); + setChangeTitle(false); + } + }; + + return ( +
+ + {changeTitle ? ( +
+ setSelectTitle(event.target.value)} + /> +
+ ) : ( + <> + setChangeTitle(true)} + > + {selectTitle} + + + + )} +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..2f1a1392f9 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,38 @@ +import { Todo } from '../../types/Todo'; +import { TempTodo } from '../TempTodo/TempTodo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + onDelete: (postId: number) => Promise; + todosInProcess: number[]; + updateTodo: ( + todoId: number, + newTitle: string, + completed?: boolean, + ) => Promise | undefined; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + onDelete, + todosInProcess, + updateTodo, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + {tempTodo && } +
+ ); +}; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..85382cc6a2 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + all = '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..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'), +};