diff --git a/package-lock.json b/package-lock.json index 19701e878..511279ae7 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": "^1.9.12", "@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": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "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 b6062525a..005692edf 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": "^1.9.12", "@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 81e011f43..cd5dd9e61 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,307 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { USER_ID, getTodos, deleteTodo, createTodo } from './api/todos'; +import { Todo } from './types/Todo'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { ErrorItem } from './components/ErrorItem'; +import { FooterItem } from './components/Footer'; +import { updateTodo } from './api/todos'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [filter, setFilter] = useState('all'); + const [isSubmitting, setIsSubmitting] = useState(false); + const inputRef = useRef(null); + const [tempTodo, setTempTodo] = useState(null); + const [deletingTodoId, setDeletingTodoId] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [editingTodoId, setEditingTodoId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const [loadingIds, setLoadingIds] = useState([]); + + useEffect(() => { + if (inputRef.current && !isSubmitting) { + inputRef.current.focus(); + } + }, [todos, isSubmitting]); + + useEffect(() => { + if (errorMessage) { + const timer = setTimeout(() => { + setErrorMessage(null); + }, 3000); + + return () => clearTimeout(timer); + } + + return undefined; + }, [errorMessage]); + + useEffect(() => { + setErrorMessage(null); + getTodos() + .then(setTodos) + .catch(() => setErrorMessage('Unable to load todos')); + }, []); + + const hasAllTodosCompleted = todos.every(todo => todo.completed); + const notCompletedTodos = todos.filter(todo => !todo.completed); + const completedTodos = todos.filter(todo => todo.completed); + if (!USER_ID) { return ; } + const onUpdate = (id: number, updatedStatus: Partial) => { + return updateTodo({ id, ...updatedStatus }) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(todo => (todo.id === id ? updatedTodo : todo)), + ); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + }); + }; + + const handleToggleAll = () => { + const haveActive = todos.some(todo => !todo.completed); + const todosToUpdate = haveActive + ? todos.filter(todo => !todo.completed) + : todos; + + setLoadingIds([...loadingIds, ...todosToUpdate.map(todo => todo.id)]); + + Promise.all( + todosToUpdate.map(todo => onUpdate(todo.id, { completed: haveActive })), + ).finally(() => { + setLoadingIds(prevLoadingIds => + prevLoadingIds.filter( + id => !todosToUpdate.some(todo => todo.id === id), + ), + ); + }); + }; + + const handleToggleTodo = (todo: Todo) => { + setLoadingIds([...loadingIds, todo.id]); + + onUpdate(todo.id, { completed: !todo.completed }).finally(() => { + setLoadingIds(loadingIds.filter(id => id !== todo.id)); + }); + }; + + const handleDeleteTodo = (todoId: number) => { + setLoadingIds([...loadingIds, todoId]); + + deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }) + .finally(() => { + setDeletingTodoId(null); + setLoadingIds(loadingIds.filter(id => todoId !== id)); + }); + }; + + const handleClearCompleted = () => { + const deletePromises = completedTodos.map(todo => + handleDeleteTodo(todo.id), + ); + + Promise.all(deletePromises).catch(() => { + setErrorMessage('Unable to delete one or more todos'); + }); + }; + + const handleAddTodo = (event: React.FormEvent) => { + event.preventDefault(); + const trimmedTitle = newTodoTitle.trim(); + + if (!newTodoTitle.trim()) { + setErrorMessage('Title should not be empty'); + + return; + } + + const tempTodoItem: Todo = { + id: 0, + title: newTodoTitle, + completed: false, + userId: USER_ID, + }; + + setTempTodo(tempTodoItem); + setIsSubmitting(true); + + createTodo({ + title: trimmedTitle, + userId: USER_ID, + completed: false, + }) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + setNewTodoTitle(''); + }) + .catch(() => setErrorMessage('Unable to add a todo')) + .finally(() => { + setIsSubmitting(false); + setTempTodo(null); + }); + }; + + const onDoubleClickHandler = (todo: Todo) => { + setEditingTodoId(todo.id); + setEditingTitle(todo.title); + setIsEditing(true); + }; + + const handleBlur = (id: number) => { + const updatedTitle = editingTitle.trim(); + + if (!updatedTitle) { + deleteTodo(id) + .then(() => { + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== id)); + setEditingTodoId(null); + setEditingTitle(''); + setIsEditing(false); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }); + + return; + } + + updateTodo({ id, title: updatedTitle }) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(todo => (todo.id === id ? updatedTodo : todo)), + ); + setEditingTodoId(null); + setEditingTitle(''); + setIsEditing(false); + }) + .catch(() => { + setErrorMessage('Unable to update title'); + }); + }; + + const handleUpdateTodo = (id: number, event: React.FormEvent) => { + event.preventDefault(); + + const updatedTitle = editingTitle.trim(); + + if (todos.find(todo => todo.id === id)?.title === updatedTitle) { + setEditingTodoId(null); + setIsEditing(false); + setEditingTitle(''); + + return; + } + + if (!updatedTitle) { + setIsSubmitting(true); + + deleteTodo(id) + .then(() => { + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== id)); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }) + .finally(() => { + setEditingTodoId(null); + setIsEditing(false); + setEditingTitle(''); + setIsSubmitting(false); + }); + + return; + } + + setLoadingIds(prevLoadingIds => [...prevLoadingIds, id]); + + updateTodo({ id, title: updatedTitle }) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(todo => (todo.id === id ? updatedTodo : todo)), + ); + setEditingTodoId(null); + setEditingTitle(''); + setIsEditing(false); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + }) + .finally(() => { + setLoadingIds(prevLoadingIds => + prevLoadingIds.filter(loadingId => loadingId !== id), + ); + }); + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+
+
+ + + + {todos.length > 0 && ( + + )} +
+ +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..b01904826 --- /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 = 1350; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export function deleteTodo(todoId: number) { + return client.delete(`/todos/${todoId}`); +} + +export function createTodo({ title, userId, completed }: Omit) { + return client.todo('/todos', { title, userId, completed }); +} + +export function updateTodo({ id, title, userId, completed }: Todo) { + return client.patch(`/todos/${id}`, { title, userId, completed }); +} diff --git a/src/components/ErrorItem.tsx b/src/components/ErrorItem.tsx new file mode 100644 index 000000000..20cb6fe9e --- /dev/null +++ b/src/components/ErrorItem.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import classNames from 'classnames'; + +interface ErrorItemProps { + errorMessage: string | null; + setErrorMessage: React.Dispatch>; +} + +export const ErrorItem: React.FC = ({ + errorMessage, + setErrorMessage, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..86611495b --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import classNames from 'classnames'; +import { FilterOptions } from '../types/FilterOptions'; + +interface FooterItemProps { + notCompletedTodos: { length: number }; + setFilter: (filter: FilterOptions) => void; + filter: string; + handleClearCompleted: () => void; + completedTodos: { length: number }; +} + +export const FooterItem: React.FC = ({ + notCompletedTodos, + setFilter, + filter, + handleClearCompleted, + completedTodos, +}) => { + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..aa8b28b24 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + hasAllTodosCompleted: boolean; + handleAddTodo: (event: React.FormEvent) => void; + newTodoTitle: string; + setNewTodoTitle: (title: string) => void; + inputRef: React.RefObject; + isSubmitting: boolean; + handleToggleAll: () => void; + todos: Todo[]; +}; + +export const Header: React.FC = ({ + hasAllTodosCompleted, + handleAddTodo, + newTodoTitle, + setNewTodoTitle, + inputRef, + isSubmitting, + handleToggleAll, + todos, +}) => { + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..731048bc9 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,122 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useRef, useEffect } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +interface TodoItemProps { + todo: Todo; + onDelete: (todoId: number) => void; + isSubmitting: boolean; + deletingTodoId: number | null; + tempTodo: Todo | null; + onDoubleClickHandler: (todo: Todo) => void; + setEditingTitle: (title: string) => void; + editingTodoId: number | null; + isEditing: boolean; + handleToggleTodo: (todo: Todo) => void; + editingTitle: string; + handleUpdateTodo: () => void; + handleBlur: () => void; + isTogglingAll: boolean; + isDeleting: boolean; + isTogglingTodo: boolean; + loadingIds: number[]; + setEditingTodoId: number; +} + +export const TodoItem: React.FC = ({ + todo, + onDelete, + isSubmitting, + tempTodo, + onDoubleClickHandler, + setEditingTitle, + editingTitle, + isEditing, + editingTodoId, + handleToggleTodo, + handleUpdateTodo, + handleBlur, + loadingIds, + setEditingTodoId, +}) => { + const { id, title, completed } = todo; + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current && editingTodoId === id) { + inputRef.current.focus(); + } + }, [editingTodoId, id]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleUpdateTodo(todo.id, event); + } else if (event.key === 'Escape') { + setEditingTitle(title); + setEditingTodoId(null); + inputRef.current?.blur(); + } + }; + + return ( +
+ + + {isEditing && editingTodoId === todo.id ? ( +
handleUpdateTodo(todo.id, event)}> + setEditingTitle(event.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + /> +
+ ) : ( + onDoubleClickHandler(todo)} + > + {title} + + )} + + {editingTodoId !== id && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..a154dd444 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,116 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useMemo } from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; +import { FilterOptions } from '../types/FilterOptions'; + +interface TodoListProps { + todos: Todo[]; + filter: FilterOptions; + isSubmitting: boolean; + deletingTodoId: number | null; + tempTodo: Todo | null; + onDelete: (todoId: number) => void; + handleToggleTodo: (todo: Todo) => void; + onDoubleClickHandler: (todo: Todo) => void; + handleBlur: (todoId: number) => void; + editingTitle: string; + isEditing: boolean; + setEditingTitle: (title: string) => void; + handleUpdateTodo: (todoId: number, event: React.FormEvent) => void; // ожидаем, что handleUpdateTodo принимает id и event + editingTodoId: number | null; + isDeleting: boolean; + isTogglingTodo: boolean; + loadingIds: number[]; + setEditingTodoId: (id: number | null) => void; // ожидаем функцию, которая обновляет editingTodoId +} + +export const TodoList: React.FC = ({ + todos, + filter, + isSubmitting, + deletingTodoId, + tempTodo, + onDelete, + onDoubleClickHandler, + handleToggleTodo, + handleBlur, + editingTitle, + isEditing, + setEditingTitle, + handleUpdateTodo, + editingTodoId, + loadingIds, + setEditingTodoId, +}) => { + const filteredTodos = useMemo(() => { + return todos.filter(todo => { + if (filter === FilterOptions.Active) { + return !todo.completed; + } + + if (filter === FilterOptions.Completed) { + return todo.completed; + } + + return true; + }); + }, [todos, filter]); + + return ( +
+ {filteredTodos.map(todo => ( + handleToggleTodo(todo)} + handleBlur={() => handleBlur(todo.id)} + editingTitle={editingTitle} + isEditing={isEditing} + setEditingTitle={setEditingTitle} + handleUpdateTodo={handleUpdateTodo} + editingTodoId={editingTodoId} + loadingIds={loadingIds} + setEditingTodoId={setEditingTodoId} + /> + ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/types/FilterOptions.tsx b/src/types/FilterOptions.tsx new file mode 100644 index 000000000..cf7553611 --- /dev/null +++ b/src/types/FilterOptions.tsx @@ -0,0 +1,5 @@ +export enum FilterOptions { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..3f52a5fdd --- /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 000000000..a215834bb --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; + +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, +): Promise { + const options: RequestInit = { method }; + + if (data) { + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + 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), + + todo: (url: string, data: any) => request(url, 'POST', data), + + patch: (url: string, data: any) => request(url, 'PATCH', data), + + delete: (url: string) => request(url, 'DELETE'), +};