diff --git a/src/App.tsx b/src/App.tsx index 81e011f43..ba84c8c42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,110 @@ -/* 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 { UserWarning } from './UserWarning'; +import { FC, useEffect, useMemo, useState } from 'react'; -const USER_ID = 0; +import { Todo } from './types/Todo'; +import { Errors } from './types/Errors'; +import { FilterBy } from './types/FilterBy'; -export const App: React.FC = () => { - if (!USER_ID) { - return ; - } +import { getFilteredTodos } from './utils/getFilteredTodos'; +import { handleFetchTodos } from './utils/handleFetchTodos'; +import { handleDeleteTodos } from './utils/handleDeleteTodos'; + +import { Header, TodoList, Footer, ErrorMessage, TodoItem } from './components'; +import { handleUpdateTodos } from './utils/handleUpdateTodos'; + +export const App: FC = () => { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [newTodoData, setNewTodoData] = useState>({}); + + const [idsForDelete, setIdsForDelete] = useState([]); + const [idsForUpdate, setIdsForUpdate] = useState([]); + + const [error, setError] = useState(Errors.DEFAULT); + + const [selectedFilter, setSelectedFilter] = useState(FilterBy.ALL); + + const completedTodosId = useMemo(() => { + return getFilteredTodos(todos, FilterBy.COMPLETED).map(todo => todo.id); + }, [todos]); + + const numberOfActiveTodos = useMemo(() => { + return getFilteredTodos(todos, FilterBy.ACTIVE).length; + }, [todos]); + + const filteredTodos = useMemo(() => { + return getFilteredTodos(todos, selectedFilter); + }, [todos, selectedFilter]); + + useEffect(() => { + if (idsForDelete.length) { + handleDeleteTodos(idsForDelete, setTodos, setIdsForDelete, setError); + } + }, [idsForDelete]); + + useEffect(() => { + if (idsForUpdate.length) { + handleUpdateTodos( + idsForUpdate, + newTodoData, + setTodos, + setIdsForUpdate, + setError, + ); + } + }, [idsForUpdate, newTodoData]); + + useEffect(() => { + handleFetchTodos(setTodos, setError); + }, []); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {tempTodo && ( + + )} + + {!!todos.length && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..448448f12 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,24 @@ +import { Todo } from '../types/Todo'; + +import { client } from '../utils/fetchClient'; + +import { USER_ID } from '../constants/constants'; + +export const getTodos = (): Promise => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (newTodo: Omit): Promise => { + return client.post('/todos', newTodo); +}; + +export const deleteTodo = (id: number): Promise => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = ( + id: number, + updates: Partial, +): Promise => { + return client.patch(`/todos/${id}`, updates); +}; diff --git a/src/components/ErrorMessage/ErrorMessage.tsx b/src/components/ErrorMessage/ErrorMessage.tsx new file mode 100644 index 000000000..2c32b2121 --- /dev/null +++ b/src/components/ErrorMessage/ErrorMessage.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; +import cn from 'classnames'; + +import { Errors } from '../../types/Errors'; +import { handleError } from '../../utils/handleError'; + +interface Props { + error: Errors; + setError: (error: Errors) => void; +} + +export const ErrorMessage: FC = ({ error, setError }) => { + const handleClose = () => { + handleError(Errors.DEFAULT, setError); + }; + + return ( +
+
+ ); +}; diff --git a/src/components/ErrorMessage/index.ts b/src/components/ErrorMessage/index.ts new file mode 100644 index 000000000..3dee9621e --- /dev/null +++ b/src/components/ErrorMessage/index.ts @@ -0,0 +1 @@ +export * from './ErrorMessage'; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..9f37e530d --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,58 @@ +import { FC } from 'react'; +import cn from 'classnames'; + +import { FilterBy } from '../../types/FilterBy'; + +interface Props { + selectedFilter: FilterBy; + completedTodosId: number[]; + numberOfActiveTodos: number; + setSelectedFilter: (filter: FilterBy) => void; + setIdsForDelete: (ids: number[]) => void; +} + +const Footer: FC = ({ + selectedFilter, + completedTodosId, + numberOfActiveTodos, + setSelectedFilter, + setIdsForDelete, +}) => { + const filters = Object.values(FilterBy); + + return ( +
+ + {numberOfActiveTodos} items left + + + + + +
+ ); +}; + +export default Footer; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 000000000..da94c2936 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export { default as Footer } from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..8431b79fb --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,116 @@ +import { ChangeEvent, FC, FormEvent, useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; + +import { Todo } from '../../types/Todo'; +import { Errors } from '../../types/Errors'; + +import { addTodo } from '../../api/todos'; +import { handleError } from '../../utils/handleError'; + +import { USER_ID } from '../../constants/constants'; + +interface Props { + todos: Todo[]; + tempTodo: Todo | null; + numberOfActiveTodos: number; + setTodos: (updateTodos: (todos: Todo[]) => Todo[]) => void; + setError: (error: Errors) => void; + setTempTodo: (todo: Todo | null) => void; + setIdsForUpdate: (prevIds: (ids: number[]) => number[]) => void; + setNewTodoData: (newData: Partial) => void; +} + +const Header: FC = ({ + todos, + tempTodo, + numberOfActiveTodos, + setTodos, + setError, + setTempTodo, + setIdsForUpdate, + setNewTodoData, +}) => { + const [title, setTitle] = useState(''); + const inputRef = useRef(null); + + const handleChangeTitle = (event: ChangeEvent) => { + setTitle(event.target.value.trimStart()); + }; + + const handleFormSubmit = async (event: FormEvent) => { + event.preventDefault(); + + const formattedTitle = title.trim(); + + if (!formattedTitle) { + handleError(Errors.TITLE_ERROR, setError); + + return; + } + + const newTodo: Omit = { + userId: USER_ID, + title: formattedTitle, + completed: false, + }; + + const tmpTodo: Todo = { + id: 0, + ...newTodo, + }; + + setTempTodo(tmpTodo); + + try { + const todo = await addTodo(newTodo); + + setTodos(currentTodos => [...currentTodos, todo]); + setTitle(''); + setTempTodo(null); + } catch { + setTempTodo(null); + handleError(Errors.ADD_TODO, setError); + } + }; + + const handleToggleAll = () => { + const isAllTodosCompleted = todos.every(todo => todo.completed); + + todos.map(todo => { + setIdsForUpdate(currentIds => [...currentIds, todo.id]); + setNewTodoData({ completed: isAllTodosCompleted ? false : true }); + }); + }; + + useEffect(() => { + inputRef.current?.focus(); + }, [todos, tempTodo]); + + return ( +
+
+ ); +}; + +export default Header; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 000000000..5653319de --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export { default as Header } from './Header'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..6c64a9209 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,154 @@ +import { + ChangeEvent, + FC, + FormEvent, + useCallback, + useEffect, + useState, +} from 'react'; +import cn from 'classnames'; + +import { Todo } from '../../types/Todo'; + +interface Props { + todo: Todo; + idsForDelete?: number[]; + idsForUpdate?: number[]; + setIdsForDelete: (prevIds: (ids: number[]) => number[]) => void; + setIdsForUpdate: (prevIds: (ids: number[]) => number[]) => void; + setNewTodoData: (newTodoData: Partial) => void; +} + +export const TodoItem: FC = ({ + todo, + idsForDelete, + idsForUpdate, + setIdsForDelete, + setIdsForUpdate, + setNewTodoData, +}) => { + const [newTitle, setNewTitle] = useState(todo.title); + const [isEditing, setIsEditing] = useState(false); + + const { id, title, completed } = todo; + + const handleTitleChange = (event: ChangeEvent) => { + setNewTitle(event.target.value.trimStart()); + }; + + const handleFormSubmit = (event: FormEvent) => { + event.preventDefault(); + }; + + const handleDelete = useCallback(() => { + setIdsForDelete(currentIds => [...currentIds, id]); + }, [id, setIdsForDelete]); + + const handleUpdate = (event: ChangeEvent) => { + setNewTodoData({ + completed: event.target.checked, + title: newTitle.trim(), + }); + setIdsForUpdate(currentIds => [...currentIds, id]); + }; + + const handleTitleSave = useCallback(() => { + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + handleDelete(); + + return; + } + + setNewTodoData({ title: trimmedTitle }); + setIdsForUpdate(currentIds => [...currentIds, id]); + setIsEditing(false); + }, [id, newTitle, setNewTodoData, handleDelete, setIdsForUpdate]); + + const handleKeyUp = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setNewTitle(title); + } else if (event.key === 'Enter') { + handleTitleSave(); + } + }, + [title, handleTitleSave], + ); + + const isLoaderShow = + !id || idsForDelete?.includes(id) || idsForUpdate?.includes(id); + + useEffect(() => { + if (isEditing) { + document.addEventListener('keyup', handleKeyUp); + } + + return () => { + document.removeEventListener('keyup', handleKeyUp); + }; + }, [isEditing, handleKeyUp]); + + return ( +
setIsEditing(true)} + > + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {isEditing ? ( +
+ +
+ ) : ( + <> + + {title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 000000000..21f4abac3 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..6e5a490d5 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,39 @@ +import { FC } from 'react'; + +import { Todo } from '../../types/Todo'; + +import { TodoItem } from '../TodoItem'; + +interface Props { + todos: Todo[]; + idsForDelete: number[]; + idsForUpdate: number[]; + setIdsForDelete: (prevIds: (ids: number[]) => number[]) => void; + setIdsForUpdate: (prevIds: (ids: number[]) => number[]) => void; + setNewTodoData: (newTodoData: Partial) => void; +} + +export const TodoList: FC = ({ + todos, + idsForDelete, + idsForUpdate, + setIdsForDelete, + setIdsForUpdate, + setNewTodoData, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 000000000..788fc6d24 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,5 @@ +export * from './Header'; +export * from './TodoList'; +export * from './TodoItem'; +export * from './Footer'; +export * from './ErrorMessage'; diff --git a/src/constants/constants.ts b/src/constants/constants.ts new file mode 100644 index 000000000..38e4f9b87 --- /dev/null +++ b/src/constants/constants.ts @@ -0,0 +1 @@ +export const USER_ID = 1443; diff --git a/src/types/Errors.ts b/src/types/Errors.ts new file mode 100644 index 000000000..5d5de3b87 --- /dev/null +++ b/src/types/Errors.ts @@ -0,0 +1,8 @@ +export enum Errors { + DEFAULT = '', + LOAD_ERROR = 'Unable to load todos', + TITLE_ERROR = 'Title should not be empty', + ADD_TODO = 'Unable to add a todo', + DELETE_TODO = 'Unable to delete a todo', + UPDATE_TODO = 'Unable to update a todo', +} diff --git a/src/types/FilterBy.ts b/src/types/FilterBy.ts new file mode 100644 index 000000000..f2dfad262 --- /dev/null +++ b/src/types/FilterBy.ts @@ -0,0 +1,5 @@ +export enum FilterBy { + 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..708ac4c17 --- /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 000000000..1a9019aaf --- /dev/null +++ b/src/utils/getFilteredTodos.ts @@ -0,0 +1,17 @@ +import { Todo } from '../types/Todo'; +import { FilterBy } from '../types/FilterBy'; + +export const getFilteredTodos = (todos: Todo[], filter: FilterBy) => { + return todos.filter(todo => { + switch (filter) { + case FilterBy.ACTIVE: + return !todo.completed; + + case FilterBy.COMPLETED: + return todo.completed; + + default: + return true; + } + }); +}; diff --git a/src/utils/handleDeleteTodos.ts b/src/utils/handleDeleteTodos.ts new file mode 100644 index 000000000..a26e89a3e --- /dev/null +++ b/src/utils/handleDeleteTodos.ts @@ -0,0 +1,29 @@ +import { Dispatch, SetStateAction } from 'react'; + +import { Todo } from '../types/Todo'; +import { Errors } from '../types/Errors'; + +import { deleteTodo } from '../api/todos'; +import { handleError } from './handleError'; + +export const handleDeleteTodos = ( + idsForDelete: number[], + setTodos: Dispatch>, + setIdsForDelete: Dispatch>, + setError: Dispatch>, +) => { + Promise.allSettled( + idsForDelete.map(id => + deleteTodo(id) + .then(() => { + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== id)); + }) + .catch(() => { + handleError(Errors.DELETE_TODO, setError); + }) + .finally(() => { + setIdsForDelete([]); + }), + ), + ); +}; diff --git a/src/utils/handleError.ts b/src/utils/handleError.ts new file mode 100644 index 000000000..a9756da24 --- /dev/null +++ b/src/utils/handleError.ts @@ -0,0 +1,12 @@ +import { Errors } from '../types/Errors'; + +export const handleError = ( + error: Errors, + setError: (error: Errors) => void, +) => { + setError(error); + + setTimeout(() => { + setError(Errors.DEFAULT); + }, 3000); +}; diff --git a/src/utils/handleFetchTodos.ts b/src/utils/handleFetchTodos.ts new file mode 100644 index 000000000..8fd04f730 --- /dev/null +++ b/src/utils/handleFetchTodos.ts @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { Errors } from '../types/Errors'; + +import { getTodos } from '../api/todos'; +import { handleError } from './handleError'; + +export const handleFetchTodos = async ( + setTodos: (todos: Todo[]) => void, + setError: (error: Errors) => void, +) => { + try { + const todos = await getTodos(); + + setTodos(todos); + } catch { + handleError(Errors.LOAD_ERROR, setError); + } +}; diff --git a/src/utils/handleUpdateTodos.ts b/src/utils/handleUpdateTodos.ts new file mode 100644 index 000000000..d84c3de95 --- /dev/null +++ b/src/utils/handleUpdateTodos.ts @@ -0,0 +1,33 @@ +import { Dispatch, SetStateAction } from 'react'; + +import { Todo } from '../types/Todo'; +import { Errors } from '../types/Errors'; + +import { updateTodo } from '../api/todos'; +import { handleError } from './handleError'; + +export const handleUpdateTodos = ( + idsForUpdate: number[], + newData: Partial, + setTodos: Dispatch>, + setIdsForUpdate: Dispatch>, + setError: Dispatch>, +) => { + Promise.allSettled( + idsForUpdate.map(id => + updateTodo(id, newData) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(todo => + todo.id !== updatedTodo.id ? todo : updatedTodo, + ), + ); + }) + .catch(() => { + handleError(Errors.UPDATE_TODO, setError); + }), + ), + ).finally(() => { + setIdsForUpdate([]); + }); +};