diff --git a/README.md b/README.md index 47a1add059..58d262fc0c 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://Banderos14.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..ae3de1105b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,344 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { + FormEvent, + KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { API_URL, ErrorMessage, RESPONSE_DELAY } from './constants/todos'; +import { ErrorNotification } from './components/ErrorNotification'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Filter } from './types/Filter'; +import { Todo } from './types/Todo'; +import { getUserId } from './utils/getUserId'; +import { request } from './utils/request'; +import { wait } from './utils/wait'; export const App: React.FC = () => { - if (!USER_ID) { + const userId = getUserId(); + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState('all'); + const [newTitle, setNewTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [isAdding, setIsAdding] = useState(false); + const [processingIds, setProcessingIds] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + + const errorTimerId = useRef | null>(null); + const newTodoField = useRef(null); + const editField = useRef(null); + + const showError = (message: ErrorMessage) => { + setErrorMessage(message); + + if (errorTimerId.current) { + clearTimeout(errorTimerId.current); + } + + errorTimerId.current = setTimeout(() => { + setErrorMessage(''); + }, 3000); + }; + + const hideError = () => { + setErrorMessage(''); + + if (errorTimerId.current) { + clearTimeout(errorTimerId.current); + } + }; + + const focusNewTodoField = () => { + setTimeout(() => newTodoField.current?.focus(), 0); + }; + + const addProcessingId = (id: number) => { + setProcessingIds(current => [...current, id]); + }; + + const removeProcessingId = (id: number) => { + setProcessingIds(current => current.filter(currentId => currentId !== id)); + }; + + const loadTodos = async () => { + try { + const loadedTodos = await request(`${API_URL}?userId=${userId}`); + + setTodos(loadedTodos); + } catch { + showError(ErrorMessage.Load); + } finally { + focusNewTodoField(); + } + }; + + useEffect(() => { + if (userId) { + loadTodos(); + } + + return () => { + if (errorTimerId.current) { + clearTimeout(errorTimerId.current); + } + }; + }, [userId]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (editingId !== null) { + editField.current?.focus(); + } + }, [editingId]); + + const visibleTodos = useMemo(() => { + switch (filter) { + case 'active': + return todos.filter(todo => !todo.completed); + + case 'completed': + return todos.filter(todo => todo.completed); + + default: + return todos; + } + }, [filter, todos]); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const completedTodosCount = todos.length - activeTodosCount; + const allTodosCompleted = todos.length > 0 && activeTodosCount === 0; + const shouldShowTempTodo = tempTodo && filter !== 'completed'; + + const createTodo = async (event: FormEvent) => { + event.preventDefault(); + hideError(); + + const title = newTitle.trim(); + + if (!title) { + showError(ErrorMessage.EmptyTitle); + focusNewTodoField(); + + return; + } + + const optimisticTodo: Todo = { + id: 0, + userId, + title, + completed: false, + }; + + setTempTodo(optimisticTodo); + setIsAdding(true); + + try { + const createdTodo = await request(API_URL, { + method: 'POST', + body: JSON.stringify({ + userId, + title, + completed: false, + }), + }); + + await wait(RESPONSE_DELAY); + setTodos(current => [...current, createdTodo]); + setNewTitle(''); + } catch { + showError(ErrorMessage.Add); + } finally { + setIsAdding(false); + setTempTodo(null); + focusNewTodoField(); + } + }; + + const deleteTodo = async (todoId: number, shouldFocusNewTodo = true) => { + hideError(); + addProcessingId(todoId); + + try { + await fetch(`${API_URL}/${todoId}`, { + method: 'DELETE', + }).then(response => { + if (!response.ok) { + throw new Error(String(response.status)); + } + }); + + await wait(RESPONSE_DELAY); + setTodos(current => current.filter(todo => todo.id !== todoId)); + + if (editingId === todoId) { + setEditingId(null); + } + } catch { + showError(ErrorMessage.Delete); + throw new Error(ErrorMessage.Delete); + } finally { + removeProcessingId(todoId); + if (shouldFocusNewTodo) { + focusNewTodoField(); + } + } + }; + + const updateTodo = async ( + todoId: number, + data: Partial>, + ) => { + hideError(); + addProcessingId(todoId); + + try { + const updatedTodo = await request(`${API_URL}/${todoId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + + await wait(RESPONSE_DELAY); + setTodos(current => + current.map(todo => + todo.id === todoId ? { ...todo, ...updatedTodo } : todo, + ), + ); + + return updatedTodo; + } catch { + showError(ErrorMessage.Update); + throw new Error(ErrorMessage.Update); + } finally { + removeProcessingId(todoId); + } + }; + + const toggleTodo = (todo: Todo) => { + updateTodo(todo.id, { completed: !todo.completed }).catch(() => {}); + }; + + const toggleAll = async () => { + const completed = !allTodosCompleted; + const todosToUpdate = todos.filter(todo => todo.completed !== completed); + + await Promise.all( + todosToUpdate.map(todo => + updateTodo(todo.id, { completed }).catch(() => undefined), + ), + ); + }; + + const deleteCompletedTodos = async () => { + await Promise.all( + todos + .filter(todo => todo.completed) + .map(todo => deleteTodo(todo.id).catch(() => undefined)), + ); + }; + + const startEditing = (todo: Todo) => { + setEditingId(todo.id); + setEditingTitle(todo.title); + }; + + const cancelEditing = () => { + setEditingId(null); + setEditingTitle(''); + }; + + const saveEditing = async (todo: Todo) => { + const title = editingTitle.trim(); + + if (title === todo.title) { + cancelEditing(); + + return; + } + + if (!title) { + try { + await deleteTodo(todo.id, false); + } catch { + return; + } + + return; + } + + try { + await updateTodo(todo.id, { title }); + cancelEditing(); + } catch { + editField.current?.focus(); + } + }; + + const handleEditSubmit = (event: FormEvent, todo: Todo) => { + event.preventDefault(); + saveEditing(todo); + }; + + const handleEditKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + cancelEditing(); + } + }; + + if (!userId) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {(todos.length > 0 || shouldShowTempTodo) && ( + deleteTodo(todoId).catch(() => {})} + onStartEditing={startEditing} + onEditingTitleChange={setEditingTitle} + onEditSubmit={handleEditSubmit} + onEditBlur={saveEditing} + onEditKeyUp={handleEditKeyUp} + /> + )} + + {todos.length > 0 && ( +
+ )} +
+ + +
); }; diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..af000de990 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + message: string; + onHide: () => void; +}; + +export const ErrorNotification: React.FC = ({ message, onHide }) => ( +
+
+); diff --git a/src/components/ErrorNotification/index.ts b/src/components/ErrorNotification/index.ts new file mode 100644 index 0000000000..8cb4787920 --- /dev/null +++ b/src/components/ErrorNotification/index.ts @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx new file mode 100644 index 0000000000..7704a80276 --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import classNames from 'classnames'; +import { + FILTER_DATA_CY, + FILTER_HREFS, + FILTER_TITLES, + FILTERS, +} from '../../constants/todos'; +import { Filter as FilterType } from '../../types/Filter'; + +type Props = { + selectedFilter: FilterType; + onFilterChange: (filter: FilterType) => void; +}; + +export const Filter: React.FC = ({ selectedFilter, onFilterChange }) => ( + +); diff --git a/src/components/Filter/index.ts b/src/components/Filter/index.ts new file mode 100644 index 0000000000..0eea77907f --- /dev/null +++ b/src/components/Filter/index.ts @@ -0,0 +1 @@ +export * from './Filter'; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..d3adc32409 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Filter } from '../Filter'; +import { Filter as FilterType } from '../../types/Filter'; + +type Props = { + activeTodosCount: number; + completedTodosCount: number; + selectedFilter: FilterType; + onFilterChange: (filter: FilterType) => void; + onDeleteCompletedTodos: () => void; +}; + +export const Footer: React.FC = ({ + activeTodosCount, + completedTodosCount, + selectedFilter, + onFilterChange, + onDeleteCompletedTodos, +}) => ( +
+ + {`${activeTodosCount} ${activeTodosCount === 1 ? 'item' : 'items'} left`} + + + + + +
+); diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 0000000000..ddcc5a9cd1 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..828011d3d8 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,50 @@ +import React, { FormEvent, RefObject } from 'react'; +import classNames from 'classnames'; + +type Props = { + todosCount: number; + allTodosCompleted: boolean; + newTitle: string; + isAdding: boolean; + newTodoField: RefObject; + onNewTitleChange: (title: string) => void; + onCreateTodo: (event: FormEvent) => void; + onToggleAll: () => void; +}; + +export const Header: React.FC = ({ + todosCount, + allTodosCompleted, + newTitle, + isAdding, + newTodoField, + onNewTitleChange, + onCreateTodo, + onToggleAll, +}) => ( +
+ {todosCount > 0 && ( +
+); diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 0000000000..266dec8a1b --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/TempTodo/TempTodo.tsx b/src/components/TempTodo/TempTodo.tsx new file mode 100644 index 0000000000..78177d5716 --- /dev/null +++ b/src/components/TempTodo/TempTodo.tsx @@ -0,0 +1,34 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ + +import React from 'react'; +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TempTodo: React.FC = ({ todo }) => ( +
+ + + + {todo.title} + + +
+
+
+
+
+); diff --git a/src/components/TempTodo/index.ts b/src/components/TempTodo/index.ts new file mode 100644 index 0000000000..85671cecaa --- /dev/null +++ b/src/components/TempTodo/index.ts @@ -0,0 +1 @@ +export * from './TempTodo'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..78c26d246a --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,98 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ + +import React, { FormEvent, KeyboardEvent, RefObject } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; + isEditing: boolean; + isProcessing: boolean; + editingTitle: string; + editField: RefObject; + onToggle: (todo: Todo) => void; + onDelete: (todoId: number) => void; + onStartEditing: (todo: Todo) => void; + onEditingTitleChange: (title: string) => void; + onEditSubmit: (event: FormEvent, todo: Todo) => void; + onEditBlur: (todo: Todo) => void; + onEditKeyUp: (event: KeyboardEvent) => void; +}; + +export const TodoItem: React.FC = ({ + todo, + isEditing, + isProcessing, + editingTitle, + editField, + onToggle, + onDelete, + onStartEditing, + onEditingTitleChange, + onEditSubmit, + onEditBlur, + onEditKeyUp, +}) => ( +
+ + + {isEditing ? ( +
onEditSubmit(event, todo)}> + onEditingTitleChange(event.target.value)} + onBlur={() => onEditBlur(todo)} + onKeyUp={onEditKeyUp} + /> +
+ ) : ( + <> + onStartEditing(todo)} + > + {todo.title} + + + + + )} + +
+
+
+
+
+); diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 0000000000..21f4abac39 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..8283d6f856 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,58 @@ +import React, { FormEvent, KeyboardEvent, RefObject } from 'react'; +import { Todo } from '../../types/Todo'; +import { TempTodo } from '../TempTodo'; +import { TodoItem } from '../TodoItem'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + editingId: number | null; + editingTitle: string; + processingIds: number[]; + editField: RefObject; + onToggleTodo: (todo: Todo) => void; + onDeleteTodo: (todoId: number) => void; + onStartEditing: (todo: Todo) => void; + onEditingTitleChange: (title: string) => void; + onEditSubmit: (event: FormEvent, todo: Todo) => void; + onEditBlur: (todo: Todo) => void; + onEditKeyUp: (event: KeyboardEvent) => void; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + editingId, + editingTitle, + processingIds, + editField, + onToggleTodo, + onDeleteTodo, + onStartEditing, + onEditingTitleChange, + onEditSubmit, + onEditBlur, + onEditKeyUp, +}) => ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && } +
+); diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/constants/todos.ts b/src/constants/todos.ts new file mode 100644 index 0000000000..811809b7aa --- /dev/null +++ b/src/constants/todos.ts @@ -0,0 +1,33 @@ +import { Filter } from '../types/Filter'; + +export const API_URL = 'https://mate.academy/students-api/todos'; +export const USER_ID = 4203; +export const RESPONSE_DELAY = 300; + +export enum ErrorMessage { + Load = 'Unable to load todos', + EmptyTitle = 'Title should not be empty', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', +} + +export const FILTERS: Filter[] = ['all', 'active', 'completed']; + +export const FILTER_TITLES: Record = { + all: 'All', + active: 'Active', + completed: 'Completed', +}; + +export const FILTER_DATA_CY: Record = { + all: 'FilterLinkAll', + active: 'FilterLinkActive', + completed: 'FilterLinkCompleted', +}; + +export const FILTER_HREFS: Record = { + all: '#/', + active: '#/active', + completed: '#/completed', +}; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..5d90d45a1b --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1 @@ +export type Filter = 'all' | 'active' | 'completed'; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..653c503ff5 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,8 @@ +export type Todo = { + id: number; + userId: number; + title: string; + completed: boolean; + createdAt?: string; + updatedAt?: string; +}; diff --git a/src/utils/getUserId.ts b/src/utils/getUserId.ts new file mode 100644 index 0000000000..ddeda543c1 --- /dev/null +++ b/src/utils/getUserId.ts @@ -0,0 +1,11 @@ +import { USER_ID } from '../constants/todos'; + +export const getUserId = () => { + try { + const user = localStorage.getItem('user'); + + return user ? Number(JSON.parse(user).id) : USER_ID; + } catch { + return USER_ID; + } +}; diff --git a/src/utils/request.ts b/src/utils/request.ts new file mode 100644 index 0000000000..881e62c7f5 --- /dev/null +++ b/src/utils/request.ts @@ -0,0 +1,18 @@ +export const request = async ( + url: string, + options?: RequestInit, +): Promise => { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + + if (!response.ok) { + throw new Error(String(response.status)); + } + + return response.json(); +}; diff --git a/src/utils/wait.ts b/src/utils/wait.ts new file mode 100644 index 0000000000..9bcc2dc714 --- /dev/null +++ b/src/utils/wait.ts @@ -0,0 +1,4 @@ +export const wait = (delay: number) => + new Promise(resolve => { + setTimeout(resolve, delay); + });