diff --git a/README.md b/README.md index c7bfa3dd36..ecfe5c8aaa 100644 --- a/README.md +++ b/README.md @@ -41,4 +41,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://Luk2asz.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 5749bdf784..71feb32405 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,161 @@ -/* eslint-disable max-len */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { Todo } from './types/Todo'; +import { SortBy } from './types/SortBy'; +import { deleteTodo, updateTodo } from './api/todos'; +import { Todos } from './components/Todos'; +import { useGetTodos } from './hooks'; +import { Errors } from './types'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; -const USER_ID = 0; +const USER_ID = 11361; export const App: React.FC = () => { + const [userId] = useState(USER_ID); + const [sortBy, setSortBy] = useState(SortBy.all); + const [selectedTodo, setSelectedTodo] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [isDeleteUpdateTodo, setIsDeleteUpdateTodo] = useState(false); + const [makeAnyChange, setMakeAnyChange] = useState(false); + const { + isLoading, + todos, + errorMessage, + handleError, + } = useGetTodos(USER_ID, makeAnyChange); + + const handleSelectedTodo = (value: number[]) => { + setSelectedTodo(value); + }; + + const handleSetTempTodo = (value:Todo | null) => { + setTempTodo(value); + }; + + const handleSetMakeAnyChange = (value:boolean) => { + setMakeAnyChange(value); + }; + + const handleDeleteUptadeTodo = (value: boolean) => { + setIsDeleteUpdateTodo(value); + }; + + const todosNotCompleted = useMemo(() => { + return todos.filter(todo => todo.completed === false).length; + }, [todos]); + + const deleteOneTodo = async (todoId: number) => { + setSelectedTodo(prevSelectedTodo => [...prevSelectedTodo, todoId]); + setIsDeleteUpdateTodo(true); + try { + await deleteTodo(USER_ID, todoId); + } catch (error) { + handleError(Errors.delete); + } finally { + setSelectedTodo([]); + } + + setIsDeleteUpdateTodo(false); + setMakeAnyChange(!makeAnyChange); + }; + + const updateCheckTodo = async (todoId: number) => { + setSelectedTodo(prevSelectedTodo => [...prevSelectedTodo, todoId]); + setIsDeleteUpdateTodo(true); + let updatedTodo: Todo | undefined = todos.find(todo => todo.id === todoId); + + updatedTodo + ? updatedTodo = { ...updatedTodo, completed: !updatedTodo.completed } + : null; + + try { + await updateTodo(USER_ID, updatedTodo, todoId); + } catch (error) { + handleError(Errors.update); + } finally { + setSelectedTodo([]); + } + + setIsDeleteUpdateTodo(true); + setMakeAnyChange(!makeAnyChange); + }; + + const handleUpdateCheckTodo = (value: number) => updateCheckTodo(value); + const handleDeleteTodo = (value: number) => deleteOneTodo(value); + + const handleSetSortBy = (value: SortBy) => { + setSortBy(value); + }; + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {todos.length > 0 && ( +
+ )} + +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..9bb6897170 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => client.get(`/todos?userId=${userId}`); + +export const deleteTodo = (userId: number, todoId: number) => { + return client.delete(`/todos/${todoId}/?userId=${userId}`); +}; + +export const addTodo = (userId: number, data: Todo | null) => { + return client.post(`/todos/?userId=${userId}`, data); +}; + +export const updateTodo = ( + userId: number, data: Todo | undefined, todoId: number, +) => { + return client.patch(`/todos/${todoId}/?userId=${userId}`, data); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..b97b1e73af --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,92 @@ +import { SortBy, Todo } from '../types'; + +type Props = { + todosNotCompleted: number, + handleSetSortBy: (value:SortBy) => void, + sortBy: SortBy, + todos: Todo[], + handleDeleteTodo: (value: number) => void, + handleSelectedTodo: (todoID: number[]) => void; + selectedTodo: number[]; +}; + +export const Footer: React.FC = ({ + todosNotCompleted, + handleSetSortBy, + sortBy, + todos, + handleDeleteTodo, + handleSelectedTodo, + selectedTodo, +}) => { + const deleteCompletedtodo = () => { + try { + todos.forEach(async todo => { + if (todo.completed) { + handleSelectedTodo([...selectedTodo, todo.id]); + } + }); + } finally { + handleSelectedTodo([]); + } + + todos.forEach(todo => { + if (todo.completed) { + handleDeleteTodo(todo.id); + } + }); + }; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..e4cebe2fa3 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,128 @@ +/* eslint-disable consistent-return */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; +import { FormEvent, useState } from 'react'; +import { Errors, Todo } from '../types'; +import { addTodo } from '../api/todos'; + +type Props = { + todosNotCompleted: number, + todos: Todo[]; + handleUpdateCheckTodo: (value: number) => void; + handleSelectedTodo: (todoID: number[]) => void; + handleError: (value:Errors) => void; + handleSetTempTodo: (value:Todo | null) => void; + userId: number, + handleSetMakeAnyChange: (value: boolean) => void; + makeAnyChange: boolean, + selectedTodo: number[], +}; + +export const Header: React.FC = ({ + todosNotCompleted, + todos, + handleUpdateCheckTodo, + handleSelectedTodo, + handleError, + handleSetTempTodo, + userId, + handleSetMakeAnyChange, + makeAnyChange, + selectedTodo, +}) => { + const [inputValue, setInputValue] = useState(''); + const [isLoadingTodo, setIsLoadingTodo] = useState(false); + const buttonAllCompleted = (value: number) => classNames( + 'todoapp__toggle-all', + { active: value === 0 }, + ); + + const updateCheckAllTodo = () => { + try { + if (todosNotCompleted === 0) { + todos.forEach(todo => handleSelectedTodo([...selectedTodo, todo.id])); + } else { + todos.forEach(todo => { + if (todo.completed === false) { + handleSelectedTodo([...selectedTodo, todo.id]); + } + }); + } + } finally { + handleSelectedTodo([]); + } + + if (todosNotCompleted === 0) { + todos.forEach(todo => handleUpdateCheckTodo(todo.id)); + } else { + todos.forEach(todo => { + if (todo.completed === false) { + handleUpdateCheckTodo(todo.id); + } + }); + } + }; + + const handleAddTodo = async ( + event: FormEvent & { + target: { + todoAdd: { + value:string; + }; + }; + }, + ) => { + event.preventDefault(); + if (!inputValue.trim()) { + return handleError(Errors.emptyTitle); + } + + setIsLoadingTodo(true); + + handleSetTempTodo({ + id: 0, + userId: 11361, + title: inputValue, + completed: false, + }); + + try { + await addTodo(userId, { + id: 0, + userId: 11361, + title: inputValue, + completed: false, + }); + } catch (error) { + handleError(Errors.add); + } + + setIsLoadingTodo(false); + handleSetTempTodo(null); + setInputValue(''); + handleSetMakeAnyChange(!makeAnyChange); + }; + + return ( +
+
+ ); +}; diff --git a/src/components/TempTodo.tsx b/src/components/TempTodo.tsx new file mode 100644 index 0000000000..53535b1561 --- /dev/null +++ b/src/components/TempTodo.tsx @@ -0,0 +1,39 @@ +import { Todo } from '../types'; + +type Props = { + tempTodo: Todo, + handleDeleteTodo: (value: number) => void, +}; + +export const TempTodo: React.FC = ({ tempTodo, handleDeleteTodo }) => ( +
  • + + + + {tempTodo.title} + + + + +
    +
    +
    +
    +
  • +); diff --git a/src/components/Todos.tsx b/src/components/Todos.tsx new file mode 100644 index 0000000000..fb09078ba9 --- /dev/null +++ b/src/components/Todos.tsx @@ -0,0 +1,195 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +/* eslint-disable no-mixed-operators */ +import { + useEffect, useMemo, useRef, useState, +} from 'react'; +import { Errors, SortBy, Todo } from '../types'; +import { deleteTodo, updateTodo } from '../api/todos'; +import { TempTodo } from './TempTodo'; + +type Props = { + todos: Todo[], + tempTodo: Todo | null, + sortBy: SortBy, + handleDeleteTodo: (value: number) => void, + isLoading: boolean, + selectedTodo: number[] + handleUpdateCheckTodo: (value: number) => void; + handleSelectedTodo: (todoID: number[]) => void; + handleError: (value: Errors) => void; + userId: number, + handleSetMakeAnyChange: (value: boolean) => void; + makeAnyChange: boolean, + isDeleteUpdateTodo: boolean + handleDeleteUptadeTodo: (value: boolean) => void; +}; + +export const Todos: React.FC = ({ + todos, + tempTodo, + sortBy, + handleDeleteTodo, + isLoading, + selectedTodo, + handleUpdateCheckTodo, + handleSelectedTodo, + handleError, + userId, + handleSetMakeAnyChange, + makeAnyChange, + isDeleteUpdateTodo, + handleDeleteUptadeTodo, +}) => { + const [isTodoEdit, setIsTodoEdit] = useState(null); + const [inputValue, setInputValue] = useState(''); + const filteredTodos = useMemo(() => { + if (sortBy === SortBy.all) { + return todos; + } + + const isCompleted = sortBy === SortBy.completed; + + return todos.filter(({ completed }) => completed === isCompleted); + }, [sortBy, todos]); + + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current && isTodoEdit !== null) { + inputRef.current.focus(); + } + }, [isTodoEdit]); + + const handleKeyDown = async ( + e: { key: string; }, todoId:number, todoTitle:string, + ) => { + handleDeleteUptadeTodo(true); + if (e.key === 'Enter') { + let updatedTodo: Todo | undefined = todos.find( + todo => todo.id === todoId, + ); + + if (todoTitle === '' && updatedTodo) { + try { + setIsTodoEdit(null); + handleSelectedTodo([...selectedTodo, todoId]); + await deleteTodo(userId, todoId); + } catch (error) { + handleError(Errors.delete); + } finally { + handleSelectedTodo([]); + } + } else if (updatedTodo && todoTitle !== updatedTodo.title) { + updatedTodo + ? updatedTodo = { ...updatedTodo, title: todoTitle } + : null; + + try { + setIsTodoEdit(null); + handleSelectedTodo([...selectedTodo, todoId]); + await updateTodo(userId, updatedTodo, todoId); + } catch (error) { + handleError(Errors.update); + } finally { + handleSelectedTodo([]); + } + } else { + setIsTodoEdit(null); + } + + handleDeleteUptadeTodo(false); + handleSetMakeAnyChange(!makeAnyChange); + } + + if (e.key === 'Escape') { + setInputValue(''); + setIsTodoEdit(null); + } + }; + + const checkIsLoading = (todo: Todo) => ( + isLoading || (isDeleteUpdateTodo && selectedTodo.includes(todo.id)) + ); + + return ( +
    +
      + {filteredTodos.map(todo => ( +
    • + + + {todo.id === isTodoEdit + ? ( +
      + { + setInputValue(e.target.value); + }} + onKeyDown={(e) => { + handleKeyDown(e, todo.id, inputValue); + }} + onBlur={() => { + handleKeyDown({ key: 'Enter' }, todo.id, inputValue); + }} + /> +
      + ) : ( + <> + { + setIsTodoEdit(todo.id); + setInputValue(todo.title); + }} + > + {todo.title} + + + + + )} + +
      +
      +
      +
      +
    • + ))} + {tempTodo && ( + + )} +
    +
    + ); +}; diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000000..7063081e8c --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { getTodos } from './api/todos'; +import { Errors, Todo } from './types'; + +export const useGetTodos = (USER_ID: number, makeAnyChange: boolean) => { + const [isLoading, setIsLoading] = useState(true); + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(Errors.noError); + + useEffect(() => { + async function fetchTodos() { + try { + const data = await getTodos(USER_ID); + + setTodos(data); + } catch (error) { + setErrorMessage(Errors.load); + } finally { + setIsLoading(false); + } + } + + fetchTodos(); + }, [makeAnyChange]); + + useEffect(() => { + if (errorMessage) { + setTimeout(() => { + setErrorMessage(Errors.noError); + }, 3000); + } + }, [errorMessage]); + + const handleError = (error: Errors) => { + setErrorMessage(error); + }; + + return { + isLoading, + todos, + errorMessage, + handleError, + }; +}; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index 836166156b..69a09c44c1 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -129,5 +129,9 @@ &:active { text-decoration: none; } + + &:disabled { + opacity: 0; + } } } diff --git a/src/types/Errors.ts b/src/types/Errors.ts new file mode 100644 index 0000000000..596af23617 --- /dev/null +++ b/src/types/Errors.ts @@ -0,0 +1,8 @@ +export enum Errors { + noError = '', + emptyTitle = "Title can't be empty", + delete = 'Unable to delete a todo', + add = 'Unable to add a todo', + load = 'Unable to load a todo', + update = 'Unable to update a todo', +} diff --git a/src/types/SortBy.ts b/src/types/SortBy.ts new file mode 100644 index 0000000000..986d61b1af --- /dev/null +++ b/src/types/SortBy.ts @@ -0,0 +1,5 @@ +export enum SortBy { + 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/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000000..2a10bdf118 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './Todo'; +export * from './SortBy'; +export * from './Errors'; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..6d5e913e4f --- /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(300) + .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'), +};