diff --git a/README.md b/README.md index d3c3756ab..b8fa3201e 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://v1rnt.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..ce1ae14a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,170 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { deleteTodo, getTodos, patchTodo } from './api/todos'; -const USER_ID = 0; +import { Todo } from './types/Todo'; +import { Filter } from './types/Filter'; +import { Errors } from './types/Errors'; + +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { ErrorMessage } from './components/ErrorMessage'; + +import { handleError } from './utils/handleError'; +import { getFilteredTodos } from './utils/getFilteredTodos'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todosFromServer, setTodosFromServer] = useState([]); + const [errorMessage, setErrorMessage] = useState(Errors.Default); + const [filterOption, setFilterOption] = useState(Filter.All); + const [tempTodo, setTempTodo] = useState(null); + const [deletionIds, setDeletionIds] = useState([]); + const [pendingTodos, setPendingTodos] = useState([]); + + const completedTodoIds = useMemo(() => { + return todosFromServer.filter(todo => todo.completed).map(todo => todo.id); + }, [todosFromServer]); + + const uncompletedTodos = useMemo(() => { + return todosFromServer.filter(todo => !todo.completed); + }, [todosFromServer]); + + const uncompletedTodosAmount = uncompletedTodos.length; + + const filteredTodos = useMemo(() => { + return getFilteredTodos(todosFromServer, filterOption); + }, [todosFromServer, filterOption]); + + const handleDeleteTodos = useCallback((ids: number[]) => { + Promise.all( + ids.map(id => { + setDeletionIds(current => [...current, id]); + + deleteTodo(id) + .then(() => { + setTodosFromServer(current => + current.filter(todo => todo.id !== id), + ); + }) + .catch(() => { + handleError(setErrorMessage, Errors.DeleteTodo); + }) + .finally(() => + setDeletionIds(currIds => currIds.filter(currId => currId !== id)), + ); + }), + ); + }, []); + + const handleChangeTodos = useCallback( + (newTodos: Todo[]) => { + return Promise.all( + newTodos.map(todo => { + setPendingTodos(current => [...current, todo]); + + const { id, ...todoBody } = todo; + + return patchTodo(todoBody, id) + .then(() => { + setTodosFromServer(current => + current.map(currentTodo => { + return currentTodo.id !== todo.id ? currentTodo : todo; + }), + ); + }) + .catch(() => { + handleError(setErrorMessage, Errors.UpdateTodo); + throw new Error(errorMessage); + }) + .finally(() => setPendingTodos([])); + }), + ); + }, + [errorMessage], + ); + + const handleToggleCompleted = (id: number, updatedField: Partial) => { + setPendingTodos(current => [...current]); + + return patchTodo(updatedField, id) + .then((updatedTodo: Todo) => + setTodosFromServer(current => + current.map(todo => (todo.id === id ? updatedTodo : todo)), + ), + ) + .catch(() => { + handleError(setErrorMessage, Errors.UpdateTodo); + throw new Error(errorMessage); + }) + .finally(() => + setPendingTodos(current => current.filter(todo => todo.id !== id)), + ); + }; + + const handleToggleCompletedAll = () => { + if (uncompletedTodos && uncompletedTodosAmount) { + Promise.allSettled( + uncompletedTodos.map(({ id }) => + handleToggleCompleted(id, { completed: true }), + ), + ); + } else { + Promise.allSettled( + todosFromServer.map(({ id }) => + handleToggleCompleted(id, { completed: false }), + ), + ); + } + }; + + useEffect(() => { + getTodos() + .then(setTodosFromServer) + .catch(() => handleError(setErrorMessage, Errors.LoadingTodos)); + }, []); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {!!todosFromServer.length && ( + <> + +
+ + )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..db01605f0 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,16 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1606; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const postTodo = (todo: Omit): Promise => + client.post('/todos', todo); + +export const deleteTodo = (todoId: number) => client.delete(`/todos/${todoId}`); + +export const patchTodo = (props: Partial, id: number): Promise => + client.patch(`/todos/${id}`, props); diff --git a/src/components/ErrorMessage/ErrorMessage.tsx b/src/components/ErrorMessage/ErrorMessage.tsx new file mode 100644 index 000000000..727b7f64e --- /dev/null +++ b/src/components/ErrorMessage/ErrorMessage.tsx @@ -0,0 +1,28 @@ +import { FC, Dispatch } from 'react'; +import cn from 'classnames'; +import { Errors } from '../../types/Errors'; + +type Props = { + errorMessage: Errors; + setErrorMessage: Dispatch>; +}; + +export const ErrorMessage: FC = ({ errorMessage, setErrorMessage }) => { + 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..e0f245496 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,56 @@ +import { FC, Dispatch, SetStateAction } from 'react'; +import { Filter } from '../../types/Filter'; +import cn from 'classnames'; + +type Props = { + completedTodoIds: number[]; + uncompletedTodosAmount: number; + filterOption: Filter; + setFilterOption: Dispatch>; + setDeletionIds: Dispatch>; + onDeleteTodos: (ids: number[]) => void; +}; + +export const Footer: FC = ({ + completedTodoIds, + uncompletedTodosAmount, + filterOption, + setFilterOption, + onDeleteTodos, +}) => { + return ( +
+ + {uncompletedTodosAmount} items left + + + + + +
+ ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 000000000..ddcc5a9cd --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..46e833ba5 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,96 @@ +import { + FC, + Dispatch, + SetStateAction, + useRef, + useEffect, + useState, +} from 'react'; +import cn from 'classnames'; +import { InputForm } from '../InputForm'; +import { Todo } from '../../types/Todo'; +import { Errors } from '../../types/Errors'; +import { handleError } from '../../utils/handleError'; +import { postTodo, USER_ID } from '../../api/todos'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + uncompletedTodosAmount: number; + setTempTodo: Dispatch>; + setError: Dispatch>; + setTodos: Dispatch>; + onToggleCompletedAll: () => void; +}; + +export const Header: FC = ({ + todos, + tempTodo, + uncompletedTodosAmount, + setTempTodo, + setError, + setTodos, + onToggleCompletedAll, +}) => { + const [inputValue, setInputValue] = useState(''); + const inputRef = useRef(null); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedInputValues = inputValue.trim(); + + if (!trimmedInputValues) { + handleError(setError, Errors.EmptyTitle); + + return; + } + + const temporaryTodo: Todo = { + id: 0, + userId: USER_ID, + title: trimmedInputValues, + completed: false, + }; + + setTempTodo(temporaryTodo); + + postTodo(temporaryTodo) + .then(res => { + setTodos(current => [...current, res]); + setInputValue(''); + setTempTodo(null); + }) + .catch(() => { + setTempTodo(null); + handleError(setError, Errors.AddTodo); + }); + }; + + useEffect(() => { + inputRef.current?.focus(); + }, [todos, tempTodo]); + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 000000000..266dec8a1 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/InputForm/InputForm.tsx b/src/components/InputForm/InputForm.tsx new file mode 100644 index 000000000..5f0810191 --- /dev/null +++ b/src/components/InputForm/InputForm.tsx @@ -0,0 +1,39 @@ +import { + Dispatch, + FC, + FormEventHandler, + MutableRefObject, + SetStateAction, +} from 'react'; +import { Todo } from '../../types/Todo'; + +type Props = { + handleSubmit: FormEventHandler; + inputRef: MutableRefObject; + tempTodo: Todo | null; + inputValue: string; + setInputValue: Dispatch>; +}; + +export const InputForm: FC = ({ + handleSubmit, + inputRef, + tempTodo, + inputValue, + setInputValue, +}) => { + return ( +
+ setInputValue(event.target.value)} + /> +
+ ); +}; diff --git a/src/components/InputForm/index.ts b/src/components/InputForm/index.ts new file mode 100644 index 000000000..139aee432 --- /dev/null +++ b/src/components/InputForm/index.ts @@ -0,0 +1 @@ +export * from './InputForm'; diff --git a/src/components/TodoBody/TodoBody.tsx b/src/components/TodoBody/TodoBody.tsx new file mode 100644 index 000000000..f8dc4da01 --- /dev/null +++ b/src/components/TodoBody/TodoBody.tsx @@ -0,0 +1,135 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { FC, useEffect, useState, useRef } from 'react'; +import { Todo } from '../../types/Todo'; +import cn from 'classnames'; +import { Errors } from '../../types/Errors'; + +type Props = { + todo: Todo; + deletionIds?: number[]; + pendingTodos?: Todo[]; + errorMessage: Errors; + onTodosChange?: any; + onDeleteTodos?: (ids: number[]) => void; +}; + +export const TodoBody: FC = ({ + todo, + deletionIds, + pendingTodos, + errorMessage, + onTodosChange = () => {}, + onDeleteTodos = () => {}, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + + const handleUpdateTodoTitle = (event: React.FormEvent) => { + event.preventDefault(); + + if (newTitle === todo.title) { + setIsEditing(false); + + return; + } else if (!newTitle) { + onDeleteTodos([todo.id]); + + return; + } + + const updatedTodo = { + ...todo, + title: newTitle.trim(), + }; + + onTodosChange([updatedTodo]).then(() => setIsEditing(false)); + }; + + const handleEscapeButton = (key: string) => { + if (key === 'Escape') { + setNewTitle(todo.title); + setIsEditing(false); + } + + return; + }; + + const handleTodoStatus = () => { + const updatedTodo = { + ...todo, + completed: !todo.completed, + }; + + onTodosChange([updatedTodo]); + }; + + const isActive = + !todo.id || + deletionIds?.includes(todo.id) || + pendingTodos?.some(current => current.id === todo.id); + + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, [isEditing, errorMessage]); + + return ( +
setIsEditing(true)} + > + + + {isEditing ? ( +
+ setNewTitle(event.currentTarget.value)} + onKeyUp={event => handleEscapeButton(event.key)} + /> +
+ ) : ( + <> + + {todo.title} + + + + + )} +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoBody/index.ts b/src/components/TodoBody/index.ts new file mode 100644 index 000000000..57926cb9c --- /dev/null +++ b/src/components/TodoBody/index.ts @@ -0,0 +1 @@ +export * from './TodoBody'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..140e449bf --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoBody } from '../TodoBody'; +import { Errors } from '../../types/Errors'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + deletionIds: number[]; + pendingTodos: Todo[] | undefined; + errorMessage: Errors; + onTodosChange: (newTodos: Todo[]) => void; + onDeleteTodos: (ids: number[]) => void; +}; + +export const TodoList: FC = ({ + todos, + tempTodo, + deletionIds, + pendingTodos, + errorMessage, + onTodosChange, + onDeleteTodos, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + {tempTodo && } +
+ ); +}; 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/types/Errors.ts b/src/types/Errors.ts new file mode 100644 index 000000000..358b045c8 --- /dev/null +++ b/src/types/Errors.ts @@ -0,0 +1,8 @@ +export enum Errors { + Default = '', + LoadingTodos = 'Unable to load todos', + EmptyTitle = 'Title should not be empty', + AddTodo = 'Unable to add a todo', + DeleteTodo = 'Unable to delete a todo', + UpdateTodo = 'Unable to update a todo', +} diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..66887875b --- /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 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..33e705a0c --- /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/getFilteredTodos.ts b/src/utils/getFilteredTodos.ts new file mode 100644 index 000000000..804447f33 --- /dev/null +++ b/src/utils/getFilteredTodos.ts @@ -0,0 +1,15 @@ +import { Filter } from '../types/Filter'; +import { Todo } from '../types/Todo'; + +export const getFilteredTodos = (todos: Todo[], filter: Filter): Todo[] => { + return todos.filter(todo => { + switch (filter) { + case Filter.Completed: + return todo.completed; + case Filter.Active: + return !todo.completed; + default: + return true; + } + }); +}; diff --git a/src/utils/handleError.tsx b/src/utils/handleError.tsx new file mode 100644 index 000000000..275a915d9 --- /dev/null +++ b/src/utils/handleError.tsx @@ -0,0 +1,12 @@ +import { Errors } from '../types/Errors'; +import { Dispatch, SetStateAction } from 'react'; + +export const handleError = ( + setError: Dispatch>, + error: Errors, +) => { + setError(error); + setTimeout(() => { + setError(Errors.Default); + }, 3000); +};