diff --git a/README.md b/README.md index 47a1add059..955f377e9b 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://rekverr.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..1f9810c03e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,190 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import { useState, useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { TodoList } from './components/TodoList'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { USER_ID } from './api/todos'; +import { HeaderTodo } from './components/HeaderTodo'; +import { FilterTodo } from './components/FilterTodo'; +import { useTodos } from './hooks/useTodos'; +import { useErrorMessage } from './hooks/useErrorMessage'; +import { filterTodos, FilterType } from './utils/todoFilters'; +import { ErrorMessage } from './types/ErrorMessage'; export const App: React.FC = () => { + const [filter, setFilter] = useState(FilterType.ALL); + const [newTodoTitle, setNewTodoTitle] = useState(''); + const { errorMessage, setErrorMessage } = useErrorMessage(); + const { + todos, + isAdding, + deletingIds, + tempTodo, + isClearing, + handleAddTodo, + handleDeleteTodo, + handleClearCompleted, + handleCompletedTodo, + handleToggleAllTodos, + completingIds, + editingId, + editingIds, + editingTitle, + setEditingTitle, + handleStartEdit, + handleCommitEdit, + handleCancelEdit, + } = useTodos(setErrorMessage); + + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + if (!isAdding && deletingIds.length === 0 && !isClearing) { + inputRef.current?.focus(); + } + }, [isAdding, deletingIds.length, isClearing]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedTitle = newTodoTitle.trim(); + + if (!trimmedTitle) { + setErrorMessage(ErrorMessage.EMPTY_TITLE); + + return; + } + + try { + await handleAddTodo(trimmedTitle, USER_ID); + setNewTodoTitle(''); + } catch { + setErrorMessage(ErrorMessage.ADD_TODO); + } + }; + + const handleComplete = async (id: number, completed: boolean) => { + try { + await handleCompletedTodo(id, completed); + } catch { + setErrorMessage(ErrorMessage.UPDATE_TODO); + } + }; + + const handleToggleAll = async () => { + const hasActiveTodos = todos.some(todo => !todo.completed); + + try { + await handleToggleAllTodos(hasActiveTodos); + } catch { + setErrorMessage(ErrorMessage.UPDATE_TODO); + } + }; + + const handleEditTodo = async (id: number, title: string) => { + let shouldDelete = false; + + try { + shouldDelete = await handleCommitEdit(id, title); + } catch { + setErrorMessage(ErrorMessage.UPDATE_TODO); + + return; + } + + if (shouldDelete) { + try { + await handleDeleteTodo(id); + handleCancelEdit(); + } catch { + setErrorMessage(ErrorMessage.DELETE_TODO); + } + } + }; + + const handleDelete = async (id: number) => { + try { + await handleDeleteTodo(id); + } catch { + setErrorMessage(ErrorMessage.DELETE_TODO); + } + }; + + const handleClear = async () => { + try { + await handleClearCompleted(); + } catch (error) { + if (error instanceof Error) { + setErrorMessage(error.message); + } + } + }; + if (!USER_ID) { return ; } + const filteredTodos = filterTodos(todos, filter, tempTodo); + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ + + + + +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..230bc23bd9 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,28 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4019; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +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 completeTodo = (id: number, completed: boolean) => { + return client.patch(`/todos/${id}`, { completed }); +}; + +export const updateTodoTitle = (id: number, title: string) => { + return client.patch(`/todos/${id}`, { title }); +}; diff --git a/src/components/FilterTodo.tsx b/src/components/FilterTodo.tsx new file mode 100644 index 0000000000..c77fdb7561 --- /dev/null +++ b/src/components/FilterTodo.tsx @@ -0,0 +1,74 @@ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { FilterType } from '../utils/todoFilters'; + +type Props = { + todos: Todo[]; + filter: FilterType; + setFilter: (filter: FilterType) => void; + handleClearCompleted: () => void; +}; + +export const FilterTodo = ({ + todos, + filter, + setFilter, + handleClearCompleted, +}: Props) => { + return ( + <> + {todos.length > 0 && ( + + )} + + ); +}; diff --git a/src/components/HeaderTodo.tsx b/src/components/HeaderTodo.tsx new file mode 100644 index 0000000000..8184ae01f0 --- /dev/null +++ b/src/components/HeaderTodo.tsx @@ -0,0 +1,50 @@ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + handlerSubmit: (e: React.FormEvent) => void; + handleToggleAll: () => void; + inputRef: React.RefObject; + newTodoTitle: string; + setNewTodoTitle: (title: string) => void; + isAdding: boolean; + todos: Todo[]; +}; + +export const HeaderTodo = ({ + handlerSubmit, + handleToggleAll, + inputRef, + newTodoTitle, + setNewTodoTitle, + isAdding, + todos, +}: Props) => { + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..dae7e63e1e --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,127 @@ +import classNames from 'classnames'; +import { useRef, useEffect } from 'react'; +import { Todo } from '../types/Todo'; + +type Props = { + todos: Todo[]; + isAdding: boolean; + deletingIds: number[]; + completingIds: number[]; + editingId: number | null; + editingIds: number[]; + editingTitle: string; + setEditingTitle: (title: string) => void; + handleDelete: (id: number) => void; + handleComplete: (id: number, completed: boolean) => void; + handleStartEdit: (todo: Todo) => void; + handleEditTodo: (id: number, title: string) => void; + handleCancelEdit: () => void; +}; + +export const TodoList = ({ + todos, + isAdding, + deletingIds, + completingIds, + editingId, + editingIds, + editingTitle, + setEditingTitle, + handleDelete, + handleComplete, + handleStartEdit, + handleEditTodo, + handleCancelEdit, +}: Props) => { + const editInputRef = useRef(null); + + useEffect(() => { + if (editingId !== null) { + editInputRef.current?.focus(); + } + }, [editingId]); + + return ( +
+ {todos.map(todo => ( +
+ + + {editingId === todo.id ? ( + setEditingTitle(e.target.value)} + onBlur={() => handleEditTodo(todo.id, editingTitle)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + e.currentTarget.blur(); + } else if (e.key === 'Escape') { + handleCancelEdit(); + } + }} + /> + ) : ( + handleStartEdit(todo)} + > + {todo.title} + + )} + + {editingId !== todo.id && ( + + )} + +
+
+
+
+
+ ))} +
+ ); +}; diff --git a/src/hooks/useErrorMessage.ts b/src/hooks/useErrorMessage.ts new file mode 100644 index 0000000000..bffb584617 --- /dev/null +++ b/src/hooks/useErrorMessage.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export const useErrorMessage = () => { + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + if (errorMessage) { + const timeoutId = setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return () => clearTimeout(timeoutId); + } + }, [errorMessage]); + + return { errorMessage, setErrorMessage }; +}; diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts new file mode 100644 index 0000000000..d280eed274 --- /dev/null +++ b/src/hooks/useTodos.ts @@ -0,0 +1,227 @@ +import { useState, useEffect } from 'react'; +import { + getTodos, + addTodo, + deleteTodo, + completeTodo, + updateTodoTitle, +} from '../api/todos'; +import { Todo } from '../types/Todo'; +import { ErrorMessage } from '../types/ErrorMessage'; + +export const useTodos = (onError: (message: string) => void) => { + const [todos, setTodos] = useState([]); + const [isAdding, setIsAdding] = useState(false); + const [deletingIds, setDeletingIds] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [isClearing, setIsClearing] = useState(false); + const [completingIds, setCompletingIds] = useState([]); + const [editingId, setEditingId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + const [editingIds, setEditingIds] = useState([]); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => { + onError(ErrorMessage.LOAD_TODOS); + }); + }, [onError]); + + const handleAddTodo = async (title: string, userId: number) => { + setIsAdding(true); + const tempTodoo: Todo = { + id: 0, + userId, + title, + completed: false, + }; + + setTempTodo(tempTodoo); + + try { + const newTodo = await addTodo(title); + + setTodos(prev => [...prev, newTodo]); + setTempTodo(null); + + return newTodo; + } catch (error) { + setTempTodo(null); + throw error; + } finally { + setIsAdding(false); + } + }; + + const handleDeleteTodo = async (id: number) => { + setDeletingIds(prev => Array.from(new Set([...prev, id]))); + try { + await deleteTodo(id); + setTodos(prev => prev.filter(todo => todo.id !== id)); + } catch (error) { + throw error; + } finally { + setDeletingIds(prev => prev.filter(todoId => todoId !== id)); + } + }; + + const handleClearCompleted = async () => { + const completedTodos = todos.filter(todo => todo.completed); + + setDeletingIds(completedTodos.map(todo => todo.id)); + setIsClearing(true); + + const deletePromises = completedTodos.map(todo => + deleteTodo(todo.id) + .then(() => ({ id: todo.id, success: true })) + .catch(() => ({ id: todo.id, success: false })), + ); + + try { + const results = await Promise.all(deletePromises); + + const successfulIds = results + .filter(result => result.success) + .map(result => result.id); + + const hasError = results.some(result => !result.success); + + setTodos(prev => prev.filter(todo => !successfulIds.includes(todo.id))); + + if (hasError) { + throw new Error('Unable to delete a todo'); + } + } finally { + setDeletingIds([]); + setIsClearing(false); + } + }; + + const handleCompletedTodo = async (id: number, completed: boolean) => { + setCompletingIds(prev => Array.from(new Set([...prev, id]))); + + try { + const updatedTodo = await completeTodo(id, completed); + + setTodos(prev => prev.map(todo => (todo.id === id ? updatedTodo : todo))); + } catch (error) { + throw error; + } finally { + setCompletingIds(prev => prev.filter(todoId => todoId !== id)); + } + }; + + const handleToggleAllTodos = async (completed: boolean) => { + const todosToUpdate = todos.filter(todo => todo.completed !== completed); + + if (todosToUpdate.length === 0) { + return; + } + + const idsToUpdate = todosToUpdate.map(todo => todo.id); + + setCompletingIds(prev => Array.from(new Set([...prev, ...idsToUpdate]))); + + const updatePromises = todosToUpdate.map(todo => + completeTodo(todo.id, completed) + .then(updatedTodo => ({ id: todo.id, success: true, updatedTodo })) + .catch(() => ({ id: todo.id, success: false, updatedTodo: null })), + ); + + try { + const results = await Promise.all(updatePromises); + const successfulUpdates = results.filter( + (result): result is { id: number; success: true; updatedTodo: Todo } => + result.success && result.updatedTodo !== null, + ); + + const hasError = results.some(result => !result.success); + + setTodos(prev => + prev.map(todo => { + const updated = successfulUpdates.find( + result => result.id === todo.id, + ); + + return updated ? updated.updatedTodo : todo; + }), + ); + + if (hasError) { + throw new Error('Unable to update a todo'); + } + } finally { + setCompletingIds(prev => + prev.filter(todoId => !idsToUpdate.includes(todoId)), + ); + } + }; + + const handleStartEdit = (todo: Todo) => { + setEditingId(todo.id); + setEditingTitle(todo.title); + }; + + const handleCommitEdit = async ( + id: number, + title: string, + ): Promise => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return true; + } + + const originalTodo = todos.find(todo => todo.id === id); + + if (originalTodo?.title === trimmedTitle) { + setEditingId(null); + setEditingTitle(''); + + return false; + } + + setEditingIds(prev => [...prev, id]); + + try { + const updatedTodo = await updateTodoTitle(id, trimmedTitle); + + setTodos(prev => prev.map(todo => (todo.id === id ? updatedTodo : todo))); + setEditingId(null); + setEditingTitle(''); + } catch (error) { + throw error; + } finally { + setEditingIds(prev => prev.filter(todoId => todoId !== id)); + } + + return false; + }; + + const handleCancelEdit = () => { + setEditingId(null); + setEditingTitle(''); + }; + + return { + todos, + isAdding, + deletingIds, + tempTodo, + isClearing, + completingIds, + editingId, + editingTitle, + editingIds, + setEditingTitle, + handleAddTodo, + handleDeleteTodo, + handleCompletedTodo, + handleToggleAllTodos, + handleClearCompleted, + handleStartEdit, + handleCommitEdit, + handleCancelEdit, + }; +}; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..8dbcfb350d --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + EMPTY_TITLE = 'Title should not be empty', + ADD_TODO = 'Unable to add a todo', + UPDATE_TODO = 'Unable to update a todo', + DELETE_TODO = 'Unable to delete a todo', + LOAD_TODOS = 'Unable to load todos', +} 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..ccd85db2c7 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,41 @@ +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: unknown = 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: unknown) => request(url, 'POST', data), + patch: (url: string, data: unknown) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +}; diff --git a/src/utils/todoFilters.ts b/src/utils/todoFilters.ts new file mode 100644 index 0000000000..df6c8be459 --- /dev/null +++ b/src/utils/todoFilters.ts @@ -0,0 +1,26 @@ +import { Todo } from '../types/Todo'; + +export enum FilterType { + ALL = 'All', + ACTIVE = 'Active', + COMPLETED = 'Completed', +} + +export const filterTodos = ( + todos: Todo[], + filter: FilterType, + tempTodo: Todo | null = null, +): Todo[] => { + const filtered = (() => { + switch (filter) { + case FilterType.ACTIVE: + return todos.filter(todo => !todo.completed); + case FilterType.COMPLETED: + return todos.filter(todo => todo.completed); + default: + return todos; + } + })(); + + return tempTodo ? [...filtered, tempTodo] : filtered; +};