diff --git a/README.md b/README.md index af7dae81f6..b69b06391d 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://dpidlutska.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..74a5aa2eaa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,55 @@ -/* 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, { + useContext, + useEffect, useMemo, useState, +} from 'react'; +import { TodoForm } from './components/TodoForm'; +import { TodoList } from './components/TodoList'; +import { TodoFilter } from './components/TodoFilter'; +import * as todoService from './api/todos'; +import { TodosFilter } from './types/TodosFilter'; +import { getFilteredTodos } from './utils/getFilteredTodos'; +import { TodoContext } from './Context/TodoContext'; +import { ErrorNotification } from './components/ErrorNotification'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const { + todos, + setTodos, + setErrorMessage, + } = useContext(TodoContext); + + const [filter, setFilter] = useState(TodosFilter.All); + + useEffect(() => { + todoService.getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage('Unable to load todos'); + }); + }, []); + + const filteredTodos = useMemo(() => { + return getFilteredTodos(todos, filter); + }, [todos, filter]); return ( -
-

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

+
+

todos

+ +
+ + + {Boolean(todos.length) && ( + + )} -

Styles are already copied

-
+ + + ); }; diff --git a/src/Context/TodoContext.tsx b/src/Context/TodoContext.tsx new file mode 100644 index 0000000000..d784e01c24 --- /dev/null +++ b/src/Context/TodoContext.tsx @@ -0,0 +1,176 @@ +import React, { createContext, useMemo, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { USER_ID } from '../utils/constants'; +import * as todoService from '../api/todos'; +import { getCompletedTodos, getUncompletedTodos } from '../utils/countTodos'; + +interface TodoContextTypes { + todos: Todo[]; + setTodos: React.Dispatch>; + completedTodos: Todo[]; + uncompletedTodos: Todo[]; + errorMessage: string; + setErrorMessage: React.Dispatch>; + tempTodo: Todo | null; + setTempTodo: React.Dispatch>; + todosIdToProcess: number[]; + setTodosIdToProcess: React.Dispatch>; + handleAddTodo: (todoTitle: string) => Promise; + handleDeleteTodo: (todoId: number) => void; + handleRenameTodo: (todo: Todo, newTodoTitle: string) => void; + handleClearCompletedTodos: () => void; + handleStatusTodoChange: (todo: Todo) => void; +} + +const initTodoContext: TodoContextTypes = { + todos: [], + setTodos: () => {}, + completedTodos: [], + uncompletedTodos: [], + errorMessage: '', + setErrorMessage: () => {}, + tempTodo: null, + setTempTodo: () => {}, + todosIdToProcess: [], + setTodosIdToProcess: () => {}, + handleAddTodo: async () => {}, + handleDeleteTodo: () => {}, + handleRenameTodo: () => {}, + handleClearCompletedTodos: () => {}, + handleStatusTodoChange: () => {}, +}; + +export const TodoContext = createContext(initTodoContext); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [todosIdToProcess, setTodosIdToProcess] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const completedTodos = getCompletedTodos(todos); + const uncompletedTodos = getUncompletedTodos(todos); + + const handleAddTodo = (todoTitle: string) => { + setTempTodo({ + id: 0, + title: todoTitle, + userId: USER_ID, + completed: false, + }); + + return todoService + .addTodo(todoTitle) + .then((newTodo) => { + setTodos((prevTodos) => [...prevTodos, newTodo]); + }) + .catch((error) => { + setErrorMessage('Unable to add a todo'); + throw error; + }) + .finally(() => { + setTempTodo(null); + }); + }; + + const handleDeleteTodo = (todoId: number) => { + setTodosIdToProcess(prevState => [...prevState, todoId]); + + todoService + .deleteTodo(todoId) + .then(() => { + setTodos(prevTodos => prevTodos.filter(({ id }) => id !== todoId)); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }) + .finally(() => { + setTodosIdToProcess(prevState => prevState.filter(id => id !== todoId)); + }); + }; + + const handleRenameTodo = (todo: Todo, newTodoTitle: string) => { + setTodosIdToProcess(prevState => [...prevState, todo.id]); + todoService + .updateTodo({ + id: todo.id, + title: newTodoTitle, + userId: todo.userId, + completed: todo.completed, + }) + .then(updatedTodo => { + setTodos(prevTodos => prevTodos.map(currentTodo => ( + currentTodo.id !== updatedTodo.id + ? currentTodo + : updatedTodo + ))); + }) + .catch((error) => { + setErrorMessage('Unable to update a todo'); + throw error; + }) + .finally(() => { + setTodosIdToProcess( + prevState => prevState.filter(id => id !== todo.id), + ); + }); + }; + + const handleClearCompletedTodos = () => { + completedTodos.forEach(todo => ( + handleDeleteTodo(todo.id))); + }; + + const handleStatusTodoChange = (todo: Todo) => { + setTodosIdToProcess(prevState => [...prevState, todo.id]); + todoService + .updateTodo({ + ...todo, + completed: !todo.completed, + }) + .then(updatedTodo => { + setTodos(prevTodos => prevTodos.map(currentTodo => ( + currentTodo.id !== updatedTodo.id + ? currentTodo + : updatedTodo + ))); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + }) + .finally(() => { + setTodosIdToProcess( + prevState => prevState.filter(id => id !== todo.id), + ); + }); + }; + + const value = useMemo(() => ({ + todos, + setTodos, + completedTodos, + uncompletedTodos, + errorMessage, + setErrorMessage, + tempTodo, + setTempTodo, + todosIdToProcess, + setTodosIdToProcess, + handleAddTodo, + handleDeleteTodo, + handleRenameTodo, + handleClearCompletedTodos, + handleStatusTodoChange, + }), [todos, errorMessage, tempTodo, todosIdToProcess]); + + return ( + + {children} + + ); +}; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..cda61333f8 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,29 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; +import { USER_ID } from '../utils/constants'; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const addTodo = (todoTitle: string) => { + return client.post('/todos', { + title: todoTitle, + userId: USER_ID, + completed: false, + }); +}; + +export const updateTodo = ({ + id, title, userId, completed, +}: Todo): Promise => { + return client.patch(`/todos/${id}`, { + title, + userId, + completed, + }); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..ecb3855704 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,42 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useContext, useEffect, useRef } from 'react'; +import cn from 'classnames'; +import { TodoContext } from '../Context/TodoContext'; + +export const ErrorNotification: React.FC = () => { + const { + errorMessage, + setErrorMessage, + } = useContext(TodoContext); + + const timerId = useRef(0); + + useEffect(() => { + if (timerId.current) { + window.clearTimeout(timerId.current); + } + + timerId.current = window.setTimeout(() => { + setErrorMessage(''); + }, 3000); + }, [errorMessage]); + + return ( +
+
+ ); +}; diff --git a/src/components/TodoFilter.tsx b/src/components/TodoFilter.tsx new file mode 100644 index 0000000000..b2657b9f07 --- /dev/null +++ b/src/components/TodoFilter.tsx @@ -0,0 +1,87 @@ +import React, { useContext } from 'react'; +import cn from 'classnames'; +import { TodosFilter } from '../types/TodosFilter'; +import { TodoContext } from '../Context/TodoContext'; + +const setFilterDataCyAndHref = (filter: TodosFilter) => { + switch (filter) { + case TodosFilter.Active: + return { + href: '#/active', + dataCy: 'FilterLinkActive', + }; + + case TodosFilter.Completed: + return { + href: '#/completed', + dataCy: 'FilterLinkCompleted', + }; + + case TodosFilter.All: + default: + return { + href: '#/', + dataCy: 'FilterLinkAll', + }; + } +}; + +type Props = { + filter: TodosFilter; + onFilterChange: (filter: TodosFilter) => void; +}; + +export const TodoFilter: React.FC = ({ + filter, + onFilterChange, +}) => { + const { + completedTodos, + uncompletedTodos, + + handleClearCompletedTodos, + } = useContext(TodoContext); + const uncompletedTodosAmount = uncompletedTodos.length; + const hasCompletedTodos = !!completedTodos.length; + + return ( + + ); +}; diff --git a/src/components/TodoForm.tsx b/src/components/TodoForm.tsx new file mode 100644 index 0000000000..d9d0ab0e22 --- /dev/null +++ b/src/components/TodoForm.tsx @@ -0,0 +1,98 @@ +import React, { + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import cn from 'classnames'; +import { TodoContext } from '../Context/TodoContext'; + +export const TodoForm: React.FC = () => { + const { + todos, + completedTodos, + uncompletedTodos, + handleAddTodo, + setErrorMessage, + handleStatusTodoChange, + } = useContext(TodoContext); + + const isTodosToShow = !!todos.length; + const titleField = useRef(null); + const [title, setTitle] = useState(''); + const [isAdding, setIsAdding] = useState(false); + + const areAllTodoCompleted = todos.length === completedTodos.length; + + const handleTitleChange = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const handleToggleAll = () => { + if (areAllTodoCompleted) { + completedTodos.forEach(todo => handleStatusTodoChange(todo)); + } else { + uncompletedTodos.forEach(todo => handleStatusTodoChange(todo)); + } + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const preparedTitle = title.trim(); + + if (!preparedTitle) { + setErrorMessage('Title should not be empty'); + + return; + } + + setIsAdding(true); + + handleAddTodo(preparedTitle) + .then(() => { + setTitle(''); + }) + .catch(() => { + setErrorMessage('Unable to add todo'); + }) + .finally(() => { + setIsAdding(false); + }); + }; + + useEffect(() => { + if (titleField.current) { + titleField.current.focus(); + } + }); + + return ( +
+ {isTodosToShow && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..0ba3d43484 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,133 @@ +import React, { + useContext, useEffect, useRef, useState, +} from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; +import { TodoContext } from '../Context/TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { + handleRenameTodo, + handleDeleteTodo, + todosIdToProcess, + handleStatusTodoChange, + } = useContext(TodoContext); + + const { completed, title, id } = todo; + const [isEditing, setIsEditing] = useState(false); + const [todoTitle, setTodoTitle] = useState(title); + const isProcessing = todosIdToProcess.includes(id) || id === 0; + + const titleInput = useRef(null); + + useEffect(() => { + if (isEditing && titleInput.current) { + titleInput.current.focus(); + } + }, [isEditing]); + + const handleTodoSave = async (event: React.FormEvent) => { + event.preventDefault(); + + const preparedTodoTitle = todoTitle.trim(); + + try { + if (preparedTodoTitle) { + await handleRenameTodo(todo, preparedTodoTitle); + } else { + await handleDeleteTodo(id); + } + + setIsEditing(false); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + }; + + const handleTodoTitleChange = ( + event: React.ChangeEvent, + ) => { + setTodoTitle(event.target.value); + }; + + const handleTodoDoubleClick = () => { + setIsEditing(true); + }; + + const handlePressedKey = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setTodoTitle(title); + } + }; + + return ( +
+ + + {isEditing + ? ( +
+ +
+ ) : ( + <> + + {title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..bbd696f2fd --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,31 @@ +import React, { useContext } from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; +import { TodoContext } from '../Context/TodoContext'; + +type Props = { + todos: Todo[]; +}; + +export const TodoList: React.FC = ({ + todos, +}) => { + const { tempTodo } = useContext(TodoContext); + + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index 7de19e0c70..d3ca54266f 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 './Context/TodoContext'; createRoot(document.getElementById('root') as HTMLDivElement) - .render(); + .render( + + + , + ); diff --git a/src/styles/index.scss b/src/styles/index.scss index bccd80c8bc..5a6d933c84 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -14,7 +14,7 @@ body { min-height: 36px; } -.notification.hidden { +.hidden { min-height: 0; opacity: 0; pointer-events: none; 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/TodosFilter.ts b/src/types/TodosFilter.ts new file mode 100644 index 0000000000..5b2422df9e --- /dev/null +++ b/src/types/TodosFilter.ts @@ -0,0 +1,5 @@ +export enum TodosFilter { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000000..628918d43b --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1 @@ +export const USER_ID = 11513; diff --git a/src/utils/countTodos.ts b/src/utils/countTodos.ts new file mode 100644 index 0000000000..2c9c2fbefa --- /dev/null +++ b/src/utils/countTodos.ts @@ -0,0 +1,9 @@ +import { Todo } from '../types/Todo'; + +export const getUncompletedTodos = (todos: Todo[]) => { + return todos.filter(({ completed }) => !completed); +}; + +export const getCompletedTodos = (todos: Todo[]) => { + return todos.filter(({ completed }) => completed); +}; 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'), +}; diff --git a/src/utils/getFilteredTodos.ts b/src/utils/getFilteredTodos.ts new file mode 100644 index 0000000000..14630e31a9 --- /dev/null +++ b/src/utils/getFilteredTodos.ts @@ -0,0 +1,15 @@ +import { Todo } from '../types/Todo'; +import { TodosFilter } from '../types/TodosFilter'; + +export const getFilteredTodos = (todos: Todo[], filter: TodosFilter) => { + switch (filter) { + case TodosFilter.Active: + return todos.filter(({ completed }) => !completed); + + case TodosFilter.Completed: + return todos.filter(({ completed }) => completed); + + default: + return todos; + } +};