diff --git a/README.md b/README.md index d3c3756ab..13be1b9df 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://dokrod.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 81e011f43..29e1195a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,88 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useCallback, useState } from 'react'; + +import { Todo } from './types/Todo'; + import { UserWarning } from './UserWarning'; +import { USER_ID } from './api/todos'; -const USER_ID = 0; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoItem } from './components/TodoItem'; +import { ErrorNotification } from './components/ErrorNotification'; +import { useTodos } from './components/hooks/useTodos'; export const App: React.FC = () => { + const { + todos, + errorMessage, + loadingTodosIds, + filteredTodos, + filter, + setTodos, + deleteTodo, + updateTodo, + toggleCompleted, + bulkToggleCompleted, + handleErrorMessage, + handleRemoveError, + handleFilterChange, + } = useTodos(); + const [tempTodo, setTempTodo] = useState(null); + + const handleTempTodo = useCallback( + (todo: Todo | null) => { + setTempTodo(todo); + }, + [setTempTodo], + ); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+
+
+ +
+ {filteredTodos.map(todo => ( + + ))} +
+ + {tempTodo && ( + + )} + + {!!todos.length && ( +
+ )} +
+ +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..cf3554b33 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,34 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1623; + +const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +const createTodo = ({ + title, + userId = USER_ID, + completed, +}: Omit) => { + return client.post(`/todos`, { + title, + userId, + completed, + }); +}; + +const updateTodo = (updatedTodo: Todo) => + client.patch(`/todos/${updatedTodo.id}`, updatedTodo); + +export const todosService = { + getAll: getTodos, + create: createTodo, + delete: deleteTodo, + update: updateTodo, +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 000000000..06638b33a --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,30 @@ +import cn from 'classnames'; +import { ErrorType } from '../types/ErrorType'; +import React from 'react'; + +type Props = { + errorMessage: ErrorType; + handleRemoveError: () => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + handleRemoveError, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..4ae5779b8 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { getFilteredTodos } from '../utils/getFilteredTodo'; +import { FilterType } from '../types/FilterType'; +import cn from 'classnames'; + +type Props = { + todos: Todo[]; + filterBy: FilterType; + setFilter: (filterBy: FilterType) => void; + onDelete: (todoId: number) => Promise; +}; + +export const Footer: React.FC = ({ + todos, + filterBy, + setFilter, + onDelete, +}) => { + const competedTodos = getFilteredTodos(todos, FilterType.completed); + const activeTodos = getFilteredTodos(todos, FilterType.active); + + const filters = Object.values(FilterType); + + const handleDeleteAllCompleted = () => { + competedTodos.map(todo => onDelete(todo.id)); + }; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..4fa0d5ca4 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; + +import { Todo } from '../types/Todo'; +import { ErrorType } from '../types/ErrorType'; +import { todosService, USER_ID } from '../api/todos'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + setTodos: (updateTodos: (todos: Todo[]) => Todo[]) => void; + setTempTodo: (todo: Todo | null) => void; + onError: (error: ErrorType) => void; + bulkToggleCompleted: () => void; +}; + +export const Header: React.FC = ({ + todos, + tempTodo, + setTodos, + onError, + setTempTodo, + bulkToggleCompleted, +}) => { + const [title, setTitle] = useState(''); + + const inputRef = useRef(null); + + const allIsCompleted = todos.every(todo => todo.completed); + + const handleChangeTitle = (event: React.ChangeEvent) => { + setTitle(event.target.value); + onError(ErrorType.DEFAULT); + }; + + const reset = () => { + setTitle(''); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const normalizeTitle = title.trim(); + + if (!normalizeTitle) { + onError(ErrorType.TITLE); + + return; + } + + const newTodo = { + id: 0, + title: normalizeTitle, + userId: USER_ID, + completed: false, + }; + + setTempTodo(newTodo); + + try { + const todo = await todosService.create(newTodo); + + setTodos(currentTodos => [...currentTodos, todo]); + reset(); + } catch { + onError(ErrorType.ADD); + setTempTodo(null); + } finally { + setTempTodo(null); + } + }; + + useEffect(() => { + inputRef.current?.focus(); + }, [todos, tempTodo]); + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..1244cb5d8 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,139 @@ +import React, { + FormEvent, + useState, + KeyboardEvent, + useRef, + useEffect, +} from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + loadingTodosIds: number[]; + onDelete?: (todoId: number) => Promise; + onUpdate?: (todoToUpdate: Todo) => Promise; + onChangeCompleted?: (todo: Todo) => void; +}; + +export const TodoItem: React.FC = ({ + todo, + loadingTodosIds, + onDelete = () => {}, + onUpdate = () => {}, + onChangeCompleted = () => {}, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + } + }, [isEditing]); + + const handleRename = () => { + const preparedNewTitle = newTitle.trim(); + + if (!preparedNewTitle) { + onDelete(todo.id); + + return; + } + + if (preparedNewTitle === todo.title) { + setIsEditing(false); + setNewTitle(todo.title); + + return; + } + + const renemedTodo: Todo = { + ...todo, + title: preparedNewTitle, + }; + + onUpdate(renemedTodo)! + .then(() => { + setIsEditing(false); + }) + .catch(() => { + setIsEditing(true); + inputRef.current?.focus(); + }); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + handleRename(); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setNewTitle(todo.title); + } + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {isEditing ? ( +
+ setNewTitle(event.target.value.trimStart())} + onBlur={handleRename} + onKeyUp={handleEscape} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + + )} +
+
+
+
+
+ ); +}; diff --git a/src/components/hooks/useTodos.ts b/src/components/hooks/useTodos.ts new file mode 100644 index 000000000..0f530e4b1 --- /dev/null +++ b/src/components/hooks/useTodos.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { ErrorType } from '../../types/ErrorType'; +import { fetchTodos } from '../../utils/fetchTodos'; +import { FilterType } from '../../types/FilterType'; +import { getFilteredTodos } from '../../utils/getFilteredTodo'; +import { todosService } from '../../api/todos'; + +export const useTodos = () => { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState( + ErrorType.DEFAULT, + ); + const [loadingTodosIds, setLoadingTodosIds] = useState([]); + const [filter, setFilter] = useState(FilterType.all); + + const timeoutRef = useRef(null); + + const activeTodos = useMemo( + () => todos.filter(todo => !todo.completed), + [todos], + ); + const completedTodos = useMemo( + () => todos.filter(todo => todo.completed), + [todos], + ); + + const handleRemoveError = useCallback(() => { + setErrorMessage(ErrorType.DEFAULT); + }, []); + + const handleErrorMessage = useCallback( + (error: ErrorType) => { + setErrorMessage(error); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + handleRemoveError(); + timeoutRef.current = null; + }, 3000); + }, + [handleRemoveError], + ); + + const handleFilterChange = useCallback( + (filterBy: FilterType) => { + setFilter(filterBy); + }, + [setFilter], + ); + + const filteredTodos = useMemo( + () => getFilteredTodos(todos, filter), + [todos, filter], + ); + + const deleteTodo = (todoId: number) => { + setLoadingTodosIds(current => [...current, todoId]); + + return todosService + .delete(todoId) + .then(() => { + setTodos(current => current.filter(todo => todo.id !== todoId)); + }) + .catch(() => { + handleErrorMessage(ErrorType.DELETE); + }) + .finally(() => { + setLoadingTodosIds(current => current.filter(id => id !== todoId)); + }); + }; + + useEffect(() => { + fetchTodos(setTodos, handleErrorMessage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updateTodo = (todoToUpdate: Todo) => { + setLoadingTodosIds(current => [...current, todoToUpdate.id]); + + return todosService + .update(todoToUpdate) + .then(updatedTodo => { + setTodos(current => + current.map(todo => { + return updatedTodo.id === todo.id ? updatedTodo : todo; + }), + ); + }) + .catch(error => { + handleErrorMessage(ErrorType.UPDATE); + throw error; + }) + .finally(() => { + setLoadingTodosIds(current => + current.filter(id => id !== todoToUpdate.id), + ); + }); + }; + + const toggleCompleted = (todo: Todo) => { + const updatedTodo: Todo = { + ...todo, + completed: !todo.completed, + }; + + updateTodo(updatedTodo); + }; + + const bulkToggleCompleted = () => { + if (activeTodos.length > 0) { + activeTodos.forEach(todo => toggleCompleted(todo)); + } else { + completedTodos.forEach(todo => toggleCompleted(todo)); + } + }; + + return { + todos, + errorMessage, + loadingTodosIds, + filteredTodos, + filter, + setTodos, + deleteTodo, + updateTodo, + toggleCompleted, + bulkToggleCompleted, + handleErrorMessage, + handleRemoveError, + handleFilterChange, + } as const; +}; diff --git a/src/types/ErrorType.ts b/src/types/ErrorType.ts new file mode 100644 index 000000000..2ab397725 --- /dev/null +++ b/src/types/ErrorType.ts @@ -0,0 +1,8 @@ +export enum ErrorType { + DEFAULT = '', + LOAD = 'Unable to load todos', + TITLE = 'Title should not be empty', + ADD = 'Unable to add a todo', + DELETE = 'Unable to delete a todo', + UPDATE = 'Unable to update a todo', +} diff --git a/src/types/FilterType.ts b/src/types/FilterType.ts new file mode 100644 index 000000000..f94eb1d8e --- /dev/null +++ b/src/types/FilterType.ts @@ -0,0 +1,5 @@ +export enum FilterType { + all = 'All', + active = 'Active', + completed = '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..5be775084 --- /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(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/fetchTodos.ts b/src/utils/fetchTodos.ts new file mode 100644 index 000000000..e3c3e8040 --- /dev/null +++ b/src/utils/fetchTodos.ts @@ -0,0 +1,14 @@ +import { Dispatch, SetStateAction } from 'react'; +import { todosService } from '../api/todos'; +import { ErrorType } from '../types/ErrorType'; +import { Todo } from '../types/Todo'; + +export const fetchTodos = ( + setTodos: Dispatch>, + handleErrorMessage: (error: ErrorType) => void, +) => { + todosService + .getAll() + .then(setTodos) + .catch(() => handleErrorMessage(ErrorType.LOAD)); +}; diff --git a/src/utils/getFilteredTodo.ts b/src/utils/getFilteredTodo.ts new file mode 100644 index 000000000..76fff4c92 --- /dev/null +++ b/src/utils/getFilteredTodo.ts @@ -0,0 +1,13 @@ +import { FilterType } from '../types/FilterType'; +import { Todo } from '../types/Todo'; + +export const getFilteredTodos = (todos: Todo[], filterBy: FilterType) => { + switch (filterBy) { + case FilterType.completed: + return todos.filter(todo => todo.completed === true); + case FilterType.active: + return todos.filter(todo => todo.completed === false); + default: + return todos; + } +};