diff --git a/README.md b/README.md index 47a1add059..75295d9c70 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://maximtsyrulnyk.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..6aa6d311e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,86 @@ -/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { USER_ID } from './api/todos'; +import { ErrorNotification } from './components/ErrorNotification'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { useTodos } from './hooks/useTodos'; import { UserWarning } from './UserWarning'; -const USER_ID = 0; - export const App: React.FC = () => { + const { + todos, + isLoading, + isAdding, + errorMessage, + filter, + newTitle, + tempTodo, + loadingTodoIds, + newTodoInputRef, + visibleTodos, + setFilter, + setNewTitle, + closeError, + handleAddTodo, + handleDeleteTodo, + handleClearCompleted, + handleToggleTodo, + handleToggleAll, + handleRenameTodo, + } = useTodos(); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ {isLoading && ( +
+
+
+
+ )} + +
0 && todos.every(todo => todo.completed)} + newTitle={newTitle} + isAdding={isAdding} + inputRef={newTodoInputRef} + onNewTitleChange={setNewTitle} + onSubmit={handleAddTodo} + hasTodos={todos.length > 0} + onToggleAll={handleToggleAll} + /> + + {!isLoading && (todos.length > 0 || tempTodo) && ( + + )} + + {todos.length > 0 && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..723cc2b30c --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,23 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 12345; + +type TodoPatch = Partial>; +type NewTodo = Omit; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (newTodo: NewTodo) => { + return client.post('/todos', newTodo); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (id: number, data: TodoPatch) => { + return client.patch(`/todos/${id}`, data); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..4963388c18 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +type Props = { + message: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ message, onClose }) => { + return ( +
+ {message} +
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..5cef16e28c --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; + +type FilterStatus = 'all' | 'active' | 'completed'; + +type Props = { + todos: Todo[]; + filter: FilterStatus; + onFilterChange: (filter: FilterStatus) => void; + onClearCompleted: () => void; +}; + +export const Footer: React.FC = ({ + todos, + filter, + onFilterChange, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..b679099a8c --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,51 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; + +type Props = { + allCompleted: boolean; + newTitle: string; + isAdding: boolean; + inputRef: React.RefObject; + onNewTitleChange: (value: string) => void; + onSubmit: (event: React.FormEvent) => void; + hasTodos: boolean; + onToggleAll: () => void; +}; + +export const Header: React.FC = ({ + allCompleted, + newTitle, + isAdding, + inputRef, + onNewTitleChange, + onSubmit, + hasTodos, + onToggleAll, +}) => { + return ( +
+ {hasTodos && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..faec12e797 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,131 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + isLoading: boolean; + onDelete: (id: number) => void; + onToggle: (todo: Todo) => void; + onRename: (todo: Todo, title: string) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + onDelete, + onToggle, + onRename, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(todo.title); + const titleFieldRef = useRef(null); + const isSubmittingRef = useRef(false); + const isCancellingRef = useRef(false); + + useEffect(() => { + if (isEditing) { + titleFieldRef.current?.focus(); + } + }, [isEditing]); + + const submitRename = () => { + isSubmittingRef.current = true; + + onRename(todo, editedTitle) + .then(() => { + setIsEditing(false); + }) + .catch(() => { + isCancellingRef.current = true; + titleFieldRef.current?.focus(); + }) + .finally(() => { + isSubmittingRef.current = false; + }); + }; + + return ( +
+ + + {!isEditing ? ( + { + setIsEditing(true); + setEditedTitle(todo.title); + }} + > + {todo.title} + + ) : ( +
{ + event.preventDefault(); + submitRename(); + }} + > + setEditedTitle(event.target.value)} + ref={titleFieldRef} + onKeyUp={event => { + if (event.key === 'Escape') { + isCancellingRef.current = true; + setIsEditing(false); + setEditedTitle(todo.title); + } + }} + onBlur={() => { + if (isSubmittingRef.current) { + return; + } + + if (isCancellingRef.current) { + isCancellingRef.current = false; + + return; + } + + submitRename(); + }} + /> +
+ )} + + {!isEditing && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..2e667ce2c7 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,64 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + loadingTodoIds: number[]; + tempTodo: Todo | null; + onDelete: (id: number) => void; + onToggle: (todo: Todo) => void; + onRename: (todo: Todo, title: string) => void; +}; + +export const TodoList: React.FC = ({ + todos, + loadingTodoIds, + tempTodo, + onDelete, + onToggle, + onRename, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts new file mode 100644 index 0000000000..a2916eccb7 --- /dev/null +++ b/src/hooks/useTodos.ts @@ -0,0 +1,307 @@ +import { FormEvent, useEffect, useRef, useState } from 'react'; +import { + addTodo, + deleteTodo, + getTodos, + USER_ID, + updateTodo, +} from '../api/todos'; +import { Todo } from '../types/Todo'; +import { ErrorMessage } from '../types/ErrorMessage'; + +type FilterStatus = 'all' | 'active' | 'completed'; + +export const useTodos = () => { + const [todos, setTodos] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isAdding, setIsAdding] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [filter, setFilter] = useState('all'); + const [newTitle, setNewTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const newTodoInputRef = useRef(null); + + const startTodoLoading = (id: number) => { + setLoadingTodoIds(current => [...current, id]); + }; + + const stopTodoLoading = (id: number) => { + setLoadingTodoIds(current => current.filter(todoId => todoId !== id)); + }; + + const showError = (message: ErrorMessage) => { + setErrorMessage(message); + }; + + const handleDeleteTodo = (id: number) => { + setErrorMessage(''); + startTodoLoading(id); + + return deleteTodo(id) + .then(() => setTodos(current => current.filter(todo => todo.id !== id))) + .catch(() => { + showError(ErrorMessage.DELETE_TODO); + }) + .finally(() => { + stopTodoLoading(id); + setTimeout(() => { + newTodoInputRef.current?.focus(); + }, 0); + }); + }; + + const handleClearCompleted = () => { + setErrorMessage(''); + + const completedIds = todos + .filter(todo => todo.completed) + .map(todo => todo.id); + + if (!completedIds.length) { + return; + } + + setLoadingTodoIds(current => [...current, ...completedIds]); + + Promise.allSettled(completedIds.map(id => deleteTodo(id))).then(results => { + const successfulIds = completedIds.filter( + (_, index) => results[index].status === 'fulfilled', + ); + const hasFailed = results.some(result => result.status === 'rejected'); + + if (successfulIds.length) { + setTodos(current => + current.filter(todo => !successfulIds.includes(todo.id)), + ); + } + + if (hasFailed) { + showError(ErrorMessage.DELETE_TODO); + } + + setLoadingTodoIds(current => + current.filter(id => !completedIds.includes(id)), + ); + + setTimeout(() => { + newTodoInputRef.current?.focus(); + }, 0); + }); + }; + + const handleToggleTodo = (todo: Todo) => { + startTodoLoading(todo.id); + setErrorMessage(''); + + updateTodo(todo.id, { completed: !todo.completed }) + .then(updatedTodo => { + setTodos(current => + current.map(item => (item.id === todo.id ? updatedTodo : item)), + ); + }) + .catch(() => showError(ErrorMessage.UPDATE_TODO)) + .finally(() => { + stopTodoLoading(todo.id); + }); + }; + + const handleToggleAll = () => { + setErrorMessage(''); + + const targetCompleted = todos.some(todo => !todo.completed); + const todosToUpdate = todos.filter( + todo => todo.completed !== targetCompleted, + ); + + if (!todosToUpdate.length) { + return; + } + + const idsToUpdate = todosToUpdate.map(todo => todo.id); + + setLoadingTodoIds(current => [...current, ...idsToUpdate]); + + Promise.allSettled( + todosToUpdate.map(todo => + updateTodo(todo.id, { completed: targetCompleted }), + ), + ).then(results => { + const updatedTodos: Todo[] = []; + + results.forEach(result => { + if (result.status === 'fulfilled') { + updatedTodos.push(result.value); + } + }); + + if (updatedTodos.length) { + setTodos(current => + current.map(todo => { + const updated = updatedTodos.find(item => item.id === todo.id); + + return updated || todo; + }), + ); + } + + if (results.some(result => result.status === 'rejected')) { + showError(ErrorMessage.UPDATE_TODO); + } + + setLoadingTodoIds(current => + current.filter(id => !idsToUpdate.includes(id)), + ); + }); + }; + + const handleRenameTodo = (todo: Todo, title: string): Promise => { + const trimmed = title.trim(); + + if (trimmed === todo.title) { + return Promise.resolve(); + } + + if (trimmed === '') { + setErrorMessage(''); + startTodoLoading(todo.id); + + return deleteTodo(todo.id) + .then(() => + setTodos(current => current.filter(item => item.id !== todo.id)), + ) + .catch(() => { + showError(ErrorMessage.DELETE_TODO); + throw new Error(ErrorMessage.DELETE_TODO); // ← тримає форму відкритою + }) + .finally(() => { + stopTodoLoading(todo.id); + setTimeout(() => { + newTodoInputRef.current?.focus(); + }, 0); + }); + } + + setErrorMessage(''); + startTodoLoading(todo.id); + + return updateTodo(todo.id, { title: trimmed }) + .then(updatedTodo => { + setTodos(current => + current.map(item => (item.id === todo.id ? updatedTodo : item)), + ); + }) + .catch(() => { + showError(ErrorMessage.UPDATE_TODO); + throw new Error(ErrorMessage.UPDATE_TODO); + }) + .finally(() => { + stopTodoLoading(todo.id); + }); + }; + + const handleAddTodo = (event: FormEvent) => { + event.preventDefault(); + + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + showError(ErrorMessage.EMPTY_TITLE); + + return; + } + + setErrorMessage(''); + setIsAdding(true); + + const newTodoData = { + userId: USER_ID, + title: trimmedTitle, + completed: false, + }; + + setTempTodo({ id: 0, ...newTodoData }); + + addTodo(newTodoData) + .then(createdTodo => { + setTodos(current => [...current, createdTodo]); + setNewTitle(''); + }) + .catch(() => showError(ErrorMessage.ADD_TODO)) + .finally(() => { + setTempTodo(null); + setIsAdding(false); + setTimeout(() => { + newTodoInputRef.current?.focus(); + }, 0); + }); + }; + + const visibleTodos = todos.filter(todo => { + if (filter === 'active') { + return !todo.completed; + } + + if (filter === 'completed') { + return todo.completed; + } + + return true; + }); + + const closeError = () => { + setErrorMessage(''); + }; + + useEffect(() => { + newTodoInputRef.current?.focus(); + }, []); + + useEffect(() => { + if (!errorMessage) { + return; + } + + const timerId = setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return () => clearTimeout(timerId); + }, [errorMessage]); + + useEffect(() => { + if (!USER_ID) { + return; + } + + setIsLoading(true); + setErrorMessage(''); + + getTodos() + .then(setTodos) + .catch(() => setErrorMessage(ErrorMessage.LOAD_TODOS)) + .finally(() => setIsLoading(false)); + }, []); + + return { + todos, + isLoading, + isAdding, + errorMessage, + filter, + newTitle, + tempTodo, + loadingTodoIds, + newTodoInputRef, + visibleTodos, + setFilter, + setNewTitle, + closeError, + handleAddTodo, + handleDeleteTodo, + handleToggleTodo, + handleToggleAll, + handleClearCompleted, + handleRenameTodo, + }; +}; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..014e443bc7 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + LOAD_TODOS = 'Unable to load todos', + EMPTY_TITLE = 'Title should not be empty', + ADD_TODO = 'Unable to add a todo', + DELETE_TODO = 'Unable to delete a todo', + UPDATE_TODO = '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/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'), +};