diff --git a/README.md b/README.md index af7dae81f6..802dd94d57 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,12 @@ -# React Todo App with API (complete) +# React Todo App -It is the third part of the React Todo App with API. +Technologies used: +- HTML +- CSS +- TypeScript +- React -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. +[DEMO](https://annaviolin23.github.io/react_todo-app-with-api/) -> 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. +How to use: +[VIDEO](https://drive.google.com/file/d/1nH5oAgHemgwnzW3182qW7n5eMOEOigzH/view?usp=sharing) diff --git a/public/icons/favicon.svg b/public/icons/favicon.svg new file mode 100644 index 0000000000..afccdd6a51 --- /dev/null +++ b/public/icons/favicon.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/index.html b/public/index.html index 18a1037610..529bd1eae1 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,7 @@ ToDo App +
diff --git a/src/App.tsx b/src/App.tsx index 5749bdf784..c525f0ea13 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,262 @@ -/* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { + FormEvent, useEffect, useMemo, useState, +} from 'react'; +import classNames from 'classnames'; +import { Todo } from './types/Todo'; +import { FilterType } from './types/FilterTypes'; +import { Error } from './utils/errorUtils'; +import { getTodos } from './api/todos'; +import * as postService from './api/todos'; import { UserWarning } from './UserWarning'; +import { TodoList } from './components/TodoList'; +import { TodoFilter } from './components/FilterTodo'; +import { TodoError } from './components/TodoError'; +import { TodoItem } from './components/TodoItem'; -const USER_ID = 0; +const USER_ID = 11707; + +const getVisibleTodos = (todos: Todo[], status: FilterType) => { + return todos.filter(todo => { + switch (status) { + case FilterType.Completed: + return todo.completed; + + case FilterType.Active: + return !todo.completed; + + default: + return true; + } + }); +}; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [status, setStatus] = useState(FilterType.All); + const [errorMessage, setErrorMessage] = useState(Error.None); + const [query, setQuery] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [loadingIds, setLoadingIds] = useState([]); + + useEffect(() => { + getTodos(USER_ID) + .then(setTodos) + .catch(() => setErrorMessage(Error.Load)); + }, []); + + const visibleTodos = useMemo(() => getVisibleTodos(todos, status), + [todos, status]); + + const todosCount = useMemo(() => todos.filter(todo => !todo.completed).length, + [todos]); + + const isCompletedTodos = useMemo(() => todos.some(todo => todo.completed), + [todos]); + + const completedTodoCount = todos.filter(todo => todo.completed).length; + + const handleQuery = (event: React.ChangeEvent) => { + setQuery(event.target.value); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (query.trim() === '') { + setErrorMessage(Error.EmptyTitle); + + return; + } + + const newTodo = { + title: query, + completed: false, + userId: USER_ID, + }; + + setTempTodo({ ...newTodo, id: 0 }); + setLoadingIds((ids) => [...ids, 0]); + + postService.createTodo(newTodo) + .then((value) => { + setTodos(currentTodos => [...currentTodos, value]); + setTempTodo(null); + }) + .catch((error) => { + setErrorMessage(Error.Add); + throw error; + }) + .finally(() => { + setLoadingIds((ids) => ids.filter(id => id !== 0)); + }); + + setQuery(''); + }; + + const onDeleteTodo = (todoId: number) => { + setLoadingIds((ids) => [...ids, todoId]); + postService.deleteTodo(todoId) + .then(() => setTodos( + currentTodos => currentTodos.filter( + todo => todo.id !== todoId, + ), + )) + .catch(() => setErrorMessage(Error.Delete)) + .finally(() => setLoadingIds((ids) => ids.filter(id => id !== todoId))); + }; + + const onDeleteCompleted = () => { + todos.filter(todo => todo.completed).forEach((todo) => { + onDeleteTodo(todo.id); + }); + }; + if (!USER_ID) { return ; } + const handleToggleAll = () => { + const uncompletedTodos = todos.filter(todo => !todo.completed); + const uncompletedTodosIds = uncompletedTodos.map(todo => todo.id); + + if (uncompletedTodos.length === 0) { + setLoadingIds(currentIds => [ + ...currentIds, + ...todos.map( + todo => todo.id, + )]); + + Promise.all(todos.map(todo => { + return postService.updateTodo(todo.id, { + completed: !todo.completed, + }); + })) + .then(() => { + getTodos(USER_ID) + .then((value) => { + setTodos(value); + setLoadingIds([]); + }) + .catch(() => { + setErrorMessage(Error.Load); + }); + }) + .catch(() => { + setErrorMessage(Error.Update); + }); + + return; + } + + setLoadingIds(currentIds => [...currentIds, ...uncompletedTodosIds]); + + Promise.all(uncompletedTodos.map(todo => { + return postService.updateTodo(todo.id, { + completed: !todo.completed, + }); + })) + .then(() => { + getTodos(USER_ID) + .then((value) => { + setTodos(value); + }) + .catch(() => { + setErrorMessage(Error.Load); + }) + + .finally(() => { + setLoadingIds([]); + }); + }) + .catch(() => { + setErrorMessage(Error.Update); + }); + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {todos.length !== 0 && ( +
+ + {!!todos.length && ( + <> + + + {tempTodo && ( + + )} + +
+ + {todosCount === 1 ? ('1 item left') + : (`${todosCount} items left`)} + + + + + +
+ + )} +
+ + { + errorMessage && ( + + ) + } +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..57001d8928 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,21 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const createTodo = ({ title, completed, userId }: Omit ) => { + return client.post('/todos', { title, completed, userId }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = ( + todoId: number, + data: { completed?: boolean, title?: string }, +) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/components/FilterTodo.tsx b/src/components/FilterTodo.tsx new file mode 100644 index 0000000000..c375189fbf --- /dev/null +++ b/src/components/FilterTodo.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames'; +import { FilterType } from '../types/FilterTypes'; + +type Props = { + status: FilterType, + onStatusChange: (filter: FilterType) => void, +}; + +export const TodoFilter: React.FC = ({ status, onStatusChange }) => { + return ( + + ); +}; diff --git a/src/components/TodoError.tsx b/src/components/TodoError.tsx new file mode 100644 index 0000000000..2d4fe82820 --- /dev/null +++ b/src/components/TodoError.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import classNames from 'classnames'; +import { Error } from '../utils/errorUtils'; + +type Props = { + error: Error, + onErrorChange: (error: Error) => void, +}; + +export const TodoError: React.FC = ({ error, onErrorChange }) => { + useEffect(() => { + let timeoutId: NodeJS.Timeout; + + if (error) { + timeoutId = setTimeout(() => { + onErrorChange(Error.None); + }, 3000); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [error, onErrorChange]); + + return ( +
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..395c552036 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,188 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import className from 'classnames'; +import { FormEvent, useState } from 'react'; +import { Todo } from '../types/Todo'; +import * as postService from '../api/todos'; +import { Error, getErrorMessage } from '../utils/errorUtils'; + +type Props = { + todo: Todo, + onDeleteTodo: (todoId: number) => void, + loadingIds: number[], + setTodos: (value: Todo[]) => void, + setLoadingIds: React.Dispatch>, + todos: Todo[] +}; + +const USER_ID = 11707; + +export const TodoItem: React.FC = ({ + todo, + onDeleteTodo, + loadingIds, + setTodos, + setLoadingIds, + todos, +}) => { + const [doubleClicked, setDoubleClicked] = useState(false); + const [editingTitle, setEditingTitle] = useState(todo.title); + const { id, title, completed } = todo; + + const handleCheckedTodo = (todoId: number) => { + setLoadingIds(currentIds => [...currentIds, todoId]); + + postService.updateTodo(todoId, { completed: !completed }) + .then(() => { + postService.getTodos(USER_ID) + .then((todos) => { + setTodos(todos); + }) + .catch(() => { + getErrorMessage(Error.Load); + }) + .finally(() => { + setLoadingIds(currentIds => currentIds.filter(id => id !== todoId)); + }); + }) + .catch(() => { + getErrorMessage(Error.Update); + }); + }; + + const handleDoubleClick = ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + setDoubleClicked(true); + }; + + const handleClickedFormSubmit = ( + event: FormEvent, + todoId: number, + ) => { + event.preventDefault(); + const currentTodo = todos.find(todo => todo.id === todoId); + + if (currentTodo?.title.trim() === '') { + setDoubleClicked(false); + setLoadingIds(currentIds => [...currentIds, todoId]); + + postService.deleteTodo(todoId) + .then(() => { + postService.getTodos(USER_ID) + .then((todos) => { + setTodos(todos); + }) + .catch(() => { + getErrorMessage(Error.Load); + setLoadingIds(currentIds => currentIds.filter( + id => id !== todoId, + )); + }); + }) + .catch(() => { + getErrorMessage(Error.Delete); + setLoadingIds(currentIds => currentIds.filter(id => id !== todoId)); + }); + + return; + } + + if (currentTodo?.title !== title) { + setLoadingIds(currentIds => [...currentIds, todoId]); + + postService.updateTodo(todoId, { title: editingTitle }) + .then(() => { + postService.getTodos(USER_ID) + .then((todos) => { + setTodos(todos); + setDoubleClicked(false); + setLoadingIds(currentIds => currentIds.filter( + id => id !== todoId, + )); + }) + .catch(() => { + getErrorMessage(Error.Load); + setLoadingIds(currentIds => currentIds.filter( + id => id !== todoId, + )); + }); + }) + .catch(() => { + getErrorMessage(Error.Update); + setDoubleClicked(false); + setLoadingIds(currentIds => currentIds.filter( + id => id !== todoId, + )); + }); + + return; + } + + setDoubleClicked(false); + }; + + const handleKeyUp = (key: string) => { + if (key === 'Escape') { + setDoubleClicked(false); + setEditingTitle(title); + } + }; + + return ( + <> +
+ + + {doubleClicked ? ( +
handleClickedFormSubmit(event, id)} + onBlur={(event) => handleClickedFormSubmit(event, id)} + > + setEditingTitle(event.target.value)} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={doubleClicked} + onKeyUp={(event) => handleKeyUp(event.key)} + /> +
+ ) : ( + <> + + {title} + + + + )} + +
+
+
+
+
+ + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..7e908302d0 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,34 @@ +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[], + onDeleteTodo: (todoId: number) => void, + loadingIds: number[], + setTodos: (value: Todo[]) => void, + setLoadingIds: React.Dispatch>, +}; + +export const TodoList: React.FC = ({ + todos, + onDeleteTodo, + loadingIds, + setTodos, + setLoadingIds, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index 836166156b..6b17309991 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/FilterTypes.ts b/src/types/FilterTypes.ts new file mode 100644 index 0000000000..579c7f50ce --- /dev/null +++ b/src/types/FilterTypes.ts @@ -0,0 +1,5 @@ +export enum FilterType { + 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/errorUtils.ts b/src/utils/errorUtils.ts new file mode 100644 index 0000000000..5a91edd0f3 --- /dev/null +++ b/src/utils/errorUtils.ts @@ -0,0 +1,25 @@ +export enum Error { + None = '', + Load = 'Unable to load todos', + Add = 'Unable to add todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', + EmptyTitle = 'Title can\'t be empty', +} + +export const getErrorMessage = (errorType: Error) => { + switch (errorType) { + case Error.Load: + return 'Unable to load todos'; + case Error.Add: + return 'Unable to add todo'; + case Error.Delete: + return 'Unable to delete a todo'; + case Error.Update: + return 'Unable to update a todo'; + case Error.EmptyTitle: + return 'Title can\'t be empty'; + default: + return 'An error occurred'; + } +}; 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'), +};