diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..a483c459a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,366 @@ /* 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 classNames from 'classnames'; import { UserWarning } from './UserWarning'; +import { Todo } from './types/Todo'; +import * as todoService from './api/todos'; -const USER_ID = 0; +const USER_ID = todoService.USER_ID || 0; + +type Filter = 'all' | 'active' | 'completed'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState('all'); + const [errorMessage, setErrorMessage] = useState(''); + const [newTitle, setNewTitle] = useState(''); + const [isAdding, setIsAdding] = useState(false); + const [loadingIds, setLoadingIds] = useState([]); + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + + const inputRef = useRef(null); + + const showError = (message: string) => { + setErrorMessage(message); + setTimeout(() => setErrorMessage(''), 3000); + }; + + useEffect(() => { + todoService + .getTodos() + .then(setTodos) + .catch(() => showError('Unable to load todos')); + }, []); + + useEffect(() => { + if (!isAdding && editingId === null) { + inputRef.current?.focus(); + } + }, [todos.length, errorMessage, isAdding, editingId]); + + const completedTodos = useMemo(() => todos.filter(t => t.completed), [todos]); + const activeCount = todos.length - completedTodos.length; + + const visibleTodos = useMemo(() => { + switch (filter) { + case 'active': + return todos.filter(t => !t.completed); + case 'completed': + return todos.filter(t => t.completed); + default: + return todos; + } + }, [todos, filter]); + + const toggleTodo = (todo: Todo) => { + setLoadingIds(prev => [...prev, todo.id]); + + todoService + .updateTodo({ + ...todo, + completed: !todo.completed, + }) + .then(updated => { + setTodos(prev => prev.map(t => (t.id === todo.id ? updated : t))); + }) + .catch(() => showError('Unable to update a todo')) + .finally(() => { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + }); + }; + + const removeTodo = (id: number) => { + setLoadingIds(prev => [...prev, id]); + + todoService + .deleteTodo(id) + .then(() => { + setTodos(prev => prev.filter(t => t.id !== id)); + }) + .catch(() => showError('Unable to delete a todo')) + .finally(() => { + setLoadingIds(prev => prev.filter(x => x !== id)); + }); + }; + + const updateTitle = (todo: Todo) => { + const trimmedTitle = editTitle.trim(); + + if (trimmedTitle === todo.title) { + setEditingId(null); + + return; + } + + if (!trimmedTitle) { + removeTodo(todo.id); + + return; + } + + setLoadingIds(prev => [...prev, todo.id]); + + todoService + .updateTodo({ ...todo, title: trimmedTitle }) + .then(updated => { + setTodos(prev => prev.map(t => (t.id === todo.id ? updated : t))); + setEditingId(null); + }) + .catch(() => showError('Unable to update a todo')) + .finally(() => { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + }); + }; + + const toggleAll = () => { + const newStatus = activeCount > 0; + const todosToUpdate = todos.filter(t => t.completed !== newStatus); + + todosToUpdate.forEach(toggleTodo); + }; + + const clearCompleted = () => { + completedTodos.forEach(todo => removeTodo(todo.id)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = newTitle.trim(); + + if (!trimmed) { + showError('Title should not be empty'); + + return; + } + + setIsAdding(true); + + todoService + .createTodo({ + title: trimmed, + completed: false, + userId: USER_ID, + }) + .then(created => { + setTodos(prev => [...prev, created]); + setNewTitle(''); + }) + .catch(() => showError('Unable to add a todo')) + .finally(() => setIsAdding(false)); + }; + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {todos.length > 0 && ( +
+ +
+ {visibleTodos.map(todo => { + const isLoading = loadingIds.includes(todo.id); + const isEditing = editingId === todo.id; + const todoId = `todo-status-${todo.id}`; + + return ( +
+ + + {isEditing ? ( +
{ + e.preventDefault(); + updateTitle(todo); + }} + > + setEditTitle(e.target.value)} + onBlur={() => updateTitle(todo)} + onKeyUp={e => { + if (e.key === 'Escape') { + setEditingId(null); + } + }} + /> +
+ ) : ( + <> + { + setEditingId(todo.id); + setEditTitle(todo.title); + }} + > + {todo.title} + + + + )} + +
+
+
+
+
+ ); + })} + + {isAdding && ( +
+ + + {newTitle} + +
+
+
+
+
+ )} +
+ + {todos.length > 0 && ( + + )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..d7200b3dc5 --- /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 = 1; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (data: Omit) => { + return client.post('/todos', data); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = ({ id, ...data }: Todo) => { + return client.patch(`/todos/${id}`, data); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..1e6bc8fadb --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import classNames from 'classnames'; +import { ErrorMessage } from '../types/ErrorMessage'; + +interface Props { + error: ErrorMessage; + onClose: () => void; +} + +export const ErrorNotification: React.FC = ({ error, onClose }) => ( +
+
+); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..3e3f14f3f0 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import classNames from 'classnames'; +import { FilterStatus } from '../types/FilterStatus'; + +interface Props { + activeCount: number; + filter: FilterStatus; + setFilter: (f: FilterStatus) => void; + hasCompleted: boolean; + onClearCompleted: () => void; +} + +export const Footer: React.FC = ({ + activeCount, + filter, + setFilter, + hasCompleted, + onClearCompleted, +}) => ( + +); diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..4d36062a61 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import classNames from 'classnames'; + +interface Props { + newTitle: string; + setNewTitle: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + isAdding: boolean; + todosCount: number; + activeCount: number; + inputRef: React.RefObject; + onToggleAll: () => void; +} + +export const Header: React.FC = ({ + newTitle, + setNewTitle, + onSubmit, + isAdding, + todosCount, + activeCount, + inputRef, + onToggleAll, +}) => ( +
+

todos

+ + {todosCount > 0 && ( +
+); diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..81715f1a8a --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,121 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useState, useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +interface Props { + todo: Todo; + isLoading?: boolean; + onDelete: (id: number) => void; + onUpdate: (data: Partial) => Promise; +} + +export const TodoItem: React.FC = ({ + todo, + isLoading = false, + onDelete, + onUpdate, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + const editInputRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editInputRef.current?.focus(); + } + }, [isEditing]); + + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + const trimmedTitle = newTitle.trim(); + + if (trimmedTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (!trimmedTitle) { + onDelete(todo.id); + + return; + } + + try { + await onUpdate({ id: todo.id, title: trimmedTitle }); + setIsEditing(false); + } catch { + editInputRef.current?.focus(); + } + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.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..f738a77c0b --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +interface Props { + todos: Todo[]; + tempTodo: Todo | null; + loadingIds: number[]; + onDelete: (id: number) => void; + onUpdate: (data: Partial) => Promise; +} + +export const TodoList: React.FC = ({ + todos, + tempTodo, + loadingIds, + onDelete, + onUpdate, +}) => ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && ( + {}} + onUpdate={async () => {}} + /> + )} +
+); diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..c19681b35e --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,8 @@ +export enum ErrorMessage { + NONE = '', + UNABLE_TO_LOAD = 'Unable to load todos', + EMPTY_TITLE = 'Title should not be empty', + UNABLE_TO_ADD = 'Unable to add a todo', + UNABLE_TO_DELETE = 'Unable to delete a todo', + UNABLE_TO_UPDATE = 'Unable to update a todo', +} diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts new file mode 100644 index 0000000000..df729538c3 --- /dev/null +++ b/src/types/FilterStatus.ts @@ -0,0 +1 @@ +export type FilterStatus = 'all' | 'active' | '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..982fb72807 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,41 @@ +const BASE_URL = 'https://mate.academy/students-api'; + +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +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(300) + .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'), +};