diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..6f28249032 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,180 @@ -/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; +import * as clientTodo from './api/todos'; +import { getFilteredTodos } from './utils/getFilteredTodos'; -const USER_ID = 0; +import { ErrorMessage } from './types/ErrorMessage'; +import { Todo } from './types/Todo'; +import { Status } from './types/Status'; + +import { TodoHeader } from './components/TodoHeader'; +import { TodoFooter } from './components/TodoFooter'; +import { TodoItem } from './components/TodoItem'; +import { ErrorNotification } from './components/ErrorNotification'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [activeStatus, setActiveStatus] = useState(Status.All); + const [tempTodo, setTempTodo] = useState(null); + const [isLoadingTodos, setIsLoadingTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState( + ErrorMessage.Default, + ); + + const inputRef = useRef(null); + + const filteredTodos = getFilteredTodos(todos, activeStatus); + const notCompletedTodos = todos.filter(todo => !todo.completed); + const completedTodos = todos.filter(todo => todo.completed); + const isToogleAll = + todos.length !== 0 ? completedTodos.length === todos.length : false; + + const onAddTodo = async (title: string) => { + setTempTodo({ + id: 0, + title, + userId: clientTodo.USER_ID, + completed: false, + }); + + const newTodo: Omit = { + title, + userId: clientTodo.USER_ID, + completed: false, + }; + + try { + const todo = await clientTodo.createTodos(newTodo); + + setTodos(currentTodos => [...currentTodos, todo]); + } catch (error) { + setErrorMessage(ErrorMessage.UnableToAdd); + throw error; + } finally { + setTempTodo(null); + } + }; + + const onUpdateTodo = async (todoToUpdate: Todo) => { + setIsLoadingTodos(prevTodos => [...prevTodos, todoToUpdate.id]); + + try { + const updatedTodo = await clientTodo.updateTodo(todoToUpdate); + + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = newTodos.findIndex(todo => todo.id === updatedTodo.id); + + newTodos.splice(index, 1, updatedTodo); + + return newTodos; + }); + } catch (error) { + setErrorMessage(ErrorMessage.UnableToUpdate); + throw error; + } finally { + setIsLoadingTodos(prevTodos => prevTodos.filter(id => todoToUpdate.id !== id)); + } + }; + + const onDeleteTodo = (todoId: number) => { + setIsLoadingTodos(prevTodos => [...prevTodos, todoId]); + + clientTodo + .deleteTodo(todoId) + .then(() => + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ), + ) + .catch(error => { + setErrorMessage(ErrorMessage.UnableToDelete); + throw error; + }) + .finally(() => + setIsLoadingTodos(prevTodos => prevTodos.filter(id => todoId !== id)), + ); + }; + + const onDeleteAllCompleted = () => { + completedTodos.forEach(todo => onDeleteTodo(todo.id)); + }; + + const onToggleAll = () => { + if (todos.every(todo => todo.completed)) { + todos.forEach(todo => onUpdateTodo({ ...todo, completed: false })); + } else { + todos.forEach(todo => onUpdateTodo({ ...todo, completed: true })); + } + }; + + useEffect(() => { + clientTodo + .getTodos() + .then(data => setTodos(data)) + .catch(error => { + setErrorMessage(ErrorMessage.UnableToLoad); + throw error; + }); + }, []); + + if (!clientTodo.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 0000000000..bbf7c07042 --- /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 = 2135; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodos = ({ title, userId, completed }: Omit) => { + return client.post(`/todos`, { title, userId, completed }); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = ({ id, title, completed, userId }: Todo) => { + return client.patch(`/todos/${id}`, { title, completed, userId }); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..f8c8081dc3 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,45 @@ +import React, { useEffect } from 'react'; +import cn from 'classnames'; +import { ErrorMessage } from '../types/ErrorMessage'; + +type Props = { + error: string; + setErrorMessage: (error: ErrorMessage) => void; +}; + +export const ErrorNotification: React.FC = props => { + const { error, setErrorMessage } = props; + + useEffect(() => { + if (error === ErrorMessage.Default) { + return; + } + + const timer = setTimeout(() => { + setErrorMessage(ErrorMessage.Default); + }, 3000); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [error]); + + return ( +
+
+ ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 0000000000..3dea92932d --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,56 @@ +/* eslint-disable prettier/prettier */ +import React from 'react'; +import cn from 'classnames'; +import { Status } from '../types/Status'; + +type Props = { + activeStatus: Status; + completedTodos: number; + notCompletedTodos: number; + onDeleteAllCompleted: () => void; + setActiveStatus: (status: Status) => void; +}; + +export const TodoFooter: React.FC = props => { + const { + activeStatus, + completedTodos, + notCompletedTodos, + onDeleteAllCompleted, + setActiveStatus, + } = props; + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 0000000000..2080f8da04 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,76 @@ +import React, { useEffect, useState } from 'react'; +import cn from 'classnames'; +import { ErrorMessage } from '../types/ErrorMessage'; + +type Props = { + inputRef: React.MutableRefObject; + error: ErrorMessage; + isToogleAll: boolean; + isInputDisablet: boolean; + isDeletedTodos: number[]; + onAddTodo: (title: string) => Promise; + onToggleAll: () => void; + setErrorMessage: (error: ErrorMessage) => void; +}; + +export const TodoHeader: React.FC = props => { + const { + inputRef, + error, + isToogleAll, + isInputDisablet, + isDeletedTodos, + onAddTodo, + onToggleAll, + setErrorMessage, + } = props; + + const [title, setTitle] = useState(''); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [inputRef, isInputDisablet, isDeletedTodos]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (error) { + setErrorMessage(ErrorMessage.Default); + } + + if (!title.trim()) { + setErrorMessage(ErrorMessage.EmptyTitle); + + return; + } + + await onAddTodo(title.trim()); + setTitle(''); + }; + + return ( +
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..8b299f9986 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,142 @@ +/* eslint-disable @typescript-eslint/indent */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; +import { USER_ID } from '../api/todos'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + onDeleteTodo: (todoId: number) => void; + onUpdateTodo: (newTodo: Todo) => Promise; + isLoading: boolean; +}; + +export const TodoItem: React.FC = props => { + const { todo, onDeleteTodo, onUpdateTodo, isLoading } = props; + + const [isUpdate, setIsUpdate] = useState(false); + const [updateTitle, setUpdateTitle] = useState(todo.title); + + const updateRef = useRef(null); + + const handleUpdate = async ( + e: + | React.FormEvent + | React.FocusEvent, + ) => { + e.preventDefault(); + + if (todo.title === updateTitle) { + setIsUpdate(false); + + return; + } + + if (!updateTitle) { + onDeleteTodo(todo.id); + } + + const updatedTodo: Todo = { + id: todo.id, + completed: todo.completed, + title: updateTitle, + userId: USER_ID, + }; + + try { + await onUpdateTodo(updatedTodo); + } catch (err) { + throw err; + } finally { + setIsUpdate(false); + } + }; + + useEffect(() => { + if (updateRef.current) { + updateRef.current.focus(); + } + }, [isUpdate]); + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + event.preventDefault(); + if (event.key === 'Escape') { + setUpdateTitle(todo.title); + setIsUpdate(false); + } + }; + + document.addEventListener('keyup', handleEscape); + + return () => { + document.removeEventListener('keyup', handleEscape); + }; + }, [todo.title]); + + const handleComplete = (event: React.ChangeEvent) => { + event.preventDefault(); + + onUpdateTodo({ ...todo, completed: !todo.completed }); + }; + + return ( +
+ + + {isUpdate ? ( +
+ setUpdateTitle(e.target.value)} + ref={updateRef} + onBlur={handleUpdate} + /> +
+ ) : ( + <> + setIsUpdate(true)} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..f74d2716fa --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,8 @@ +export enum ErrorMessage { + Default = '', + EmptyTitle = 'Title should not be empty', + UnableToAdd = 'Unable to add a todo', + UnableToLoad = 'Unable to load todos', + UnableToDelete = 'Unable to delete a todo', + UnableToUpdate = 'Unable to update a todo', +} diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 0000000000..dc864cc93b --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + 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'), +}; diff --git a/src/utils/getFilteredTodos.ts b/src/utils/getFilteredTodos.ts new file mode 100644 index 0000000000..308c1b65b3 --- /dev/null +++ b/src/utils/getFilteredTodos.ts @@ -0,0 +1,15 @@ +import { Status } from '../types/Status'; +import { Todo } from '../types/Todo'; + +export const getFilteredTodos = (todos: Todo[], status: Status) => + todos.filter(todo => { + if (status === Status.Completed) { + return todo.completed; + } + + if (status === Status.Active) { + return !todo.completed; + } + + return true; + });