diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..04512b7508 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,263 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; + +import React, { useEffect, useMemo, useRef } from 'react'; + import { UserWarning } from './UserWarning'; +import { USER_ID } from './api/todos'; +import { Todo } from './types/Todo'; +import { getTodos, addTodo, deleteTodo, updateTodo } from './api/todos'; -const USER_ID = 0; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { ErrorNotification } from './components/ErrorNotification'; +import { Filter } from './types/Filter'; +import { ErrorMessage } from './types/ErrorMessage'; export const App: React.FC = () => { + const [todos, setTodos] = React.useState([]); + const [error, setError] = React.useState(null); + const [newTitle, setNewTitle] = React.useState(''); + const [tempTodo, setTempTodo] = React.useState(null); + const [isAdding, setIsAdding] = React.useState(false); + const [deleteIds, setDeleteIds] = React.useState([]); + const [filter, setFilter] = React.useState(Filter.All); + const [loadingIds, setLoadingIds] = React.useState([]); + + const inputRef = useRef(null); + + const loadTodos = async () => { + try { + const data = await getTodos(); + + setTodos(data); + } catch { + setTodos([]); + setError(ErrorMessage.Load); + } + }; + + useEffect(() => { + setTimeout(loadTodos, 0); + }, []); + + useEffect(() => { + if (!error) { + return; + } + + const timer = setTimeout(() => setError(null), 3000); + + return () => clearTimeout(timer); + }, [error]); + + useEffect(() => { + inputRef.current?.focus(); + }); + + const activeCount = todos.filter(t => !t.completed).length; + const hasCompleted = todos.some(t => t.completed); + const allCompleted = todos.length > 0 && activeCount === 0; + + const visibleTodos = useMemo(() => { + switch (filter) { + case Filter.Active: + return todos.filter(t => !t.completed); + case Filter.Completed: + return todos.filter(t => t.completed); + default: + return todos; + } + }, [todos, filter]); + + const handleAddTodo = async (e: React.FormEvent) => { + e.preventDefault(); + + const trimmed = newTitle.trim(); + + if (!trimmed) { + setError(ErrorMessage.EmptyTitle); + + return; + } + + const temp: Todo = { + id: 0, + title: trimmed, + completed: false, + userId: USER_ID, + }; + + setTempTodo(temp); + setIsAdding(true); + + try { + const created = await addTodo({ + title: trimmed, + completed: false, + userId: USER_ID, + }); + + setTodos(prev => [...prev, created]); + setNewTitle(''); + } catch { + setError(ErrorMessage.Add); + } finally { + setTempTodo(null); + setIsAdding(false); + } + }; + + const handleDeleteTodo = async (id: number) => { + setDeleteIds(prev => [...prev, id]); + + try { + await deleteTodo(id); + setTodos(prev => prev.filter(t => t.id !== id)); + } catch { + setError(ErrorMessage.Delete); + } finally { + setDeleteIds(prev => prev.filter(x => x !== id)); + } + }; + + const handleClearCompleted = async () => { + const completed = todos.filter(t => t.completed); + + const results = await Promise.allSettled( + completed.map(t => deleteTodo(t.id)), + ); + + const okIds: number[] = []; + + results.forEach((r, i) => { + if (r.status === 'fulfilled') { + okIds.push(completed[i].id); + } + }); + + setTodos(prev => prev.filter(t => !okIds.includes(t.id))); + + if (results.some(r => r.status === 'rejected')) { + setError(ErrorMessage.Delete); + } + }; + + const handleToggleTodo = 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 { + setError(ErrorMessage.Update); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + const handleToggleAll = async () => { + const newStatus = !allCompleted; + + const toUpdate = todos.filter(t => t.completed !== newStatus); + + await Promise.all( + toUpdate.map(async todo => { + setLoadingIds(prev => [...prev, todo.id]); + + try { + const updated = await updateTodo(todo.id, { + completed: newStatus, + }); + + setTodos(prev => prev.map(t => (t.id === todo.id ? updated : t))); + } catch { + setError(ErrorMessage.Update); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + }), + ); + }; + if (!USER_ID) { return ; } + const handleRename = async (todo: Todo, update: string) => { + const trimmed = update.trim(); + + if (trimmed === todo.title) { + return; + } + + if (!trimmed) { + await handleDeleteTodo(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))); + } catch { + setError(ErrorMessage.Update); + + throw new Error('Unable to update'); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
0} + onToggleAll={handleToggleAll} + /> + + + + {todos.length > 0 && ( +
+ )} +
+ + setError(null)} /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..9da09a5d52 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,23 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4139; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (todo: Omit) => { + return client.post('/todos', { + ...todo, + userId: USER_ID, + }); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (id: number, updates: Partial>) => { + return client.patch(`/todos/${id}`, updates); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..2b2520905e --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +type Props = { + error: string | null; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ error, onClose }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..f3cccb54f3 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Filter } from '../types/Filter'; + +type Props = { + activeCount: number; + filter: Filter; + setFilter: (value: Filter) => void; + hasCompleted: boolean; + onClearCompleted: () => void; +}; + +const filters = [ + { label: 'All', value: Filter.All, href: '#/' }, + { label: 'Active', value: Filter.Active, href: '#/active' }, + { label: 'Completed', value: Filter.Completed, href: '#/completed' }, +]; + +export const Footer: React.FC = ({ + activeCount, + filter, + setFilter, + hasCompleted, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..deeb5e5354 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +type Props = { + newTitle: string; + setNewTitle: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + isAdding: boolean; + inputRef: React.RefObject; + allCompleted: boolean; + hasTodos: boolean; + onToggleAll?: () => void; +}; + +export const Header: React.FC = ({ + newTitle, + setNewTitle, + onSubmit, + isAdding, + inputRef, + allCompleted, + hasTodos, + onToggleAll, +}) => { + return ( +
+ {hasTodos && ( +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..9ee58cf8fb --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,180 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React from 'react'; +import { Todo } from '../types/Todo'; + +type Props = { + todos: Todo[]; + deleteIds: number[]; + onDelete: (id: number) => Promise; + tempTodo: Todo | null; + isAdding: boolean; + loadingIds: number[]; + onToggle: (todo: Todo) => void; + onRename: (todo: Todo, newTitle: string) => Promise; +}; + +export const TodoList: React.FC = ({ + todos, + deleteIds, + onDelete, + tempTodo, + isAdding, + loadingIds, + onToggle, + onRename, +}) => { + const [editingId, setEditingId] = React.useState(null); + const [editedTitle, setEditedTitle] = React.useState(''); + + const handleEditStart = (todo: Todo) => { + setEditingId(todo.id); + setEditedTitle(todo.title); + }; + + const handleEditCancel = (todo: Todo) => { + setEditedTitle(todo.title); + setEditingId(null); + }; + + const handleEditSubmit = async (todo: Todo) => { + const trimmed = editedTitle.trim(); + + if (trimmed === todo.title) { + setEditingId(null); + + return; + } + + if (!trimmed) { + try { + await onDelete(todo.id); + } catch { + // + } + + return; + } + + try { + await onRename(todo, trimmed); + + setEditingId(null); + } catch { + // keep opened + } + }; + + const handleEditKeyUp = ( + e: React.KeyboardEvent, + todo: Todo, + ) => { + if (e.key === 'Escape') { + handleEditCancel(todo); + } + + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } + }; + + return ( +
+ {todos.map(todo => ( +
+ + + {editingId === todo.id ? ( + setEditedTitle(e.target.value)} + onBlur={() => handleEditSubmit(todo)} + onKeyUp={e => handleEditKeyUp(e, todo)} + autoFocus + /> + ) : ( + handleEditStart(todo)} + > + {todo.title} + + )} + + {editingId !== todo.id && ( + + )} + +
+
+
+
+
+ ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..ec07b7f3d3 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + Load = 'Unable to load todos', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + EmptyTitle = 'Title should not be empty', + Update = 'Unable to update a todo', +} diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..174408fd69 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + 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..708ac4c17b --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; + +// returns a promise resolved after a given delay +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +// To have autocompletion and avoid mistypes +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, // we can send any data to the server +): Promise { + const options: RequestInit = { method }; + + if (data) { + // We add body and Content-Type only for the requests with data + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + // DON'T change the delay it is required for tests + 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'), +};