diff --git a/README.md b/README.md index 47a1add059..b23d735086 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ 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) +Take your code implemented for [Add and Delete](https://andriy-stetsula.github.io/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/) +> Here is [the working example](https://andriy-stetsula.github.io/react_todo-app-with-api/) ## Toggling a todo status Toggle the `completed` status on `TodoStatus` change: + - Install Prettier Extention and use this [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. - covered the todo with a loader overlay while waiting for API response; - the status should be changed on success; @@ -38,6 +39,7 @@ Implement the ability to edit a todo title on double click: - or the deletion error message if we tried to delete the todo. ## If you want to enable tests + - open `cypress/integration/page.spec.js` - replace `describe.skip` with `describe` for the root `describe` diff --git a/package-lock.json b/package-lock.json index 19701e8788..0dbc74a8e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1183,10 +1183,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index b6062525ab..8f7671a2e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react_todo-app-with-api", - "homepage": "react_todo-app-with-api", + "homepage": "https://andriy-stetsula.github.io/react_todo-app-with-api/", "version": "0.1.0", "keywords": [], "author": "Mate Academy", @@ -15,7 +15,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..d313ff552c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,375 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useRef } from 'react'; import { UserWarning } from './UserWarning'; +import { USER_ID } from './api/todos'; +import { Todo } from './types/Todo'; +import { getTodos, addTodo, deleteTodo, updateTodo } from './api/todos'; +import { useEffect, useState } from 'react'; +import { getFilterTodos } from './helpers'; +import cn from 'classnames'; -const USER_ID = 0; +enum FILTERS { + all = 'all', + completed = 'completed', + active = 'active', +} + +enum ERROR { + LOAD__ERROR = 'Unable to add a todo', + LOAD__DELETE = 'Unable to delete a todo', + LOAD__UPDATE = 'Unable to update a todo', + LOAD__TITLE = 'Title should not be empty', +} export const App: React.FC = () => { + const [todo, setTodo] = useState([]); + const [error, setError] = useState(''); + const [selected, setSelected] = useState('all'); + const [title, setTitle] = useState(''); + const [loading, setLoading] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const [loadingIds, setLoadingIds] = useState([]); + const [edit, setEdit] = useState(null); + const [editTitle, setEditTitle] = useState(''); + + const LOAD_ERROR = { + LOAD_TODOS: 'Unable to load todos', + }; + + const autoFocus = useRef(null); + + const handleUpdate = (currentTodo: Todo) => { + setLoadingIds(prev => [...prev, currentTodo.id]); + updateTodo({ ...currentTodo, completed: !currentTodo.completed }) + .then(() => + setTodo(prev => + prev.map(todos => + todos.id === currentTodo.id + ? { ...todos, completed: !todos.completed } + : todos, + ), + ), + ) + .catch(() => setError(ERROR.LOAD__UPDATE)) + .finally(() => { + setLoadingIds(prev => prev.filter(ids => ids !== currentTodo.id)); + autoFocus.current?.focus(); + }); + }; + + const handleDelete = (id: number) => { + setLoadingIds(prev => [...prev, id]); + deleteTodo(id) + .then(() => setTodo(prev => prev.filter(todos => todos.id !== id))) + .catch(() => setError(ERROR.LOAD__DELETE)) + .finally(() => { + setLoadingIds(prev => prev.filter(ids => ids !== id)); + autoFocus.current?.focus(); + }); + }; + + const clearCompleted = () => { + todo + .filter(todos => todos.completed) + .forEach(todos => handleDelete(todos.id)); + }; + + const handleUpdateTodo = () => { + const completeUpdateAll = todo.every(todos => todos.completed); + const updateAllTodo = todo.filter( + todos => todos.completed === completeUpdateAll, + ); + + updateAllTodo.forEach(todos => handleUpdate(todos)); + }; + + const handleRename = (currentTodo: Todo) => { + if (editTitle.trim() === currentTodo.title) { + setEdit(null); + + return; + } + + if (!editTitle.trim()) { + handleDelete(currentTodo.id); + + return; + } + + setLoadingIds(prev => [...prev, currentTodo.id]); + updateTodo({ ...currentTodo, title: editTitle.trim() }) + .then(() => { + setTodo(prev => + prev.map(todos => + todos.id === currentTodo.id + ? { ...todos, title: editTitle.trim() } + : todos, + ), + ); + setEdit(null); + }) + .catch(() => setError(ERROR.LOAD__UPDATE)) + .finally(() => + setLoadingIds(prev => prev.filter(ids => ids !== currentTodo.id)), + ); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!title.trim()) { + setError(ERROR.LOAD__TITLE); + + return; + } + + setLoading(true); + setTempTodo({ + id: 0, + userId: USER_ID, + completed: false, + title: title.trim(), + }); + + addTodo({ + userId: USER_ID, + completed: false, + title: title.trim(), + }) + .then(newTodo => { + setTodo(prev => [...prev, newTodo]); + setTitle(''); + }) + + .catch(() => setError(ERROR.LOAD__ERROR)) + .finally(() => { + setLoading(false); + setTempTodo(null); + setTimeout(() => { + autoFocus.current?.focus(); + }, 0); + }); + }; + + useEffect(() => { + autoFocus.current?.focus(); + }, []); + + useEffect(() => { + getTodos() + .then(data => setTodo(data)) + .catch(() => setError(LOAD_ERROR.LOAD_TODOS)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (error) { + setTimeout(() => { + setError(''); + }, 3000); + } + }, [error]); + + const filterTodos = getFilterTodos(todo, selected); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {todo.length > 0 && ( +
+ {todo.length > 0 && ( +
+ {filterTodos.map((todos: Todo) => ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control, jsx-a11y/label-has-for */} + + {edit === todos.id ? ( + setEditTitle(event.target.value)} + onKeyUp={event => { + if (event.key === 'Escape') { + setEdit(null); + } + + if (event.key === 'Enter') { + handleRename(todos); + } + }} + onBlur={() => handleRename(todos)} + data-cy="TodoTitleField" + type="text" + className="todo__title-field" + autoFocus + /> + ) : ( + { + setEdit(todos.id); + setEditTitle(todos.title); + }} + > + {todos.title} + + )} + {edit !== todos.id && ( + + )} + + {/* overlay will cover the todo while it is being deleted or updated */} +
+
+
+
+
+ ))} +
+ )} + + {tempTodo && ( +
+ + + + {tempTodo.title} + +
+
+
+
+
+ )} + + {todo.length > 0 && ( + + )} +
+ + {/* DON'T use conditional rendering to hide the notification */} + {/* Add the 'hidden' class to hide the message smoothly */} +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..1a2bb96765 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,20 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4248; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (data: Omit) => { + return client.post('/todos', data); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (todo: Todo) => { + return client.patch(`/todos/${todo.id}`, todo); +}; diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000000..aed6b8381a --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,19 @@ +enum FILTERS { + all = 'all', + completed = 'completed', + active = 'active', +} + +export function getFilterTodos(todo, selected) { + return todo.filter(todos => { + if (selected === FILTERS.active) { + return !todos.completed; + } + + if (selected === FILTERS.completed) { + return todos.completed; + } + + return true; + }); +} 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/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..708ac4c17b --- /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', + }; + } + + // DON'T change the delay it is required for tests + 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/vite.config.ts b/vite.config.ts index 5a33944a9b..3083c62c0d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,5 +3,6 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ + base: "/react_todo-app-with-api/", plugins: [react()], })