diff --git a/README.md b/README.md index af7dae81f6..dc1212e0b6 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://OlhaMomot.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..231bdee538 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,62 @@ -/* 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, { + useMemo, + useState, +} from 'react'; +import { TodoList } from './components/TodoList'; +import { Header } from './components/Header'; +import { Todo } from './types/Todo'; +import { Footer } from './components/Footer'; +import { ErrorNotification } from './components/ErrorNotification'; +import { FilterOption } from './types/FilterOptions'; +import { useTodo } from './hooks/useTodo'; +import { getFilteredTodos } from './helpers/getFilteredTodos'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [filter, setFilter] = useState(FilterOption.All); + const [tempTodo, setTempTodo] = useState(null); + + const { + todos, + setErrorMessage, + errorMessage, + } = useTodo(); + + const filteredTodos = useMemo( + () => getFilteredTodos(todos, filter), [filter, todos], + ); + + const activeTodos = useMemo(() => { + return todos.filter(({ completed }) => !completed); + }, [todos]); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ +
+ + {!!filteredTodos.length && ( + + )} + + {!!todos.length && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..b0a0712cd2 --- /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) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const addTodo = (newTodo: Omit) => { + return client.post('/todos', newTodo); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, newTodo: Omit) => { + return client.patch(`/todos/${todoId}`, newTodo); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..02117d95c6 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,49 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; +import { useEffect } from 'react'; + +type Props = { + errorMessage: string; + setErrorMessage: (message: string) => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + setErrorMessage, +}) => { + useEffect(() => { + let timer: NodeJS.Timeout | undefined; + + if (errorMessage) { + timer = setTimeout(() => { + setErrorMessage(''); + }, 3000); + } + + return () => { + if (timer) { + clearTimeout(timer); + } + }; + }, [errorMessage]); + + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..3ce738f84e --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,66 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { Todo } from '../types/Todo'; +import { FilterOption } from '../types/FilterOptions'; +import { useTodo } from '../hooks/useTodo'; + +type Props = { + activeTodos: Todo[], + filter: string, + setFilter: (filter: string) => void, +}; + +export const Footer: React.FC = ({ + activeTodos, + filter, + setFilter, +}) => { + const { todos, handleDeleteTodo } = useTodo(); + + const completedTodos = todos.filter(({ completed }) => completed); + + const onDeleteCompletedTodos = async () => { + await Promise.all(completedTodos + .map(({ id }) => handleDeleteTodo(id))); + }; + + return ( +
+ + {activeTodos.length === 1 + ? '1 item left' + : `${activeTodos.length} items left`} + + + + + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..8da04b47b4 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,123 @@ +import React, { + useEffect, useRef, useState, +} from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../types/Todo'; +import { USER_ID } from '../utils/constants'; +import { useTodo } from '../hooks/useTodo'; + +type Props = { + activeTodos: Todo[]; + setTempTodo: (todo: Todo | null) => void; +}; + +export const Header: React.FC = ({ activeTodos, setTempTodo }) => { + const [title, setTitle] = useState(''); + const [titleError, setTitleError] = useState(false); + const [isSubmiting, setIsSubmiting] = useState(false); + const todoTitleField = useRef(null); + const { + todos, + setErrorMessage, + handleAddTodo, + handleUpdateTodo, + } = useTodo(); + + useEffect(() => { + if (todoTitleField.current) { + todoTitleField.current.focus(); + } + }, [todos.length]); + + const onTitleChange = (event: React.ChangeEvent) => { + setTitle(event?.target.value); + + if (titleError) { + setTitleError(false); + } + }; + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + setIsSubmiting(true); + const newTitle = title.trim(); + + if (!newTitle) { + setErrorMessage('Title should not be empty'); + setIsSubmiting(false); + + return; + } + + const newTodo = { + title: newTitle, + userId: USER_ID, + completed: false, + }; + + setTempTodo({ id: 0, ...newTodo }); + + try { + await handleAddTodo(newTodo); + + setIsSubmiting(false); + + setTitle(''); + setTempTodo(null); + } catch (error) { + setIsSubmiting(false); + setTempTodo(null); + } + }; + + const onToggleAll = async () => { + if (activeTodos.length) { + const updatePromises = activeTodos.map(currentTodo => handleUpdateTodo( + currentTodo, + { completed: true }, + )); + + await Promise.all(updatePromises); + } else { + const updatePromises = todos.map(currentTodo => handleUpdateTodo( + currentTodo, + { completed: false }, + )); + + await Promise.all(updatePromises); + } + }; + + return ( +
+ {!!todos.length + && ( + + )} +
+ +
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..a10e69d18c --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,153 @@ +import classNames from 'classnames'; +import React, { + useEffect, + useRef, + useState, +} from 'react'; +import { Todo } from '../types/Todo'; +import { useTodo } from '../hooks/useTodo'; + +type Props = { + todo: Todo, +}; + +export const TodoItem: React.FC = ({ todo }) => { + const [isItemLoading, setIsItemLoading] = useState(todo.id === 0); + const [isEditing, setIsEditing] = useState(false); + const [todoTitle, setTodoTitle] = useState(todo.title); + const todoTitleField = useRef(null); + const { + isLoadingTodoIds, + handleDeleteTodo, + handleUpdateTodo, + } = useTodo(); + + const onCheckboxChange = async () => { + setIsItemLoading(true); + await handleUpdateTodo(todo, { completed: !todo.completed }); + setIsItemLoading(false); + }; + + const handleTodoTitleChange = ( + event: React.ChangeEvent, + ) => { + setTodoTitle(event.target.value); + }; + + const handleTodoDoubleClick = () => { + setIsEditing(true); + }; + + const handleTodoSave = async (event: React.ChangeEvent) => { + event.preventDefault(); + + setIsItemLoading(true); + const title = todoTitle.trim(); + + if (title === todo.title) { + setIsEditing(false); + setIsItemLoading(false); + + return; + } + + if (title) { + await handleUpdateTodo(todo, { title }); + } else { + await handleDeleteTodo(todo.id); + } + + setIsEditing(false); + setIsItemLoading(false); + }; + + const handleTodoDelete = async () => { + setIsItemLoading(true); + await handleDeleteTodo(todo.id); + setIsItemLoading(false); + }; + + useEffect(() => { + if (isEditing && todoTitleField.current) { + todoTitleField.current.focus(); + } + }, [isEditing]); + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditing(false); + setTodoTitle(todo.title); + } + }; + + const isLoaderActive = (isLoadingTodoIds.includes(todo.id)) + || isItemLoading; + + return ( +
+ + + {isEditing + ? ( +
+ +
+ ) : ( + <> + + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..98b728ea30 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,24 @@ +import { TodoItem } from './TodoItem'; +import { Todo } from '../types/Todo'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; +}; + +export const TodoList: React.FC = ({ todos, tempTodo }) => { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/components/TodoProvider.tsx b/src/components/TodoProvider.tsx new file mode 100644 index 0000000000..94d2afb80e --- /dev/null +++ b/src/components/TodoProvider.tsx @@ -0,0 +1,124 @@ +import React, { createContext, useEffect, useState } from 'react'; + +import { Todo } from '../types/Todo'; +import { + addTodo, + deleteTodo, + getTodos, + updateTodo, +} from '../api/todos'; +import { USER_ID } from '../utils/constants'; + +export interface TodoContextProps { + todos: Todo[]; + handleAddTodo: (todo: Omit) => Promise; + handleDeleteTodo: (todoId: number) => Promise; + handleUpdateTodo: (todo: Todo, property: Partial) => Promise; + errorMessage: string; + setErrorMessage: (str: string) => void; + isLoadingTodoIds: number[]; +} + +export const TodoContext = createContext({ + todos: [], + handleAddTodo: async () => { }, + handleDeleteTodo: async () => { }, + handleUpdateTodo: async () => { }, + errorMessage: '', + setErrorMessage: () => { }, + isLoadingTodoIds: [], +}); + +type TodoProviderProps = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoadingTodoIds, setIsLoadingTodoIds] = useState([]); + + const handleAddTodo = async ( + newTodo: Omit, + ) => { + try { + const createdTodo = await addTodo(newTodo); + + setTodos((currentTodos) => [...currentTodos, createdTodo]); + } catch (error) { + setErrorMessage('Unable to add a todo'); + throw new Error(); + } + }; + + const handleUpdateTodo = async ( + todo: Todo, + propertiesToUpdate: Partial, + ) => { + setIsLoadingTodoIds(prevLoadingTodoIds => ([ + ...prevLoadingTodoIds, + todo.id, + ])); + + try { + const updatedTodo = await updateTodo(todo.id, { + ...todo, + ...propertiesToUpdate, + }); + + setTodos((currentTodos: Todo[]) => { + return currentTodos.map((currentTodo) => { + return currentTodo.id === updatedTodo.id + ? updatedTodo + : currentTodo; + }) as Todo[]; + }); + } catch (error) { + setErrorMessage('Unable to update todo'); + } + + setIsLoadingTodoIds(prevLoadingTodos => prevLoadingTodos.filter( + id => todo.id !== id, + )); + }; + + const handleDeleteTodo = async (todoId: number) => { + setIsLoadingTodoIds(prevLoadingTodoIds => ([ + ...prevLoadingTodoIds, + todoId, + ])); + + try { + await deleteTodo(todoId); + setTodos(currentTodos => currentTodos.filter(({ id }) => id !== todoId)); + } catch { + setErrorMessage('Unable to delete a todo'); + } + + setIsLoadingTodoIds(prevLoadingTodos => prevLoadingTodos.filter( + id => todoId !== id, + )); + }; + + useEffect(() => { + getTodos(USER_ID) + .then(setTodos) + .catch(() => setErrorMessage('Unable to load todos')); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/helpers/getFilteredTodos.tsx b/src/helpers/getFilteredTodos.tsx new file mode 100644 index 0000000000..90a0fc6080 --- /dev/null +++ b/src/helpers/getFilteredTodos.tsx @@ -0,0 +1,16 @@ +import { FilterOption } from '../types/FilterOptions'; +import { Todo } from '../types/Todo'; + +export const getFilteredTodos = (todos: Todo[], filter: string) => { + return todos.filter(({ completed }) => { + switch (filter) { + case FilterOption.Active: + return !completed; + case FilterOption.Completed: + return completed; + case FilterOption.All: + default: + return true; + } + }); +}; diff --git a/src/hooks/useTodo.tsx b/src/hooks/useTodo.tsx new file mode 100644 index 0000000000..6b64fb8a35 --- /dev/null +++ b/src/hooks/useTodo.tsx @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import { TodoContext } from '../components/TodoProvider'; + +export const useTodo = () => { + const todoContextValues = useContext(TodoContext); + + return todoContextValues; +}; diff --git a/src/index.tsx b/src/index.tsx index 7de19e0c70..7e81c03da4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,11 @@ import '@fortawesome/fontawesome-free/css/all.css'; import './styles/index.scss'; import { App } from './App'; +import { TodoProvider } from './components/TodoProvider'; createRoot(document.getElementById('root') as HTMLDivElement) - .render(); + .render( + + + , + ); diff --git a/src/styles/todo.scss b/src/styles/todo.scss index c7f93ff6b9..fc99714ef2 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -8,7 +8,7 @@ font-size: 24px; line-height: 1.4em; border-bottom: 1px solid #ededed; - + &:last-child { border-bottom: 0; } @@ -30,7 +30,7 @@ &__title { padding: 12px 15px; - + word-break: break-all; transition: color 0.4s; } @@ -56,7 +56,7 @@ border: 0; background: none; cursor: pointer; - + transform: translateY(-2px); opacity: 0; transition: color 0.2s ease-out; @@ -65,7 +65,7 @@ color: #af5b5e; } } - + &:hover &__remove { opacity: 1; } @@ -73,13 +73,13 @@ &__title-field { width: 100%; padding: 11px 14px; - + font-size: inherit; line-height: inherit; font-family: inherit; font-weight: inherit; color: inherit; - + border: 1px solid #999; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); @@ -92,58 +92,11 @@ .overlay { position: absolute; - top: 0; left: 0; right: 0; - height: 58px; + bottom: 0; + top: 0; opacity: 0.5; } } - -.item-enter { - max-height: 0; -} - -.item-enter-active { - overflow: hidden; - max-height: 58px; - transition: max-height 0.3s ease-in-out; -} - -.item-exit { - max-height: 58px; -} - -.item-exit-active { - overflow: hidden; - max-height: 0; - transition: max-height 0.3s ease-in-out; -} - -.temp-item-enter { - max-height: 0; -} - -.temp-item-enter-active { - overflow: hidden; - max-height: 58px; - transition: max-height 0.3s ease-in-out; -} - -.temp-item-exit { - max-height: 58px; -} - -.temp-item-exit-active { - transform: translateY(-58px); - max-height: 0; - opacity: 0; - transition: 0.3s ease-in-out; - transition-property: opacity, max-height, transform; -} - -.has-error .temp-item-exit-active { - transform: translateY(0); - overflow: hidden; -} diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index 9095f1847f..836166156b 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -121,7 +121,6 @@ appearance: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - transition: opacity 0.3s; &:hover { text-decoration: underline; @@ -130,9 +129,5 @@ &:active { text-decoration: none; } - - &:disabled { - visibility: hidden; - } } } diff --git a/src/types/FilterOptions.tsx b/src/types/FilterOptions.tsx new file mode 100644 index 0000000000..3d3ecd02f0 --- /dev/null +++ b/src/types/FilterOptions.tsx @@ -0,0 +1,5 @@ +export enum FilterOption { + 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/constants.tsx b/src/utils/constants.tsx new file mode 100644 index 0000000000..1aa2c9b5c0 --- /dev/null +++ b/src/utils/constants.tsx @@ -0,0 +1 @@ +export const USER_ID = 11503; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..ca588ab63a --- /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', + }; + } + + // we wait for testing purpose to see loaders + 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'), +};