diff --git a/README.md b/README.md index d3c3756ab9..c4959b44aa 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://StanislavKapytsia.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..ea033c88ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,326 @@ -/* 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 React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { addTodo, delTodo, get, updTodo } from './api/todos'; +import { TodoInterface } from './types/Todo'; +import { Filter } from './types/filter'; +import { TodoList } from './components/todoList/TodoList'; +import { FilteredTodoList } from './components/footer/FilteredTodoList'; +import classNames from 'classnames'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState(Filter.All); + const [value, setValue] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [todosForModify, setTodosForModify] = useState([]); + + const inputForFocusRef = useRef(null); + const notificationRef = useRef(null); + + const hideNotification = useCallback(() => { + if (notificationRef.current) { + notificationRef.current.classList.add('hidden'); + } + }, []); + + const errorHandling = (error: Error) => { + if (notificationRef.current) { + notificationRef.current.classList.remove('hidden'); + setErrorMessage(error.message); + setTimeout(() => { + if (notificationRef.current) { + notificationRef.current.classList.add('hidden'); + } + }, 3000); + } + }; + + const newId = () => { + const maxId = Math.max(0, ...todos.map(todo => todo.id)); + + return maxId + 1; + }; + + const errorManagement = (er: unknown) => { + if (er instanceof Error) { + errorHandling(er); + } + }; + + const createTodo = (id: number, title: string): TodoInterface => ({ + id, + userId: 2039, + title: title.trim(), + completed: false, + }); + + useEffect(() => { + if (inputForFocusRef.current) { + inputForFocusRef.current.focus(); + } + + const fetchTodos = async () => { + try { + hideNotification(); + + const data = await get(); + + setTodos(data); + } catch (error) { + if (error instanceof Error) { + errorHandling(error); + } + } + }; + + fetchTodos(); + }, [hideNotification]); + + const allTodosComplited = useMemo(() => { + return todos.every(item => item.completed); + }, [todos]); + + const [active, setActive] = useState(allTodosComplited); + + useEffect(() => { + setActive(allTodosComplited); + }, [allTodosComplited]); + + useEffect(() => { + if (inputForFocusRef.current && !tempTodo) { + inputForFocusRef.current.focus(); + } + }, [tempTodo]); + + const deleteTodos = async (content: number[], inputEdit?: boolean) => { + for (const todoId of content) { + try { + hideNotification(); + await delTodo(todoId); + // await new Promise(resolve => setTimeout(resolve, 2000)); for checking delete/save every single todo; + + setTodos(current => current.filter(item => todoId !== item.id)); + } catch (error) { + errorManagement(error); + } + } + + if (inputForFocusRef.current && !inputEdit) { + inputForFocusRef.current.focus(); + } + + setTodosForModify([]); + }; + + const addTodos = async (data: string) => { + if (inputForFocusRef.current) { + inputForFocusRef.current.focus(); + } + + setTempTodo(() => createTodo(0, data)); + + try { + hideNotification(); + + await addTodo(data); + + const newTodo = createTodo(newId(), data); + + setTodos(current => [...current, newTodo]); + + setValue(''); + } catch (error) { + errorManagement(error); + setValue(value); + } finally { + setTempTodo(null); + } + }; + + const updateTodos = async (updateTodo: TodoInterface[]) => { + const updatedTodos: TodoInterface[] = []; + + for (const upTodo of updateTodo) { + try { + const updatedTodo = (await updTodo(upTodo)) as TodoInterface; + + setTodos(current => { + const newTodosList = [...current]; + const index = newTodosList.findIndex(todo => todo.id === upTodo.id); + + newTodosList.splice(index, 1, updatedTodo); + + return newTodosList; + }); + + updatedTodos.push(updatedTodo); + + if (inputForFocusRef.current) { + inputForFocusRef.current.focus(); + } + } catch (error) { + errorManagement(error); + } + } + + setTodosForModify([]); + + return updatedTodos; + }; + + const handleClose = () => { + if (notificationRef.current) { + notificationRef.current.classList.add('hidden'); + } + }; + + const filteredTodos = useMemo(() => { + return todos.filter(todo => { + switch (filter) { + case Filter.All: + return true; + case Filter.Active: + return !todo.completed; + case Filter.Completed: + return todo.completed; + default: + return true; + } + }); + }, [todos, filter]); + + const handleChangeValue = (e: React.ChangeEvent) => { + if (notificationRef.current) { + notificationRef.current.classList.add('hidden'); + } + + setValue(e.target.value); + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (value.trim()) { + addTodos(value); + } else { + const empty = new Error('Title should not be empty'); + + errorHandling(empty); + setValue(''); + } + }; + + const handleUpdateStatus = async () => { + const copyTodos = [...todos]; + + let content; + + if (active) { + content = copyTodos.map(item => ({ ...item, completed: false })); + } else { + content = copyTodos + .filter(todo => !todo.completed) + .map(item => ({ ...item, completed: true })); + } + + setTodosForModify(content); + + await Promise.allSettled(content.map(todo => updateTodos([todo]))).then( + results => { + const successfulResults = results + .filter(result => result.status === 'fulfilled') + .flatMap(result => result.value); + + setTodos(currnet => { + const newList = [...currnet]; + + successfulResults.forEach(updatedTodo => { + const index = newList.findIndex(todo => todo.id === updatedTodo.id); + + if (index !== -1) { + newList[index] = updatedTodo; + } + }); + + return newList; + }); + }, + ); + + setTodosForModify([]); + }; return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {todos.length > 0 && ( +
+ + {todos.length > 0 && ( + + )} + + {todos.length > 0 && ( + + )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..1290f84f28 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,55 @@ +import { TodoInterface } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 2039; + +export const get = async () => { + try { + const todos = await client.get(`/todos?userId=${USER_ID}`); + + return todos; + } catch (error) { + throw new Error('Unable to load todos'); + } +}; + +export const delTodo = async (id: number) => { + try { + const deleteTodo = await client.delete(`/todos/${id}`); + + return deleteTodo; + } catch (error) { + throw new Error('Unable to delete a todo'); + } +}; + +export const addTodo = async (title: string) => { + try { + const adTodo = await client.post(`/todos/`, { + completed: false, + title, + userId: 2039, + }); + + return adTodo; + } catch (error) { + throw new Error('Unable to add a todo'); + } +}; + +export const updTodo = async (updateTodo: TodoInterface) => { + const { id, title, completed, userId } = updateTodo; + + try { + const upTodo = await client.patch(`/todos/${id}`, { + id, + completed, + title, + userId, + }); + + return upTodo; + } catch (error) { + throw new Error('Unable to update a todo'); + } +}; diff --git a/src/components/footer/FilteredTodoList.tsx b/src/components/footer/FilteredTodoList.tsx new file mode 100644 index 0000000000..2f87ab6bd3 --- /dev/null +++ b/src/components/footer/FilteredTodoList.tsx @@ -0,0 +1,73 @@ +import { useMemo } from 'react'; +import { TodoInterface } from '../../types/Todo'; +import cn from 'classnames'; +import { Filter } from '../../types/filter'; + +interface Props { + todos: TodoInterface[]; + setFilter: React.Dispatch>; + filter: string; + deleteTodos: (content: number[]) => Promise; + setTodosForModify: React.Dispatch>; +} + +export const FilteredTodoList: React.FC = ({ + todos, + + filter, + setFilter, + + deleteTodos, + + setTodosForModify, +}) => { + const countNotCompletedItem = useMemo(() => { + const filtered = todos.filter(todo => !todo.completed); + + return filtered.length; + }, [todos]); + + const handledeleteTodos = () => { + setTodosForModify(() => { + return todos.filter(todo => todo.completed); + }); + + const content = todos.filter(todo => todo.completed).map(todo => todo.id); + + deleteTodos(content); + }; + + return ( + + ); +}; diff --git a/src/components/todo/Todo.tsx b/src/components/todo/Todo.tsx new file mode 100644 index 0000000000..ede9503ee1 --- /dev/null +++ b/src/components/todo/Todo.tsx @@ -0,0 +1,211 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; +import { TodoInterface } from '../../types/Todo'; + +interface Props { + todo: TodoInterface; + deleteTodos: (content: number[], inputEdit?: boolean) => Promise; + + setTodosForModify: React.Dispatch>; + todosForModify: TodoInterface[]; + + updateTodos: (updateTodo: TodoInterface[]) => Promise; +} + +export const Todo: React.FC = ({ + todo, + deleteTodos, + setTodosForModify, + todosForModify, + updateTodos, +}) => { + const [value, setValue] = useState(todo.title); + const [canEdit, setCanEdit] = useState(false); + + const inputRef = useRef(null); + + const prevValue = useRef(null); + + const { title, completed, id } = todo; + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }); + + const handleDoubleClick = () => { + setCanEdit(true); + prevValue.current = value; + }; + + const handleChange = (e: React.ChangeEvent) => { + const content = e.target.value; + + setValue(content); + }; + + const handledeleteTodo = () => { + setTodosForModify(prev => [...prev, todo]); + + const content = [id]; + + deleteTodos(content); + }; + + const updateTodo = ( + currentTodo: TodoInterface, + status?: string, + ): TodoInterface => { + let state = false; + + if (status) { + state = currentTodo.completed; + } else { + state = currentTodo.completed ? false : true; + } + + return { + id: currentTodo.id, + userId: currentTodo.userId, + title: value ? value.trim() : currentTodo.title, + completed: state, + }; + }; + + const handleUpdateTodo = (way?: string) => { + if (prevValue.current) { + const oldValue = prevValue.current; + + if (oldValue.trim() === value.trim()) { + setCanEdit(false); + setValue(value.trim()); + + return; + } + } + + const newTodo = updateTodo(todo, way); + + setTodosForModify(prev => [...prev, newTodo]); + + const content = [newTodo]; + + if (value) { + updateTodos(content); + } else { + const deleteTodo = [newTodo.id]; + + deleteTodos(deleteTodo, canEdit); + } + }; + + const handleOnSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + handleUpdateTodo('go'); + setValue(value.trim()); + }; + + const handleCancel = () => { + if (prevValue.current) { + setValue(prevValue.current); + + setCanEdit(false); + } + }; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleCancel(); + } + }; + + if (canEdit) { + document.addEventListener('keydown', handleKeyDown); + } else { + document.removeEventListener('keydown', handleKeyDown); + } + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }); + + const handleOnBlur = () => { + handleUpdateTodo('go'); + + setValue(value.trim()); + }; + + return ( +
+ + + {canEdit && ( +
+ { + setCanEdit(false); + handleOnBlur(); + }} + onChange={handleChange} + /> +
+ )} + + {!canEdit && ( + + {title} + + )} + + {!canEdit && ( + + )} + +
item.id === todo.id), + })} + > +
+
+
+
+ ); +}; diff --git a/src/components/todoList/TodoList.tsx b/src/components/todoList/TodoList.tsx new file mode 100644 index 0000000000..b4626e26a9 --- /dev/null +++ b/src/components/todoList/TodoList.tsx @@ -0,0 +1,66 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { TodoInterface } from '../../types/Todo'; +import { Todo } from '../todo/Todo'; + +interface Props { + filteredTodos: TodoInterface[]; + + deleteTodos: (content: number[]) => Promise; + + tempTodo: TodoInterface | null; + + todosForModify: TodoInterface[]; + setTodosForModify: React.Dispatch>; + updateTodos: (updateTodo: TodoInterface[]) => Promise; +} + +export const TodoList: React.FC = ({ + filteredTodos, + deleteTodos, + + tempTodo, + todosForModify, + + setTodosForModify, + updateTodos, +}) => { + return ( +
+ {filteredTodos.map(todo => ( + + ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..7ce080d7e6 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,6 @@ +export interface TodoInterface { + id: number; + userId: number; + title: string; + completed: boolean; +} diff --git a/src/types/filter.ts b/src/types/filter.ts new file mode 100644 index 0000000000..66887875b7 --- /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/postContent.ts b/src/types/postContent.ts new file mode 100644 index 0000000000..66c6167282 --- /dev/null +++ b/src/types/postContent.ts @@ -0,0 +1,5 @@ +export type PostContent = { + completed: boolean; + title: string; + userId: number; +}; 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'), +};