diff --git a/README.md b/README.md index af7dae81f6..b01867b1f9 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,11 @@ -# React Todo App with API (complete) +# React Todo App with API +[DEMO LINK](https://polinavafik.github.io/react_todo-app-with-api/) -It is the third part of the React Todo App with API. +Todo App is a simple tool made with React for managing your to-do list. It's built with reusable components, making it highly modular and maintainable. It uses useContext for state management and adds a touch of elegance with smooth animations via TransitionGroup -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. +Key Features: +✧ API Integration: Load, update, delete, and add todos seamlessly from an external API, ensuring your task list is always up-to-date. +✧ Effortless Task Management: Easily mark tasks as completed or uncompleted, perform bulk actions (e.g., mark all completed tasks), and edit tasks inline with a double click. +✧ Smooth Animations: Enjoy a visually pleasing experience with smooth transitions for adding, updating, and deleting tasks. +✧ User-Friendly Interface: Navigate the app effortlessly with its intuitive and clean design. -> 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 waiting 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 opposite 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 the 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. diff --git a/package.json b/package.json index 58d72f957f..d5a8df5fd5 100755 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@cypress/webpack-dev-server": "^1.8.4", "@mate-academy/cypress-tools": "^1.0.5", "@mate-academy/eslint-config-react-typescript": "^1.0.11", - "@mate-academy/scripts": "^1.2.8", + "@mate-academy/scripts": "^1.2.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^17.0.45", diff --git a/public/icons/favicon.svg b/public/icons/favicon.svg new file mode 100644 index 0000000000..00b5eb969c --- /dev/null +++ b/public/icons/favicon.svg @@ -0,0 +1,19 @@ + + + + + done [#1477] + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/index.html b/public/index.html index 18a1037610..fdaa9e8cba 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,8 @@ - ToDo App + + Todo App
diff --git a/src/App.tsx b/src/App.tsx index 5749bdf784..031b44632a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,97 @@ -/* 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, + useMemo, + useRef, +} from 'react'; +import classNames from 'classnames'; +import './styles/App.scss'; +import { TodoFilter } from './types/TodoFilter'; +import { TodoList } from './components/TodoList'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoFooter } from './components/TodoFooter'; +import { getFilteredTodos } from './utils/getFilteredTodos'; +import { CurrentError } from './types/CurrentError'; +import { useTodo } from './Context/TodoContext'; +import { USER_ID } from './utils/constants'; +import * as todoService from './api/todos'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todoFilter, setTodoFilter] = useState(TodoFilter.All); + + const { + todos, + setTodos, + error, + setError, + } = useTodo(); + + useEffect(() => { + todoService.getTodos(USER_ID) + .then(setTodos) + .catch(() => { + setError(CurrentError.LoadingError); + }); + }, []); + + const timerId = useRef(0); + + useEffect(() => { + if (timerId.current) { + window.clearTimeout(timerId.current); + } + + timerId.current = window.setTimeout(() => { + setError(CurrentError.Default); + }, 3000); + }, [error]); + + const filteredTodos = useMemo(() => { + return getFilteredTodos(todos, todoFilter); + }, [todos, todoFilter]); + + const handleSetTodoFilter = (filter: TodoFilter) => ( + setTodoFilter(filter) + ); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ + + + + {!!todos.length && ( + + )} +
+ +
+
+
); }; diff --git a/src/Context/TodoContext.tsx b/src/Context/TodoContext.tsx new file mode 100644 index 0000000000..2b1e34f8ed --- /dev/null +++ b/src/Context/TodoContext.tsx @@ -0,0 +1,207 @@ +import React, { + createContext, + useContext, + useMemo, + useState, +} from 'react'; +import { CurrentError } from '../types/CurrentError'; +import * as todoService from '../api/todos'; +import { Todo } from '../types/Todo'; +import { getCompletedTodos } from '../utils/getCompletedTodos'; +import { getActiveTodos } from '../utils/getActiveTodos'; + +type Props = { + children: React.ReactNode +}; + +interface TodoContextInterface { + todos: Todo[], + setTodos: React.Dispatch>, + tempTodo: Todo | null; + setTempTodo: React.Dispatch>; + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; + error: CurrentError, + setError: (error: CurrentError) => void; + handleToggleChange: (todo: Todo) => void; + handleTodoDelete: (id: number) => void; + handleTodoAdd: (newTodo: Omit) => Promise + handleTodoRename: (todo: Todo, newTodoTitle: string) => Promise | void, + completedTodos: Todo[]; + activeTodos: Todo[]; + handleClearCompleted: () => void; + processingTodoIds: number[]; + setProcessingTodoIds: (todoIdsToDelete: number[]) => void; +} + +const initalContext: TodoContextInterface = { + todos: [], + setTodos: () => {}, + tempTodo: null, + setTempTodo: () => {}, + isLoading: false, + setIsLoading: () => {}, + error: CurrentError.Default, + setError: () => {}, + handleToggleChange: () => {}, + handleTodoDelete: () => {}, + handleTodoAdd: async () => {}, + handleTodoRename: async () => {}, + completedTodos: [], + activeTodos: [], + handleClearCompleted: () => {}, + processingTodoIds: [], + setProcessingTodoIds: () => {}, +}; + +export const TodoContext = createContext(initalContext); + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [error, setError] = useState(CurrentError.Default); + const [isLoading, setIsLoading] = useState(false); + const [processingTodoIds, setProcessingTodoIds] = useState([]); + + const completedTodos = getCompletedTodos(todos); + const activeTodos = getActiveTodos(todos); + + const handleTodoDelete = (todoId: number) => { + setIsLoading(true); + setProcessingTodoIds(prevState => [...prevState, todoId]); + + todoService.deleteTodo(todoId) + .then(() => { + setTodos(prevTodos => { + return prevTodos.filter(todo => todo.id !== todoId); + }); + }) + .catch(() => { + setError(CurrentError.DeleteError); + }) + .finally(() => { + setProcessingTodoIds( + (prevState) => prevState.filter(id => id !== todoId), + ); + setIsLoading(false); + }); + }; + + const handleTodoAdd = (newTodo: Omit) => { + setIsLoading(true); + + return todoService.addTodo(newTodo) + .then(createdTodo => { + setTodos((prevTodos) => [...prevTodos, createdTodo]); + }) + .catch(() => { + setError(CurrentError.AddError); + throw new Error(); + }) + .finally(() => { + setIsLoading(false); + setTempTodo(null); + }); + }; + + const handleTodoRename = (todo: Todo, newTodoTitle: string) => { + if (todo.title === newTodoTitle) { + return; + } + + if (!newTodoTitle.trim()) { + setError(CurrentError.EmptyTitleError); + + return; + } + + setIsLoading(true); + setProcessingTodoIds(prevState => [...prevState, todo.id]); + + // eslint-disable-next-line consistent-return + return todoService + .updateTodo({ + ...todo, + title: newTodoTitle, + }) + .then(updatedTodo => { + setTodos(prevState => prevState.map(currTodo => { + return currTodo.id !== updatedTodo.id + ? currTodo + : updatedTodo; + })); + }) + .catch(() => { + setError(CurrentError.UpdateError); + throw new Error(CurrentError.UpdateError); + }) + .finally(() => { + setProcessingTodoIds( + (prevState) => prevState.filter(id => id !== todo.id), + ); + setIsLoading(false); + }); + }; + + const handleToggleChange = (todo: Todo) => { + setIsLoading(true); + setProcessingTodoIds(prevState => [...prevState, todo.id]); + + todoService.updateTodo({ + ...todo, + completed: !todo.completed, + }) + .then((updatedTodo) => { + setTodos(prevState => prevState + .map(currTodo => ( + currTodo.id === updatedTodo.id + ? updatedTodo + : currTodo + ))); + }) + .catch(() => { + setError(CurrentError.UpdateError); + throw new Error(CurrentError.UpdateError); + }) + .finally(() => { + setProcessingTodoIds( + (prevState) => prevState.filter(id => id !== todo.id), + ); + setIsLoading(false); + }); + }; + + const handleClearCompleted = () => { + completedTodos.forEach(({ id }) => handleTodoDelete(id)); + }; + + const value = useMemo(() => ({ + todos, + setTodos, + tempTodo, + setTempTodo, + isLoading, + setIsLoading, + error, + setError, + handleToggleChange, + handleTodoDelete, + handleTodoAdd, + handleTodoRename, + completedTodos, + activeTodos, + handleClearCompleted, + processingTodoIds, + setProcessingTodoIds, + }), [todos, error, isLoading, tempTodo]); + + return ( + + {children} + + ); +}; + +export const useTodo = () => useContext(TodoContext); diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..305310eb02 --- /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 deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const addTodo = (todo: Omit) => { + return client.post('/todos', todo); +}; + +export const updateTodo = (todoToUpdate: Todo): Promise => { + return client.patch(`/todos/${todoToUpdate.id}`, todoToUpdate); +}; diff --git a/src/components/TodoElement.tsx b/src/components/TodoElement.tsx new file mode 100644 index 0000000000..2e09312e19 --- /dev/null +++ b/src/components/TodoElement.tsx @@ -0,0 +1,151 @@ +import React, { + useEffect, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useTodo } from '../Context/TodoContext'; + +type Props = { + todo: Todo, +}; + +export const TodoElement: React.FC = ({ todo }) => { + const { completed, title, id } = todo; + const { + handleTodoDelete, + processingTodoIds, + handleTodoRename, + handleToggleChange, + } = useTodo(); + + const [isEditing, setIsEditing] = useState(false); + const [todoTitle, setTodoTitle] = useState(title); + const shouldDisplayLoader = id === 0 || processingTodoIds.includes(id); + const titleInputRef = useRef(null); + + const handleTodoDoubleClick = () => { + setIsEditing(true); + }; + + const handleTodoChange = async ( + event: React.FormEvent, + ) => { + event.preventDefault(); + + try { + if (todoTitle) { + await handleTodoRename(todo, todoTitle); + } else { + await handleTodoDelete(id); + } + + setIsEditing(false); + } catch (error) { + titleInputRef.current?.focus(); + } + }; + + const handleTodoTitleChange = ( + event: React.ChangeEvent, + ) => { + setTodoTitle(event.target.value); + }; + + const handleOnKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setTodoTitle(title); + } + }; + + useEffect(() => { + if (isEditing && titleInputRef.current) { + titleInputRef.current.focus(); + } + }, [isEditing]); + + return ( +
+ + + {isEditing + ? ( +
+ { + handleOnKeyUp(event); + }} + /> +
+ ) + : ( + <> + { + setIsEditing(false); + }} + > + {title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 0000000000..f0d8bbef50 --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import classNames from 'classnames'; +import { TodoFilter } from '../types/TodoFilter'; +import { useTodo } from '../Context/TodoContext'; + +type Props = { + filter: TodoFilter; + setFilter: (newFilter: TodoFilter) => void; +}; + +export const TodoFooter: React.FC = ({ + filter, + setFilter, +}) => { + const { + activeTodos, + completedTodos, + handleClearCompleted, + } = useTodo(); + + const activeTodosCount = activeTodos.length; + const completedTodosCount = completedTodos.length; + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 0000000000..599468cad9 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,107 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import { + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { CurrentError } from '../types/CurrentError'; +import { useTodo } from '../Context/TodoContext'; +import { USER_ID } from '../utils/constants'; + +type Props = {}; + +export const TodoHeader: React.FC = () => { + const { + todos, + setError, + handleTodoAdd, + setTempTodo, + activeTodos, + completedTodos, + isLoading, + handleToggleChange, + } = useTodo(); + const [title, setTitle] = useState(''); + + const activeTodosCount = activeTodos.length; + const inputField = useRef(null); + + const isAllCompleted = useMemo(() => ( + todos.every(todo => todo.completed) + ), [todos]); + + useEffect(() => { + if (!isLoading) { + inputField.current?.focus(); + } + }, [isLoading]); + + const handleToggleAll = () => { + if (activeTodosCount) { + activeTodos.forEach(todo => handleToggleChange(todo)); + } else { + completedTodos.forEach(todo => handleToggleChange(todo)); + } + }; + + const handleTitleChange = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + setError(CurrentError.EmptyTitleError); + + return; + } + + const newTodo = { + userId: USER_ID, + title: title.trim(), + completed: false, + }; + + setTempTodo({ id: 0, ...newTodo }); + handleTodoAdd(newTodo) + .then(() => { + setTitle(''); + }) + .catch(() => { + setError(CurrentError.AddError); + throw new Error(); + }); + }; + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..f0b516282f --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { Todo } from '../types/Todo'; +import { TodoElement } from './TodoElement'; +import { useTodo } from '../Context/TodoContext'; + +type Props = { + todos: Todo[], +}; + +export const TodoList: React.FC = ({ todos }) => { + const { tempTodo } = useTodo(); + + 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/App.scss b/src/styles/App.scss new file mode 100644 index 0000000000..04f5318372 --- /dev/null +++ b/src/styles/App.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/styles/index.scss b/src/styles/index.scss index bccd80c8bc..05df71edae 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -23,3 +23,4 @@ body { @import "./todoapp"; @import "./todo"; @import "./filter"; +@import "./App.scss" diff --git a/src/types/CurrentError.tsx b/src/types/CurrentError.tsx new file mode 100644 index 0000000000..6c3c62f07e --- /dev/null +++ b/src/types/CurrentError.tsx @@ -0,0 +1,8 @@ +export enum CurrentError { + Default = '', + LoadingError = 'Unable to load todos', + EmptyTitleError = 'Title should not be empty', + AddError = 'Unable to add a todo', + DeleteError = 'Unable to delete a todo', + UpdateError = 'Unable to update a todo', +} 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/TodoFilter.ts b/src/types/TodoFilter.ts new file mode 100644 index 0000000000..6b7e03b793 --- /dev/null +++ b/src/types/TodoFilter.ts @@ -0,0 +1,5 @@ +export enum TodoFilter { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000000..b9d96964ef --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1 @@ +export const USER_ID = 11522; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..d3e2fcc51a --- /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(`${response.status} ${response.statusText}`); + } + + 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/getActiveTodos.ts b/src/utils/getActiveTodos.ts new file mode 100644 index 0000000000..9afc5b93e6 --- /dev/null +++ b/src/utils/getActiveTodos.ts @@ -0,0 +1,5 @@ +import { Todo } from '../types/Todo'; + +export const getActiveTodos = ( + todos: Todo[], +) => todos.filter(({ completed }) => !completed); diff --git a/src/utils/getCompletedTodos.ts b/src/utils/getCompletedTodos.ts new file mode 100644 index 0000000000..818b8a8279 --- /dev/null +++ b/src/utils/getCompletedTodos.ts @@ -0,0 +1,5 @@ +import { Todo } from '../types/Todo'; + +export const getCompletedTodos = ( + todos: Todo[], +) => todos.filter(({ completed }) => completed); diff --git a/src/utils/getFilteredTodos.tsx b/src/utils/getFilteredTodos.tsx new file mode 100644 index 0000000000..efd257f526 --- /dev/null +++ b/src/utils/getFilteredTodos.tsx @@ -0,0 +1,24 @@ +import { Todo } from '../types/Todo'; +import { TodoFilter } from '../types/TodoFilter'; + +export const getFilteredTodos = ( + todos: Todo[], + selectedFilter: string, +) => { + if (selectedFilter !== TodoFilter.All) { + return todos.filter(({ completed }) => { + switch (selectedFilter) { + case TodoFilter.Active: + return !completed; + + case TodoFilter.Completed: + return completed; + + default: + throw new Error('Something went wrong :('); + } + }); + } + + return todos; +};