diff --git a/README.md b/README.md index 47a1add059..9af70d3531 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://anastasiia-levochkina.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..1a4b2bd349 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,250 @@ /* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { + getTodos, + createTodo, + deleteTodo, + updateTodo, + USER_ID, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { FilterType } from './types/FilterType'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoList } from './components/TodoList'; +import { TodoFooter } from './components/TodoFooter'; +import { ErrorNotification } from './components/ErrorNotification'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [newTitle, setNewTitle] = useState(''); + const [loadingIds, setLoadingIds] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [filter, setFilter] = useState(FilterType.All); + const [editingId, setEditingId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + + const inputRef = useRef(null); + + useEffect(() => { + if (errorMessage) { + const timer = window.setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return () => clearTimeout(timer); + } + + return undefined; + }, [errorMessage]); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => setErrorMessage('Unable to load todos')); + }, []); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const title = newTitle.trim(); + + if (!title) { + setErrorMessage('Title should not be empty'); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + + return; + } + + const temp: Todo = { + id: 0, + userId: USER_ID, + title, + completed: false, + }; + + setTempTodo(temp); + + try { + const created = await createTodo({ + userId: USER_ID, + title, + completed: false, + }); + + setTodos(prev => [...prev, created]); + setNewTitle(''); + } catch { + setErrorMessage('Unable to add a todo'); + } finally { + setTempTodo(null); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + } + }; + + const handleDelete = async (id: number) => { + setLoadingIds(prev => [...prev, id]); + + try { + await deleteTodo(id); + setTodos(prev => prev.filter(todo => todo.id !== id)); + } catch { + setErrorMessage('Unable to delete a todo'); + } finally { + setLoadingIds(prev => prev.filter(item => item !== id)); + inputRef.current?.focus(); + } + }; + + const handleToggle = async (todo: Todo) => { + setLoadingIds(prev => [...prev, todo.id]); + + try { + const updated = await updateTodo(todo.id, { + completed: !todo.completed, + }); + + setTodos(prev => prev.map(t => (t.id === todo.id ? updated : t))); + } catch { + setErrorMessage('Unable to update a todo'); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + const handleToggleAll = async () => { + const allCompleted = todos.length > 0 && todos.every(t => t.completed); + + const toUpdate = todos.filter(t => t.completed === allCompleted); + + await Promise.all(toUpdate.map(todo => handleToggle(todo))); + }; + + const handleEditStart = (todo: Todo) => { + setEditingId(todo.id); + setEditingTitle(todo.title); + }; + + const handleEditSave = async (todo: Todo) => { + const trimmed = editingTitle.trim(); + + if (trimmed === todo.title) { + setEditingId(null); + + return; + } + + if (!trimmed) { + setLoadingIds(prev => [...prev, todo.id]); + + try { + await deleteTodo(todo.id); + + setTodos(prev => prev.filter(t => t.id !== todo.id)); + setEditingId(null); + } catch { + setErrorMessage('Unable to delete a todo'); + + return; + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + + return; + } + + setLoadingIds(prev => [...prev, todo.id]); + + try { + const updated = await updateTodo(todo.id, { + title: trimmed, + }); + + setTodos(prev => prev.map(t => (t.id === todo.id ? updated : t))); + + setEditingId(null); + } catch { + setErrorMessage('Unable to update a todo'); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + const visibleTodos = useMemo(() => { + switch (filter) { + case FilterType.Active: + return todos.filter(todo => !todo.completed); + case FilterType.Completed: + return todos.filter(todo => todo.completed); + default: + return todos; + } + }, [todos, filter]); + + const activeCount = todos.filter(todo => !todo.completed).length; + const hasCompleted = todos.some(todo => todo.completed); + if (!USER_ID) { return ; } + const handleClearCompleted = () => { + Promise.all( + todos.filter(todo => todo.completed).map(todo => handleDelete(todo.id)), + ); + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ + + {(todos.length > 0 || tempTodo) && ( + setEditingId(null)} + /> + )} + + {todos.length > 0 && ( + + )} +
+ + setErrorMessage('')} + /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..864eecfaff --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,20 @@ +import { client } from '../utils/fetchClient'; +import { Todo } from '../types/Todo'; + +export const USER_ID = 3953; // твой id + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (todo: Omit) => { + return client.post('/todos', todo); +}; + +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..eb015e9654 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import classNames from 'classnames'; + +interface ErrorNotificationProps { + message: string; + onClose: () => void; +} + +export const ErrorNotification: React.FC = ({ + message, + onClose, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 0000000000..7b16bd7e71 --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import classNames from 'classnames'; +import { FilterType } from '../types/FilterType'; + +interface TodoFooterProps { + activeCount: number; + hasCompleted: boolean; + filter: FilterType; + onFilterChange: (filter: FilterType) => void; + onClearCompleted: () => void; +} + +export const TodoFooter: React.FC = ({ + activeCount, + hasCompleted, + filter, + onFilterChange, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 0000000000..51c8fdedf9 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +interface TodoHeaderProps { + todos: Todo[]; + newTitle: string; + tempTodo: Todo | null; + inputRef: React.RefObject; + onToggleAll: () => void; + onSubmit: (event: React.FormEvent) => void; + onNewTitleChange: (title: string) => void; +} + +export const TodoHeader: React.FC = ({ + todos, + newTitle, + tempTodo, + inputRef, + onToggleAll, + onSubmit, + onNewTitleChange, +}) => { + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..b1fad6297b --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,116 @@ +import React, { useRef, useEffect } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +interface TodoItemProps { + todo: Todo; + isLoading: boolean; + isEditing: boolean; + editingTitle: string; + onToggle: (todo: Todo) => void; + onDelete: (id: number) => void; + onEditStart: (todo: Todo) => void; + onEditSave: (todo: Todo) => void; + onEditTitleChange: (title: string) => void; + onEditCancel: () => void; +} + +export const TodoItem: React.FC = ({ + todo, + isLoading, + isEditing, + editingTitle, + onToggle, + onDelete, + onEditStart, + onEditSave, + onEditTitleChange, + onEditCancel, +}) => { + const editInputRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editInputRef.current?.focus(); + } + }, [isEditing]); + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onEditSave(todo); + } + + if (e.key === 'Escape') { + onEditCancel(); + } + }; + + const handleBlur = () => { + onEditSave(todo); + }; + + return ( +
+ onToggle(todo)} + /> + + + + {isEditing ? ( + onEditTitleChange(e.target.value)} + onBlur={handleBlur} + onKeyUp={handleKeyUp} + /> + ) : ( + onEditStart(todo)} + > + {todo.title} + + )} + + {!isEditing && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..b99aee1ec0 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +interface TodoListProps { + todos: Todo[]; + tempTodo: Todo | null; + loadingIds: number[]; + editingId: number | null; + editingTitle: string; + onToggle: (todo: Todo) => void; + onDelete: (id: number) => void; + onEditStart: (todo: Todo) => void; + onEditSave: (todo: Todo) => void; + onEditTitleChange: (title: string) => void; + onEditCancel: () => void; +} + +export const TodoList: React.FC = ({ + todos, + tempTodo, + loadingIds, + editingId, + editingTitle, + onToggle, + onDelete, + onEditStart, + onEditSave, + onEditTitleChange, + onEditCancel, +}) => { + return ( +
+ {todos.map(todo => { + const isLoading = loadingIds.includes(todo.id); + + return ( + + ); + })} + + {tempTodo && ( +
+ + {tempTodo.title} + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/types/FilterType.ts b/src/types/FilterType.ts new file mode 100644 index 0000000000..5b306c57d0 --- /dev/null +++ b/src/types/FilterType.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/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..5be775084e --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,42 @@ +/* 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), + post: (url: string, data: any) => request(url, 'POST', data), + patch: (url: string, data: any) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +};