diff --git a/README.md b/README.md index c7bfa3dd36..eb1469cb6a 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,22 @@ -# React Todo App with API (complete) - -It is the third part of the React Todo App with API. - -Take your code implemented for [Add and Delete](https://github.com/mate-academy/react_todo-app-add-and-delete) -and implement the ability to toggle and rename todos. - -> Here is [the working example](https://mate-academy.github.io/react_todo-app-with-api/) - -## Toggling a todo status - -Toggle the `completed` status on `TodoStatus` change: - -- covered the todo with a loader overlay while wating for API response; -- the status should be changed on success; -- show the `Unable to update a todo` notification in case of API error. - -Add the ability to toggle the completed status of all the todos with the `toggleAll` checkbox: - -- `toggleAll` button should have `active` class only if all the todos are completed; -- `toggleAll` click changes its status to the oppsite one, and sets this new status to all the todos; -- it should work the same as several individual updates of the todos which statuses were actually changed; -- do send requests for the todos that were not changed; - -## Renaming a todo - -Implement the ability to edit a todo title on double click: - -- show the edit form instead of the title and remove button; -- saves changes on the form submit (just press `Enter`); -- save changes when the field loses focus (`onBlur`); -- if new title is the same as the old one just cancel editing; -- cancel editing on `Esс` key `keyup` event; -- if the new title is empty delete the todo the same way the `x` button does it; -- if the title was changed show the loader while waiting for the API response; -- update the todo title on success; -- show `Unable to update a todo` in case of API error; -- or the deletion error message if we tried to delete the todo. - -## Instructions - -- 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. +# React Todo App with API +React Todo App is built on React and uses RapidAPI. It has a intuitive and friendly user interface that helps you manage tasks efficiently. + +# Features +- Intuitive Task Creation: Easily add and organize tasks with a clean and user-friendly interface. +- Real-time Updates: Instantly see changes and updates without refreshing the page. +- Task Completion: Mark tasks as complete and track your progress. +- Task Editing: Modify task details on the fly, adapting to your evolving needs. +- Filtering: Quickly find tasks with filtering options. +- Data Persistence: Your tasks are saved and available when you return to the app. + +# Technologies & Tools Used +- React +- Typescript +- HTML +- SCSS +- JavaScript +- VS Code +- Rapid API + +# Demo +A live demo of the React Products Catalog App is available at [DEMO](https://kbekher.github.io/react_todo-app-with-api/) . diff --git a/src/App.tsx b/src/App.tsx index 5749bdf784..018518cdce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,236 @@ -/* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { + useEffect, useMemo, useRef, useState, +} from 'react'; +import classNames from 'classnames'; + import { UserWarning } from './UserWarning'; +import * as todoService from './api/todos'; +import { TodoList } from './components/TodoList/TodoList'; +import { TodoFilter } from './components/TodoFilter/TodoFilter'; +import { useTodo } from './hooks/useTodo'; +import { ErrorMessage } from './types/ErrorMessage'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +const USER_ID = 11340; export const App: React.FC = () => { + const { + todos, + setTodos, + isChecked, + setIsChecked, + errorMessage, + setErrorMessage, + setIsProcessing, + } = useTodo(); + + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }); + + useEffect(() => { + todoService.getTodos(USER_ID) + .then(setTodos) + .catch(() => setErrorMessage(ErrorMessage.LOAD_ERROR)); + }, []); + + useEffect(() => { + const timer = setTimeout(() => { + setErrorMessage(ErrorMessage.DEFAULT); + }, 3000); + + return () => clearTimeout(timer); + }, [errorMessage]); + + setIsChecked(todos.every(todo => todo.completed) && todos.length > 0); + + const handleTitleChange = (event: React.ChangeEvent) => { + setErrorMessage(ErrorMessage.DEFAULT); + setNewTodoTitle(event.target.value); + }; + + const addNewTodo = (newTitle: string) => { + const newTodoToAdd = { + id: 0, + userId: USER_ID, + title: (newTitle), + completed: false, + }; + + setTempTodo(newTodoToAdd); + + todoService.addTodo(newTodoToAdd) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + }) + .catch((error) => { + setErrorMessage(ErrorMessage.ADD_ERROR); + throw error; + }) + .finally(() => { + setTempTodo(null); + setNewTodoTitle(''); + }); + }; + + const deleteSelectedTodo = (todoId: number): void => { + setIsProcessing(currentIds => [...currentIds, todoId]); + + todoService.deleteTodo(todoId) + .then(() => { + setTodos(curentTodos => curentTodos.filter(item => item.id !== todoId)); + }) + .catch((error) => { + setTodos(todos); + setErrorMessage(ErrorMessage.DELETE_ERROR); + throw error; + }) + .finally(() => setIsProcessing([])); + }; + + const updateSelectedTodo = (updatedTodo: Todo): void => { + setIsProcessing(currentIds => [...currentIds, updatedTodo.id]); + + todoService.updateTodo(updatedTodo) + .then(todo => { + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = newTodos.findIndex(item => item.id === updatedTodo.id); + + newTodos.splice(index, 1, todo); + + return newTodos; + }); + }) + .catch((error) => { + setErrorMessage(ErrorMessage.UPDATE_ERROR); + throw error; + }) + .finally(() => { + setIsProcessing([]); + setIsChecked(!isChecked); + }); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const newTitle = newTodoTitle.trim(); + + if (!newTitle) { + setErrorMessage(ErrorMessage.TITLE_ERROR); + setNewTodoTitle(''); + + return; + } + + addNewTodo(newTitle); + }; + + const handleCheckAllTodos = () => { + todos.forEach(todo => { + const updatedTodo = { + ...todo, + completed: !isChecked, + }; + + updateSelectedTodo(updatedTodo); + }); + }; + + const activeTodosCounter = useMemo(() => { + return todos.filter(todo => !todo.completed).length; + }, [todos]); + + const completedTodosCounter = useMemo(() => { + return todos.filter(todo => todo.completed).length; + }, [todos]); + + const clearCompletedTodos = () => { + todos.forEach(todo => todo.completed && deleteSelectedTodo(todo.id)); + }; + + const handleDeleteErrorMessage = () => { + setErrorMessage(ErrorMessage.DEFAULT); + }; + 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 || tempTodo !== null) && ( + <> + + +
+ + {`${activeTodosCounter} item${activeTodosCounter === 1 ? '' : 's'} left`} + + + + + +
+ + )} +
+ + {errorMessage && ( +
+
+ )} +
); }; diff --git a/src/TodosContext.tsx b/src/TodosContext.tsx new file mode 100644 index 0000000000..1fb99b51a4 --- /dev/null +++ b/src/TodosContext.tsx @@ -0,0 +1,63 @@ +import React, { useMemo, useState } from 'react'; +import { Todo } from './types/Todo'; +import { Status } from './types/Status'; +import { TodosContextType } from './types/TodosContext'; +import { ErrorMessage } from './types/ErrorMessage'; + +export const TodosContext = React.createContext({ + todos: [], + setTodos: () => {}, + visibleTodos: [], + filter: Status.ALL, + setFilter: () => { }, + isChecked: false, + setIsChecked: () => {}, + errorMessage: ErrorMessage.DEFAULT, + setErrorMessage: () => {}, + isProcessing: [], + setIsProcessing: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [isChecked, setIsChecked] = useState(false); + const [filter, setFilter] = useState(Status.ALL); + const [errorMessage, setErrorMessage] = useState(ErrorMessage.DEFAULT); + const [isProcessing, setIsProcessing] = useState([]); + + const visibleTodos = useMemo(() => { + if (filter === Status.ACTIVE) { + return todos.filter(todo => !todo.completed); + } + + if (filter === Status.COMPLETED) { + return todos.filter(todo => todo.completed); + } + + return todos; + }, [filter, todos]); + + const value = { + todos, + setTodos, + visibleTodos, + isChecked, + setIsChecked, + filter, + setFilter, + errorMessage, + setErrorMessage, + isProcessing, + setIsProcessing, + }; + + return ( + + {children} + + ); +}; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..89623cedd7 --- /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 addTodo = ({ userId, title, completed }: Omit) => { + return client.post('/todos', { title, userId, completed }); +}; + +export const updateTodo = ( + { id, title, completed }: Todo, +) => { + return client.patch(`/todos/${id}`, { title, completed }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; diff --git a/src/components/TodoFilter/TodoFilter.tsx b/src/components/TodoFilter/TodoFilter.tsx new file mode 100644 index 0000000000..df45dd4551 --- /dev/null +++ b/src/components/TodoFilter/TodoFilter.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import classNames from 'classnames'; +import { useTodo } from '../../hooks/useTodo'; +import { Status } from '../../types/Status'; + +export const TodoFilter: React.FC = () => { + const { filter, setFilter } = useTodo(); + + const handleClick = ( + event: React.MouseEvent, + ) => { + const target = event.target as HTMLElement; + const newFilter = target.innerText; + + setFilter(newFilter as Status); + }; + + return ( + + ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..c7a27f67dd --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,187 @@ +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../../types/Todo'; +import { useTodo } from '../../hooks/useTodo'; +import { ErrorMessage } from '../../types/ErrorMessage'; +import { deleteTodo, updateTodo } from '../../api/todos'; + +type Props = { + todo: Todo; + loading?: boolean; +}; + +export const TodoItem: React.FC = ({ todo, loading }) => { + const { + todos, + setTodos, + setIsChecked, + setErrorMessage, + isProcessing, + setIsProcessing, + } = useTodo(); + const [title, setTitle] = useState(todo.title); + const [isEditing, setIsEditing] = useState(false); + const [focus, setFocus] = useState(false); + + const inputRef = useRef(null); + + useEffect(() => { + if (focus && inputRef.current) { + inputRef.current.focus(); + } + }, [focus]); + + const updateSelectedTodo = (updatedTodo: Todo): void => { + setIsProcessing(currentIds => [...currentIds, updatedTodo.id]); + + updateTodo(updatedTodo) + .then(newTodo => { + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = newTodos.findIndex(item => item.id === updatedTodo.id); + + newTodos.splice(index, 1, newTodo); + + return newTodos; + }); + }) + .catch((error) => { + setErrorMessage(ErrorMessage.UPDATE_ERROR); + throw error; + }) + .finally(() => { + setIsProcessing([]); + setIsChecked(todos.every(item => item.completed)); + }); + }; + + const checkTodo = (selectedTodo: Todo): void => { + const updatedTodo = { + ...selectedTodo, + completed: !selectedTodo.completed, + }; + + updateSelectedTodo(updatedTodo); + }; + + const deleteSelectedTodo = (todoId: number): void => { + setIsProcessing(currentIds => [...currentIds, todoId]); + + deleteTodo(todoId) + .then(() => { + setTodos(curentTodos => curentTodos.filter(item => item.id !== todoId)); + }) + .catch((error) => { + setTodos(todos); + setErrorMessage(ErrorMessage.DELETE_ERROR); + throw error; + }) + .finally(() => setIsProcessing([])); + }; + + const handleDoubleClick = useCallback(() => { + setIsEditing(true); + setFocus(true); + }, [isEditing]); + + const handleBlur = () => { + const newTitle = title.trim(); + + if (!newTitle) { + deleteSelectedTodo(todo.id); + + return; + } + + if (newTitle === todo.title) { + setTitle(todo.title); + setIsEditing(false); + setFocus(false); + + return; + } + + const updatedTodo = { + ...todo, + title: newTitle, + }; + + updateSelectedTodo(updatedTodo); + + setTitle(newTitle); + setIsEditing(false); + setFocus(false); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setTitle(todo.title); + setIsEditing(false); + setFocus(false); + } + + if (event.key === 'Enter') { + handleBlur(); + } + }; + + return ( +
+ + + {!isEditing ? ( + <> + + {todo.title} + + + + ) : ( +
+ setTitle(event.target.value)} + value={title} + onBlur={handleBlur} + onKeyUp={handleKeyUp} + /> +
+ )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..61c90bda3a --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { useTodo } from '../../hooks/useTodo'; +import { Todo } from '../../types/Todo'; +import './animations.scss'; + +type Props = { + tempTodo: Todo | null; +}; + +export const TodoList: React.FC = ({ tempTodo }) => { + const { visibleTodos } = useTodo(); + + return ( +
+ + {visibleTodos.map(todo => ( + + + + ))} + + {tempTodo && ( + + + + )} + +
+ ); +}; diff --git a/src/components/TodoList/animations.scss b/src/components/TodoList/animations.scss new file mode 100644 index 0000000000..04f5318372 --- /dev/null +++ b/src/components/TodoList/animations.scss @@ -0,0 +1,46 @@ +.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/hooks/useTodo.ts b/src/hooks/useTodo.ts new file mode 100644 index 0000000000..9e441735cc --- /dev/null +++ b/src/hooks/useTodo.ts @@ -0,0 +1,5 @@ +import React from 'react'; +import { TodosContext } from '../TodosContext'; +import { TodosContextType } from '../types/TodosContext'; + +export const useTodo = (): TodosContextType => React.useContext(TodosContext); diff --git a/src/index.tsx b/src/index.tsx index 7de19e0c70..3da113de25 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 './TodosContext'; createRoot(document.getElementById('root') as HTMLDivElement) - .render(); + .render( + + + , + ); diff --git a/src/styles/todo.scss b/src/styles/todo.scss index fc99714ef2..0699047769 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,15 +73,16 @@ &__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); + outline: none; &::placeholder { font-style: italic; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index 836166156b..d1d4a75f5a 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -70,6 +70,7 @@ border: none; background: rgba(0, 0, 0, 0.01); box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); + outline: none; &::placeholder { font-style: italic; @@ -105,6 +106,7 @@ } &__clear-completed { + width: 92px; margin: 0; padding: 0; border: 0; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..945af08377 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,8 @@ +export enum ErrorMessage { + DEFAULT = '', + LOAD_ERROR = 'Unable to load todos', + TITLE_ERROR = 'Title cannot be empty', + ADD_ERROR = 'Unable to add a todo', + DELETE_ERROR = 'Unable to delete a todo', + UPDATE_ERROR = 'Unable to update a todo', +} diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 0000000000..6315e8e9ec --- /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 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/TodosContext.ts b/src/types/TodosContext.ts new file mode 100644 index 0000000000..ac07136eef --- /dev/null +++ b/src/types/TodosContext.ts @@ -0,0 +1,17 @@ +import { ErrorMessage } from './ErrorMessage'; +import { Status } from './Status'; +import { Todo } from './Todo'; + +export type TodosContextType = { + todos: Todo[]; + setTodos: (value: Todo[] | { (prev: Todo[]) : Todo[] }) => void; + visibleTodos: Todo[]; + isChecked: boolean; + setIsChecked: (value: boolean) => void; + filter: Status; + setFilter: (value: Status) => void; + errorMessage: string; + setErrorMessage: (value: ErrorMessage) => void; + isProcessing: number[]; + setIsProcessing: (value: [] | { (prev: number[]) : number[] }) => void; +}; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..42421feae0 --- /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(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'), +};