diff --git a/src/App.tsx b/src/App.tsx index 5749bdf78..12c94cd58 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,50 @@ -/* 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, { useEffect, useState } from 'react'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoList } from './components/TodoList'; +import { TodoFooter } from './components/TodoFooter'; +import { TodoNotification } from './components/TodoNotification'; +import { useTodo } from './context/TodoContext'; +import { getTodos } from './api/todos'; +import { useError } from './context/ErrorContext'; +import { USER_ID } from './utils/variables'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const { todos, setTodos } = useTodo(); + const { setErrorMessage } = useError(); + + const [isActive, setIsActive] = useState(false); + const [isToggleActive, setIsToggleActive] = useState([]); + + useEffect(() => { + getTodos(USER_ID) + .then(setTodos) + .catch(() => { + setErrorMessage('Unable to load todos'); + }); + }, []); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ + + + + {Boolean(todos.length) && ( + + )} +
+ + +
); }; diff --git a/src/UserWarning.tsx b/src/UserWarning.tsx deleted file mode 100644 index db7dd16e3..000000000 --- a/src/UserWarning.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -export const UserWarning: React.FC = () => ( -
-

- Please get your - {' '} - userId - {' '} - - here - - {' '} - and save it in the app - {' '} -

const USER_ID = ...
- - All requests to the API must be sent with this - userId. -

-
-); diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..7624419fb --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,20 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const createTodo = ({ + userId, title, completed, +}: Omit) => { + return client.post('/todos', { userId, title, completed }); +}; + +export const updateTodo = (todoId: number, todo: Partial) => { + return client.patch(`/todos/${todoId}`, todo); +}; diff --git a/src/components/TodoEditItem/TodoEditItem.tsx b/src/components/TodoEditItem/TodoEditItem.tsx new file mode 100644 index 000000000..54ba86f73 --- /dev/null +++ b/src/components/TodoEditItem/TodoEditItem.tsx @@ -0,0 +1,130 @@ +import { + useEffect, useRef, useState, +} from 'react'; +import classnames from 'classnames'; +import { Todo } from '../../types/Todo'; +import { useTodo } from '../../context/TodoContext'; +import { useError } from '../../context/ErrorContext'; +import { updateTodo } from '../../api/todos'; + +type Props = { + todo: Todo; + onEditedId: () => void; + onDelete: (value: Todo) => void; + isLoading: boolean; + onLoad: (value: boolean) => void; +}; + +export const TodoEditItem: React.FC = ({ + todo, onEditedId, onDelete, isLoading, onLoad, +}) => { + const { id, completed } = todo; + + const { todos, setTodos } = useTodo(); + const { setErrorMessage } = useError(); + + const editInputRef = useRef(null); + + useEffect(() => { + editInputRef.current?.focus(); + }, []); + + const [newTitle, setNewTitle] = useState(todo.title); + + const updateNewTodo = () => { + if (!newTitle.trim()) { + onDelete(todo); + + return; + } + + const updatedTodos = todos.map((currentTodo) => { + if (currentTodo.id === id) { + return { + ...currentTodo, + title: newTitle, + }; + } + + return currentTodo; + }); + + const updatedTodo = updatedTodos.find(({ id: updatedId }) => { + return updatedId === id; + }); + + onLoad(true); + + if (updatedTodo) { + updateTodo(todo.id, updatedTodo) + .then(() => setTodos(updatedTodos)) + .catch(() => setErrorMessage('Unable to update a todo')) + .finally(() => { + onLoad(false); + onEditedId(); + }); + } + }; + + const handlePressEscape = ( + event: React.KeyboardEvent, + ) => { + if (event.key === 'Escape') { + onEditedId(); + } + }; + + const handleEditTodo = ( + event: React.FormEvent + | React.FocusEvent, + ) => { + event.preventDefault(); + + if (todo.title === newTitle) { + onEditedId(); + + return; + } + + updateNewTodo(); + }; + + return ( +
+ + +
+ setNewTitle(event.target.value)} + onBlur={handleEditTodo} + onKeyUp={handlePressEscape} + /> +
+ +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoEditItem/index.ts b/src/components/TodoEditItem/index.ts new file mode 100644 index 000000000..d9de65de4 --- /dev/null +++ b/src/components/TodoEditItem/index.ts @@ -0,0 +1 @@ +export * from './TodoEditItem'; diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..09d050d9e --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,98 @@ +import { useMemo } from 'react'; +import classnames from 'classnames'; +import { Status } from '../../types/Status'; +import { useFilter } from '../../context/FilterContext'; +import { useTodo } from '../../context/TodoContext'; +import { deleteTodo } from '../../api/todos'; +import { useError } from '../../context/ErrorContext'; + +export const TodoFooter = () => { + const { selectedFilter, setSelectedFilter } = useFilter(); + const { todos, setTodos } = useTodo(); + const { setErrorMessage } = useError(); + + const isActiveItemsLeft = todos + .filter(({ completed }) => !completed).length; + + const hasCompletedTodo = useMemo(() => { + return todos.some(todo => todo.completed); + }, [todos]); + + const clearCompleted = () => { + const deletePromises = todos + .filter((todo) => todo.completed) + .map(({ id }) => deleteTodo(id)); + + Promise.all(deletePromises) + .then(() => { + setTodos((prevState) => prevState.filter((todo) => !todo.completed)); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }); + }; + + return ( + + ); +}; diff --git a/src/components/TodoFooter/index.ts b/src/components/TodoFooter/index.ts new file mode 100644 index 000000000..544d07114 --- /dev/null +++ b/src/components/TodoFooter/index.ts @@ -0,0 +1 @@ +export * from './TodoFooter'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..8e7288303 --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,138 @@ +import { + useEffect, + useRef, + useState, +} from 'react'; +import classnames from 'classnames'; + +import { useTodo } from '../../context/TodoContext'; +import { useError } from '../../context/ErrorContext'; +import { useTodoTemp } from '../../context/TodoTempContext'; +import { USER_ID } from '../../utils/variables'; +import { createTodo, updateTodo } from '../../api/todos'; +import { Todo } from '../../types/Todo'; + +type Props = { + onHandleActive: (value: boolean) => void; + onToggleActive: (value: number[]) => void; +}; + +export const TodoHeader: React.FC = ({ + onHandleActive, onToggleActive, +}) => { + const { setTodoTemp } = useTodoTemp(); + const [title, setTitle] = useState(''); + const { todos, setTodos } = useTodo(); + const { errorMessage, setErrorMessage } = useError(); + + const [isInputDisabled, setIsInputDisabled] = useState(false); + + const inputRef = useRef(null); + + const isActiveItemsLeft = todos.some(({ completed }) => !completed); + + useEffect(() => { + inputRef?.current?.focus(); + }, [todos, errorMessage]); + + const toggleAll = async () => { + const isAllCompleted = todos.every(({ completed }) => completed); + const todosForUpdate = isAllCompleted + ? todos + : todos.filter(({ completed }) => !completed); + const todosIds = todosForUpdate.map(({ id }) => id); + const updatePromises = todos.map(todo => ( + updateTodo(todo.id, { ...todo, completed: !isAllCompleted }) + )); + + onToggleActive(todosIds); + + try { + await Promise.all(updatePromises); + + setTodos(currentTodos => { + return currentTodos.map(todo => ({ + ...todo, + completed: !isAllCompleted, + })); + }); + + onToggleActive([]); + } catch (error) { + setErrorMessage(`Error updating todos: ${error}`); + } + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + setErrorMessage('Title should not be empty'); + inputRef?.current?.focus(); + + return; + } + + const newTodo = { + userId: USER_ID, + title: title.trim(), + completed: false, + }; + + setTodoTemp({ + ...newTodo, + id: 0, + }); + + setIsInputDisabled(true); + onHandleActive(true); + + createTodo(newTodo) + .then((response) => { + setTodos((prevTodos: Todo[]) => [...prevTodos, response]); + setIsInputDisabled(false); + setTitle(''); + }) + .catch(() => { + setIsInputDisabled(false); + setErrorMessage('Unable to add a todo'); + }) + .finally(() => { + setTodoTemp(null); + inputRef?.current?.focus(); + onHandleActive(false); + }); + }; + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/TodoHeader/index.ts b/src/components/TodoHeader/index.ts new file mode 100644 index 000000000..c4db4bc40 --- /dev/null +++ b/src/components/TodoHeader/index.ts @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..c3ef4ca47 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import classnames from 'classnames'; +import { Todo } from '../../types/Todo'; +import { useTodo } from '../../context/TodoContext'; +import { updateTodo } from '../../api/todos'; +import { useError } from '../../context/ErrorContext'; + +type Props = { + todo: Todo; + onDelete?: (value: Todo) => void; + onEditedId?: (value: number | null) => void; + isDeleteActive?: boolean; + onDeleteActive?: (value: boolean) => void; + isLoading?: boolean; + isToggleActive?: number[]; +}; + +export const TodoItem: React.FC = ({ + todo, + onDelete = () => {}, + onEditedId = () => {}, + isDeleteActive, + isLoading, + isToggleActive, +}) => { + const { setTodos } = useTodo(); + const { setErrorMessage } = useError(); + const { id, title, completed } = todo; + + const [deletedTodoId, setDeletedTodoId] = useState(null); + + const [isCompleteActive, setIsCompleteActive] = useState(false); + + const handleComplete = ( + event: React.ChangeEvent, + ) => { + const updatedTodo = { ...todo, completed: event.target.checked }; + + setIsCompleteActive(true); + + updateTodo(todo.id, updatedTodo) + .then(() => { + setTodos((prevTodo) => prevTodo.map((currentTodo) => { + return currentTodo.id === todo.id ? updatedTodo : currentTodo; + })); + }) + .catch(() => setErrorMessage('Unable to update todo')) + .finally(() => setIsCompleteActive(false)); + }; + + const handleDelete = () => { + setDeletedTodoId(todo.id); + onDelete(todo); + }; + + const isActiveLoader = (isDeleteActive && id === deletedTodoId) + || isToggleActive?.includes(id) || isCompleteActive || isLoading; + + return ( +
+ + + onEditedId(id)} + > + {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..3923f2615 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; + +import { getFilteredTodos } from '../../utils/utils'; +import { TodoItem } from '../TodoItem'; +import { useTodo } from '../../context/TodoContext'; +import { useFilter } from '../../context/FilterContext'; +import { useTodoTemp } from '../../context/TodoTempContext'; +import { deleteTodo } from '../../api/todos'; +import { useError } from '../../context/ErrorContext'; +import { TodoEditItem } from '../TodoEditItem'; +import { Todo } from '../../types/Todo'; + +type Props = { + isActive: boolean; + onHandleActive?: (value: boolean) => void; + isToggleActive: number[]; +}; + +export const TodoList: React.FC = ({ + isActive, isToggleActive, +}) => { + const { todos, setTodos } = useTodo(); + const { selectedFilter } = useFilter(); + const { todoTemp } = useTodoTemp(); + const { setErrorMessage } = useError(); + + const filteredTodos = getFilteredTodos(todos, selectedFilter); + + const [isLoading, setIsLoading] = useState(false); + const [editedId, setEditedId] = useState(null); + const [isDeleteActive, setIsDeleteActive] = useState(false); + + const handleDeleteTodo = (todo: Todo) => { + setIsLoading(true); + setIsDeleteActive(true); + + deleteTodo(todo.id) + .then(() => { + setTodos(todos.filter(({ id }) => id !== todo.id)); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }) + .finally(() => { + setIsLoading(false); + setIsDeleteActive(false); + }); + }; + + return ( +
+ {filteredTodos.map(todo => ( + editedId === todo.id ? ( + setEditedId(null)} + onDelete={handleDeleteTodo} + isLoading={isLoading} + onLoad={setIsLoading} + /> + ) : ( + + ) + ))} + + {todoTemp && ( + + )} +
+ ); +}; 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/TodoNotification/TodoNotification.tsx b/src/components/TodoNotification/TodoNotification.tsx new file mode 100644 index 000000000..1b563cc83 --- /dev/null +++ b/src/components/TodoNotification/TodoNotification.tsx @@ -0,0 +1,42 @@ +import classnames from 'classnames'; +import { useEffect } from 'react'; +import { useError } from '../../context/ErrorContext'; + +export const TodoNotification: React.FC = () => { + const { + errorMessage, + isErrorHidden, + setIsErrorHidden, + } = useError(); + + useEffect(() => { + if (errorMessage.length) { + setIsErrorHidden(false); + } + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }, [errorMessage]); + + return ( +
+
+ ); +}; diff --git a/src/components/TodoNotification/index.ts b/src/components/TodoNotification/index.ts new file mode 100644 index 000000000..f29aba7e0 --- /dev/null +++ b/src/components/TodoNotification/index.ts @@ -0,0 +1 @@ +export * from './TodoNotification'; diff --git a/src/context/ErrorContext.tsx b/src/context/ErrorContext.tsx new file mode 100644 index 000000000..b5a6b0ead --- /dev/null +++ b/src/context/ErrorContext.tsx @@ -0,0 +1,43 @@ +import { + useState, createContext, useContext, +} from 'react'; + +interface ErrorContextType { + errorMessage: string; + setErrorMessage: React.Dispatch>; + isErrorHidden: boolean; + setIsErrorHidden: React.Dispatch>; +} + +export const ErrorContext = createContext({ + errorMessage: '', + setErrorMessage: () => {}, + isErrorHidden: true, + setIsErrorHidden: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const ErrorProvider: React.FC = ({ children }) => { + const [errorMessage, setErrorMessage] = useState(''); + const [isErrorHidden, setIsErrorHidden] = useState(true); + + const value = { + errorMessage, + setErrorMessage, + isErrorHidden, + setIsErrorHidden, + }; + + return ( + + {children} + + ); +}; + +export const useError = (): ErrorContextType => { + return useContext(ErrorContext); +}; diff --git a/src/context/FilterContext.tsx b/src/context/FilterContext.tsx new file mode 100644 index 000000000..962be3892 --- /dev/null +++ b/src/context/FilterContext.tsx @@ -0,0 +1,35 @@ +import { useState, createContext, useContext } from 'react'; +import { Status } from '../types/Status'; + +interface FilterContextType { + selectedFilter: Status; + setSelectedFilter: React.Dispatch>; +} + +export const FilterContext = createContext({ + selectedFilter: Status.All, + setSelectedFilter: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const FilterProvider: React.FC = ({ children }) => { + const [selectedFilter, setSelectedFilter] = useState(Status.All); + + const value = { + selectedFilter, + setSelectedFilter, + }; + + return ( + + {children} + + ); +}; + +export const useFilter = (): FilterContextType => { + return useContext(FilterContext); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..2fcb714c7 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,37 @@ +import { + useState, createContext, useContext, +} from 'react'; +import { Todo } from '../types/Todo'; + +interface TodoContextType { + todos: Todo[]; + setTodos: React.Dispatch>; +} + +export const TodoContext = createContext({ + todos: [], + setTodos: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + + const value = { + todos, + setTodos, + }; + + return ( + + {children} + + ); +}; + +export const useTodo = (): TodoContextType => { + return useContext(TodoContext); +}; diff --git a/src/context/TodoTempContext.tsx b/src/context/TodoTempContext.tsx new file mode 100644 index 000000000..7b03a0c4a --- /dev/null +++ b/src/context/TodoTempContext.tsx @@ -0,0 +1,37 @@ +import { + useState, createContext, useContext, +} from 'react'; +import { Todo } from '../types/Todo'; + +interface TodoTempContextType { + todoTemp: Todo | null; + setTodoTemp: React.Dispatch>; +} + +export const TodoTempContext = createContext({ + todoTemp: null, + setTodoTemp: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodoTempProvider: React.FC = ({ children }) => { + const [todoTemp, setTodoTemp] = useState(null); + + const value = { + todoTemp, + setTodoTemp, + }; + + return ( + + {children} + + ); +}; + +export const useTodoTemp = (): TodoTempContextType => { + return useContext(TodoTempContext); +}; diff --git a/src/index.tsx b/src/index.tsx index 7de19e0c7..5dce46795 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,20 @@ import '@fortawesome/fontawesome-free/css/all.css'; import './styles/index.scss'; import { App } from './App'; +import { TodoProvider } from './context/TodoContext'; +import { FilterProvider } from './context/FilterContext'; +import { ErrorProvider } from './context/ErrorContext'; +import { TodoTempProvider } from './context/TodoTempContext'; createRoot(document.getElementById('root') as HTMLDivElement) - .render(); + .render( + + + + + + + + + , + ); diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index 836166156..2db666227 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -129,5 +129,9 @@ &:active { text-decoration: none; } + + &:disabled { + visibility: hidden; + } } } diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 000000000..2ec4b8714 --- /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 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..ccf6f6cb6 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; +// https://mate.academy/students-api/todos?userId=11458 + +// 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', + }; + } + + // we wait for testing purpose to see loaders + 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'), +}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 000000000..096baeed7 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,19 @@ +import { Status } from '../types/Status'; +import { Todo } from '../types/Todo'; + +export const getFilteredTodos = (todos: Todo[], selectedFilter: Status) => { + const todosCopy = [...todos]; + + if (selectedFilter) { + switch (selectedFilter) { + case Status.Active: + return todosCopy.filter(({ completed }) => !completed); + case Status.Completed: + return todosCopy.filter(({ completed }) => completed); + default: + return todosCopy; + } + } + + return todosCopy; +}; diff --git a/src/utils/variables.ts b/src/utils/variables.ts new file mode 100644 index 000000000..3dbfce90f --- /dev/null +++ b/src/utils/variables.ts @@ -0,0 +1 @@ +export const USER_ID = 11458;