diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..2b5108bb47 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,232 @@ /* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; +import React, { useState, useEffect, useRef } from 'react'; +import * as todoService from './api/todos'; +import { Todo } from './types/Todo'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { ErrorNotification } from './components/ErrorNotification'; +import { FilterStatus } from './enums/FilterStatus'; -const USER_ID = 0; +enum ErrorMessages { + None = '', + Load = 'Unable to load todos', + Empty = '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 App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState(FilterStatus.All); + const [errorMessage, setErrorMessage] = useState( + ErrorMessages.None, + ); + const [query, setQuery] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [loadingIds, setLoadingIds] = useState([]); + + const itemField = useRef(null); + + useEffect(() => { + todoService + .getTodos() + .then(setTodos) + .catch(() => setErrorMessage(ErrorMessages.Load)) + .finally(() => { + itemField.current?.focus(); + }); + }, []); + + const visibleTodos = todos.filter(todo => { + if (filter === FilterStatus.Active) { + return !todo.completed; + } + + if (filter === FilterStatus.Completed) { + return todo.completed; + } + + return true; + }); + + const toggleTodoLocal = (id: number) => { + setTodos(currentTodos => + currentTodos.map(todo => { + if (todo.id === id) { + return { ...todo, completed: !todo.completed }; + } + + return todo; + }), + ); + }; + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + + const hasCompleted = todos.some(todo => todo.completed); + + const deleteTodo = (todoId: number) => { + setLoadingIds(prev => [...prev, todoId]); + todoService + .deleteTodo(todoId) + .then(() => + setTodos(current => current.filter(todo => todo.id !== todoId)), + ) + .catch(() => { + setErrorMessage(ErrorMessages.Delete); + setTimeout(() => setErrorMessage(ErrorMessages.None), 3000); + }) + .finally(() => { + setLoadingIds(prev => prev.filter(id => id !== todoId)); + itemField.current?.focus(); + }); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmedTitle = query.trim(); + + if (!trimmedTitle) { + setErrorMessage(ErrorMessages.Empty); + setTimeout(() => setErrorMessage(ErrorMessages.None), 3000); + + return; + } + + setTempTodo({ id: 0, userId: 4057, title: trimmedTitle, completed: false }); + + todoService + .createTodo({ userId: 4057, title: trimmedTitle, completed: false }) + .then(newTodo => { + setTodos(prev => [...prev, newTodo]); + setQuery(''); + }) + .catch(() => { + setErrorMessage(ErrorMessages.Add); + setTimeout(() => setErrorMessage(ErrorMessages.None), 3000); + }) + .finally(() => { + setTempTodo(null); + setTimeout(() => { + itemField.current?.focus(); + }, 0); + }); + }; + + const clearCompleted = async () => { + const completedTodos = todos.filter(todo => todo.completed); + + const deletionPromises = completedTodos.map(todo => { + return todoService + .deleteTodo(todo.id) + .then(() => { + setTodos(currentTodos => currentTodos.filter(t => t.id !== todo.id)); + }) + .catch(() => { + setErrorMessage(ErrorMessages.Delete); + setTimeout(() => setErrorMessage(ErrorMessages.None), 3000); + }); + }); + + await Promise.all(deletionPromises); + + itemField.current?.focus(); + }; + + const handleUpdateTodo = ({ id, title, completed }: Todo) => { + setLoadingIds(prev => [...prev, id]); + + return todoService + .updateTodo({ id, title, completed }) + .then(updatedTodo => { + setTodos(current => + current.map(todo => (todo.id === id ? updatedTodo : todo)), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessages.Update); + setTimeout(() => setErrorMessage(ErrorMessages.None), 3000); + throw new Error(); + }) + .finally(() => { + setLoadingIds(prev => prev.filter(loadingId => loadingId !== id)); + }); + }; + + const toggleAll = async () => { + const areAllCompleted = todos.every(todo => todo.completed); + const newStatus = !areAllCompleted; + + const todosToUpdate = todos.filter(todo => todo.completed !== newStatus); + const idsToUpdate = todosToUpdate.map(t => t.id); + + setLoadingIds(prev => [...prev, ...idsToUpdate]); + + try { + await Promise.all( + todosToUpdate.map(todo => + todoService.updateTodo({ ...todo, completed: newStatus }), + ), + ); + + setTodos(current => + current.map(todo => + idsToUpdate.includes(todo.id) + ? { ...todo, completed: newStatus } + : todo, + ), + ); + } catch { + setErrorMessage(ErrorMessages.Update); + setTimeout(() => setErrorMessage(ErrorMessages.None), 3000); + } + }; return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+
+
0} + onToggleAll={toggleAll} + isAllCompleted={ + todos.length > 0 && todos.every(todo => todo.completed) + } + /> + + {(todos.length > 0 || tempTodo) && ( + <> + +
+ + )} +
+ + setErrorMessage(ErrorMessages.None)} + /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..e2824abf83 --- /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 = 4057; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (todo: Omit) => { + return client.post('/todos', todo); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = ({ id, title, completed }: Todo) => { + return client.patch(`/todos/${id}`, { title, completed }); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..82e4a1634e --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react'; +import cn from 'classnames'; + +type Props = { + message: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ message, onClose }) => { + useEffect(() => { + if (!message) { + return; + } + + const timer = setTimeout(() => { + onClose(); + }, 3000); + + return () => clearTimeout(timer); + }, [message, onClose]); + + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..b13e310ca5 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import cn from 'classnames'; +import { FilterStatus } from '../enums/FilterStatus'; + +interface Props { + filter: FilterStatus; + setFilter: (filter: FilterStatus) => void; + activeCount: number; + onClearCompleted: () => void; + hasCompleted: boolean; +} + +export const Footer: React.FC = ({ + filter, + setFilter, + activeCount, + onClearCompleted, + hasCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..176bc94df2 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import cn from 'classnames'; + +type Props = { + todoFieldRef: React.RefObject; + query: string; + setQuery: (value: string) => void; + onSubmit: (event: React.FormEvent) => void; + disabled: boolean; + onToggleAll: () => void; + isAllCompleted: boolean; + hasTodos: boolean; +}; + +export const Header: React.FC = ({ + todoFieldRef, + query, + setQuery, + onSubmit, + disabled, + onToggleAll, + isAllCompleted, + hasTodos, +}) => { + return ( +
+ {hasTodos && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..71cf885ccb --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,119 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useState, useRef, useEffect } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; + +interface Props { + todo: Todo; + onDelete: (id: number) => void; + isLoading: boolean; + onUpdate: (todo: Todo) => Promise; +} + +export const TodoItem: React.FC = ({ + todo, + onDelete, + isLoading, + onUpdate, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + const editFieldRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editFieldRef.current?.focus(); + } + }, [isEditing]); + + const handleSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + const trimmedTitle = newTitle.trim(); + + if (trimmedTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (!trimmedTitle) { + onDelete(todo.id); + + return; + } + + onUpdate({ ...todo, title: trimmedTitle }) + .then(() => setIsEditing(false)) + .catch(() => { + editFieldRef.current?.focus(); + }); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setNewTitle(todo.title); + } + }; + + return ( +
+ + + {isEditing ? ( +
+ setNewTitle(e.target.value)} + onBlur={handleSubmit} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..b413ea3768 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +interface Props { + todos: Todo[]; + onDelete: (id: number) => void; + loadingIds: number[]; + tempTodo: Todo | null; + onToggle: (id: number) => void; + onUpdate: (todo: Todo) => Promise; +} + +export const TodoList: React.FC = ({ + todos, + onDelete, + loadingIds, + tempTodo, + onToggle, + onUpdate, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + {tempTodo && ( + {}} + onToggle={() => {}} + onUpdate={() => Promise.resolve()} + /> + )} +
+ ); +}; diff --git a/src/enums/FilterStatus.ts b/src/enums/FilterStatus.ts new file mode 100644 index 0000000000..a5d0643a05 --- /dev/null +++ b/src/enums/FilterStatus.ts @@ -0,0 +1,6 @@ + +export enum FilterStatus { + 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..b995721f0b --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,39 @@ +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'; + +async function request( + url: string, + method: RequestMethod = 'GET', + data: unknown = null, +): Promise { + const options: RequestInit = { method }; + + if (data !== null) { + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + await wait(100); + + const response = await fetch(BASE_URL + url, options); + + 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'), +};