diff --git a/README.md b/README.md index 47a1add059..5df2d33fad 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,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://Irina0401.github.io/react_todo-app-with-api/) and add it to the PR description. 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..6d0f20adcc 100644 --- a/package.json +++ b/package.json @@ -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..44ec4f2dd3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,283 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* 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 { USER_ID, addTodo, deleteTodo } from './api.ts/todos'; +import { useEffect, useState, useRef } from 'react'; +import { getTodos } from './api.ts/todos'; +import { Todo, FilterType, ErrorMessage } from './types/Todo'; +import { Header } from './components/Header'; +import { TodoItem } from './components/TodoItem'; +import { ErrorNotification } from './components/ErrorNotification'; +import { Footer } from './components/Footer'; +import { TempTodoItem } from './components/TempTodoItem'; +import { updateTodo } from './api.ts/todos'; export const App: React.FC = () => { + const field = useRef(null); + const [todos, setTodos] = useState([]); + const [error, setError] = useState(''); + const [filter, setFilter] = useState(FilterType.All); + const [loading, setLoading] = useState(false); + const [todoTitle, setTodoTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [deletingIds, setDeletingIds] = useState([]); + const [updatingIds, setUpdatingIds] = useState([]); + + function showError(message: string) { + setError(message); + setTimeout(() => { + setError(''); + }, 3000); + } + + async function loadTodos() { + setError(''); + setLoading(true); + try { + const result = await getTodos(); + + setTodos(result); + field.current?.focus(); + } catch { + showError(ErrorMessage.UnableLoadTodos); + } finally { + setLoading(false); + } + } + + async function handleAddTodo(event: React.FormEvent) { + setError(''); + event.preventDefault(); + + const title = todoTitle.trim(); + + if (!title) { + showError(ErrorMessage.TitleEmpty); + + return; + } + + const todo = { + id: 0, + completed: false, + title, + userId: USER_ID, + }; + + setTempTodo(todo); + + try { + setLoading(true); + const newTodo = await addTodo(title); + + setTodos([...todos, newTodo]); + setTodoTitle(''); + setTempTodo(null); + } catch { + showError(ErrorMessage.UnableAddTodo); + setTempTodo(null); + } finally { + setLoading(false); + field.current?.focus(); + } + } + + async function handleDeleteTodo(id: number) { + setDeletingIds([...deletingIds, id]); + + try { + await deleteTodo(id); + setTodos(todos.filter(todo => todo.id !== id)); + } catch { + showError(ErrorMessage.UnableDeleteTodo); + } finally { + setDeletingIds(deletingIds.filter(delId => delId !== id)); + } + } + + async function handleToggleTodo(todo: Todo) { + setError(''); + setUpdatingIds([...updatingIds, todo.id]); + + try { + const updatedTodo = await updateTodo(todo.id, { + completed: !todo.completed, + }); + + setTodos(todos.map(t => (t.id === updatedTodo.id ? updatedTodo : t))); + } catch { + showError(ErrorMessage.UnableToggleTodo); + } finally { + setUpdatingIds(updatingIds.filter(updId => updId !== todo.id)); + } + } + + const toggleAll = todos.every(todo => todo.completed); + + async function handleToggleAll() { + setError(''); + const newStatus = !toggleAll; + const todosToUpdate = todos.filter(todo => todo.completed !== newStatus); + const updatingIdsArray = todosToUpdate.map(todo => todo.id); + + setUpdatingIds(prev => [...prev, ...updatingIdsArray]); + try { + const result = await Promise.allSettled( + todosToUpdate.map(todo => + updateTodo(todo.id, { completed: newStatus }), + ), + ); + const hasErrors = result.some(r => r.status === 'rejected'); + + if (hasErrors) { + showError(ErrorMessage.UnableToggleTodo); + } + + const successfulUpd: Todo[] = []; + + result.forEach(res => { + if (res.status === 'fulfilled') { + successfulUpd.push(res.value); + } + }); + setTodos(currentTodos => + currentTodos.map(todo => { + const upd = successfulUpd.find(u => u.id === todo.id); + + return upd ? upd : todo; + }), + ); + } catch { + showError(ErrorMessage.UnableToggleTodo); + } finally { + setUpdatingIds(prev => prev.filter(id => !updatingIdsArray.includes(id))); + } + } + + useEffect(() => { + if (!loading) { + field.current?.focus(); + } + }, [loading]); + + useEffect(() => { + loadTodos(); + }, []); + + useEffect(() => { + if (deletingIds.length === 0 && !loading) { + field.current?.focus(); + } + }, [deletingIds, loading]); + if (!USER_ID) { return ; } + function getFilteredTodos() { + if (filter === FilterType.All) { + return todos; + } + + if (filter === FilterType.Active) { + return todos.filter(todo => !todo.completed); + } + + if (filter === FilterType.Completed) { + return todos.filter(todo => todo.completed); + } + + return todos; + } + + async function handleClearCompleted() { + setError(''); + const completedIds = todos + .filter(todo => todo.completed) + .map(todo => todo.id); + + setDeletingIds([...deletingIds, ...completedIds]); + + try { + const result = await Promise.allSettled( + completedIds.map(id => deleteTodo(id)), + ); + + const hasErrors = result.some(r => r.status === 'rejected'); + + if (hasErrors) { + showError(ErrorMessage.UnableDeleteTodo); + } + + const successfulIds = completedIds.filter( + (id, index) => result[index].status === 'fulfilled', + ); + + setTodos(todos.filter(todo => !successfulIds.includes(todo.id))); + } finally { + setDeletingIds([]); + } + } + + const filteredTodos = getFilteredTodos(); + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const completedTodosCount = todos.filter(todo => todo.completed).length; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {(filteredTodos.length > 0 || tempTodo) && ( +
+ {filteredTodos.map(todo => ( + + ))} + + +
+ )} + + {/* Hide the footer if there are no todos */} + {todos.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.ts/todos.ts b/src/api.ts/todos.ts new file mode 100644 index 0000000000..ff02b97ced --- /dev/null +++ b/src/api.ts/todos.ts @@ -0,0 +1,25 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 3837; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here +export const addTodo = (title: string) => { + return client.post(`/todos`, { + title, + userId: USER_ID, + completed: false, + }); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (id: number, data: Partial) => { + return client.patch(`/todos/${id}`, data); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..08c0e1afea --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +interface ErrorNotificationProps { + error: string; + loading: boolean; + setError: (error: string) => void; +} + +export const ErrorNotification: React.FC = ({ + error, + loading, + setError, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..92806a33a1 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { FilterType } from '../types/Todo'; + +interface FooterProps { + activeTodosCount: number; + filter: FilterType; + setFilter: (filter: FilterType) => void; + completedTodosCount: number; + handleClearCompleted: () => void; +} + +export const Footer: React.FC = ({ + activeTodosCount, + filter, + setFilter, + completedTodosCount, + handleClearCompleted, +}) => { + const filters: FilterType[] = [ + FilterType.All, + FilterType.Active, + FilterType.Completed, + ]; + + return ( +
+ + {activeTodosCount} {activeTodosCount === 1 ? 'item' : 'items'} left + + + {/* Active link should have the 'selected' class */} + + + {/* this button should be disabled if there are no completed todos */} + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..83a7762fb2 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; + +interface HeaderProps { + todos: Todo[]; + activeTodosCount: number; + loading: boolean; + field: React.RefObject; + todoTitle: string; + handleAddTodo: (event: React.FormEvent) => void; + setTodoTitle: (title: string) => void; + toggleAll: boolean; + handleToggleAll: () => void; +} + +export const Header: React.FC = ({ + todos, + loading, + field, + todoTitle, + handleAddTodo, + setTodoTitle, + toggleAll, + handleToggleAll, +}) => { + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TempTodoItem.tsx b/src/components/TempTodoItem.tsx new file mode 100644 index 0000000000..f5726d6ef0 --- /dev/null +++ b/src/components/TempTodoItem.tsx @@ -0,0 +1,37 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ + +import React from 'react'; +import { Todo } from '../types/Todo'; + +interface TempTodoItemProps { + tempTodo: Todo | null; +} + +export const TempTodoItem: React.FC = ({ tempTodo }) => { + if (!tempTodo) { + return null; + } + + return ( +
+ + + + {tempTodo.title} + + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..413c7e9ae8 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,145 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ + +import { updateTodo } from '../api.ts/todos'; +import { Todo, ErrorMessage } from '../types/Todo'; +import { useEffect, useState, useRef } from 'react'; + +interface TodoItemProps { + todo: Todo; + loading: boolean; + deletingIds: number[]; + handleDeleteTodo: (id: number) => void; + updatingIds: number[]; + handleToggleTodo: (todo: Todo) => void; + showError: (title: string) => void; + setTodos: React.Dispatch>; + setUpdatingIds: React.Dispatch>; +} + +export const TodoItem: React.FC = ({ + todo, + loading, + deletingIds, + handleDeleteTodo, + updatingIds, + handleToggleTodo, + showError, + setTodos, + setUpdatingIds, +}) => { + const [editTodo, setEditTodo] = useState(null); + const [saveText, setSaveText] = useState(''); + const ref = useRef(null); + + async function handleSave() { + if (saveText.trim() === '') { + handleDeleteTodo(todo.id); + + return; + } + + if (saveText === todo.title) { + setEditTodo(null); + + return; + } + + setUpdatingIds(prev => [...prev, todo.id]); + + try { + const updatedTodo = await updateTodo(todo.id, { title: saveText.trim() }); + + setTodos(prev => + prev.map(t => (t.id === updatedTodo.id ? updatedTodo : t)), + ); + setEditTodo(null); + } catch { + showError(ErrorMessage.UnableToggleTodo); + } finally { + setUpdatingIds(prev => prev.filter(upd => upd !== todo.id)); + } + } + + useEffect(() => { + if (editTodo === todo.id) { + ref.current?.focus(); + } + }, [editTodo]); + + return ( +
+ + + {editTodo !== todo.id && ( + { + setEditTodo(todo.id); + setSaveText(todo.title); + }} + > + {todo.title} + + )} + + {editTodo === todo.id && ( + setSaveText(e.target.value)} + onBlur={handleSave} + onKeyDown={e => { + if (e.key === 'Enter') { + handleSave(); + } + }} + onKeyUp={e => { + if (e.key === 'Escape') { + setEditTodo(null); + setSaveText(todo.title); + } + }} + /> + )} + + {/* Remove button appears only on hover */} + {editTodo !== todo.id && ( + + )} + + {/* overlay will cover the todo while it is being deleted or updated */} +
+
+
+
+
+ ); +}; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..0df4940289 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,20 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} + +export enum FilterType { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} + +export enum ErrorMessage { + UnableLoadTodos = 'Unable to load todos', + TitleEmpty = 'Title should not be empty', + UnableAddTodo = 'Unable to add a todo', + UnableDeleteTodo = 'Unable to delete a todo', + UnableToggleTodo = 'Unable to update a todo', +} 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'), +};