diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..c72c9d7df0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,293 @@ -/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { UserWarning } from './UserWarning'; +import { USER_ID } from './api/todos'; +import * as todoService from './api/todos'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +import { Header } from './component/Header'; +import { TodoList } from './component/TodoList'; +import { Footer } from './component/Footer'; +import { ErrorNotification } from './component/Error'; + +export enum ErrorText { + EmptyTitle = 'Title should not be empty', + AddFailed = 'Unable to add a todo', + UpdateFailed = 'Unable to update a todo', + DeleteFailed = 'Unable to delete a todo', + LoadFailed = 'Unable to load todos', +} + +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} + +export type FilterType = Filter.All | Filter.Active | Filter.Completed; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [isLoadingTodos, setIsLoadingTodos] = useState(true); + const [error, setError] = useState(''); + const [filter, setFilter] = useState(Filter.All); + const [newTitle, setNewTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [isAdding, setIsAdding] = useState(false); + + const inputRef = useRef(null); + + const startLoading = (id: number) => { + setLoadingTodoIds(prev => [...prev, id]); + }; + + const stopLoading = (id: number) => { + setLoadingTodoIds(prev => prev.filter(todoId => todoId !== id)); + }; + + const handleAddTodo = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = newTitle.trim(); + + if (!trimmed) { + setError(ErrorText.EmptyTitle); + inputRef.current?.focus(); + + return; + } + + const temp: Todo = { + id: 0, + userId: USER_ID, + title: trimmed, + completed: false, + }; + + setTempTodo(temp); + setIsAdding(true); + + setError(''); + + try { + const newTodo = await todoService.postTodos({ + title: trimmed, + userId: USER_ID, + }); + + setTodos(prev => [...prev, newTodo]); + setNewTitle(''); + inputRef.current?.focus(); + } catch { + setError(ErrorText.AddFailed); + inputRef.current?.focus(); + } finally { + setIsAdding(false); + setTempTodo(null); + } + }; + + useEffect(() => { + if (!isAdding && tempTodo === null) { + inputRef.current?.focus(); + } + }, [isAdding, tempTodo]); + + const toggleTodo = async (todo: Todo) => { + startLoading(todo.id); + setError(''); + + try { + await todoService.patchTodo(todo.id, { completed: !todo.completed }); + setTodos(prev => + prev.map(existingTodo => + existingTodo.id === todo.id + ? { ...existingTodo, completed: !existingTodo.completed } + : existingTodo, + ), + ); + } catch { + setError(ErrorText.UpdateFailed); + } finally { + stopLoading(todo.id); + } + }; + + const toggleAll = async () => { + setError(''); + + const areAllCompleted = todos.every(todo => todo.completed); + const todosToUpdate = areAllCompleted + ? todos + : todos.filter(todo => !todo.completed); + + await Promise.all( + todosToUpdate.map(async todo => { + startLoading(todo.id); + + try { + await todoService.patchTodo(todo.id, { + completed: areAllCompleted ? false : true, + }); + setTodos(prev => + prev.map(t => + t.id === todo.id + ? { ...t, completed: areAllCompleted ? false : true } + : t, + ), + ); + } catch { + setError(ErrorText.UpdateFailed); + } finally { + stopLoading(todo.id); + } + }), + ); + }; + + const handleUpdateTodo = async (edited: Todo): Promise => { + startLoading(edited.id); + setError(''); + + try { + const updatedTodo = await todoService.patchTodo(edited.id, { + title: edited.title, + }); + + setTodos(prev => + prev.map(t => (t.id === updatedTodo.id ? updatedTodo : t)), + ); + + return true; + } catch { + setError(ErrorText.UpdateFailed); + + return false; + } finally { + stopLoading(edited.id); + } + }; + + const clearCompleted = async () => { + setError(''); + + const completed = todos.filter(t => t.completed); + + await Promise.allSettled( + completed.map(async todo => { + startLoading(todo.id); + + try { + await todoService.deleteTodo(todo.id); + setTodos(prev => prev.filter(t => t.id !== todo.id)); + } catch { + setError(ErrorText.DeleteFailed); + } finally { + stopLoading(todo.id); + } + }), + ); + inputRef.current?.focus(); + }; + + const handleDeleteTodo = async (todoId: number) => { + startLoading(todoId); + setError(''); + try { + await todoService.deleteTodo(todoId); + setTodos(prev => prev.filter(t => t.id !== todoId)); + inputRef.current?.focus(); + + return true; + } catch { + setError(ErrorText.DeleteFailed); + + return false; + } finally { + stopLoading(todoId); + } + }; + + const filterTodos = todos.filter(todo => { + if (filter === Filter.Active) { + return !todo.completed; + } + + if (filter === Filter.Completed) { + return todo.completed; + } + + return true; + }); + + useEffect(() => { + inputRef.current?.focus(); + setIsLoadingTodos(true); + setError(''); + todoService + .getTodos() + .then(fetchedTodos => { + setTodos(fetchedTodos); + }) + .catch(() => { + setError(ErrorText.LoadFailed); + }) + .finally(() => { + setIsLoadingTodos(false); + }); + }, []); + + useEffect(() => { + if (!error) { + return; + } + + const timer = setTimeout(() => { + setError(''); + }, 3000); + + return () => clearTimeout(timer); + }, [error]); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + +
+
+ + setError('')} /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..798339a09d --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,24 @@ +import { Todo } from '../types/Todo'; +import { client } from '../ulits/fetchClient'; + +export const USER_ID = 3968; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here +export const postTodos = ({ + title, + userId, +}: Omit) => { + return client.post(`/todos`, { title, userId, completed: false }); +}; + +export const patchTodo = (id: number, data: Partial) => { + return client.patch(`/todos/${id}`, data); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; diff --git a/src/component/Error.tsx b/src/component/Error.tsx new file mode 100644 index 0000000000..4480a30602 --- /dev/null +++ b/src/component/Error.tsx @@ -0,0 +1,31 @@ +import classNames from 'classnames'; +import React from 'react'; +type Props = { + error: string; + onErrorDeleat: () => void; +}; + +export const ErrorNotification: React.FC = ({ + error, + onErrorDeleat, +}) => { + return ( + <> +
+
+ + ); +}; diff --git a/src/component/Footer.tsx b/src/component/Footer.tsx new file mode 100644 index 0000000000..a49916ac3b --- /dev/null +++ b/src/component/Footer.tsx @@ -0,0 +1,57 @@ +import classNames from 'classnames'; +import React from 'react'; +import { Todo } from '../types/Todo'; +import { Filter, FilterType } from '../App'; +type Props = { + todos: Todo[]; + filter: FilterType; + onFilterChange: (filter: Filter) => void; + onClearCompleted: () => void; +}; + +export const Footer: React.FC = ({ + todos, + filter, + onFilterChange, + onClearCompleted, +}) => { + const filtersValues = Object.values(Filter); + + return ( + <> + {todos.length > 0 && ( + + )} + + ); +}; diff --git a/src/component/Header.tsx b/src/component/Header.tsx new file mode 100644 index 0000000000..051ad94e4a --- /dev/null +++ b/src/component/Header.tsx @@ -0,0 +1,58 @@ +import classNames from 'classnames'; +import React from 'react'; +import { Todo } from '../types/Todo'; +type Props = { + todos: Todo[]; + newTitle: string; + isAdding: boolean; + isLoadingTodos: boolean; + inputRef: React.RefObject; + toggleAll: () => void; + onSubmit: (e: React.FormEvent) => void; + onTitleChange: (value: string) => void; +}; + +export const Header: React.FC = ({ + todos, + newTitle, + isAdding, + isLoadingTodos, + inputRef, + toggleAll, + onSubmit, + onTitleChange, +}) => { + const handleTitleChange = (e: React.ChangeEvent) => { + onTitleChange(e.target.value); + }; + + return ( + <> +
+ {!isLoadingTodos && todos.length > 0 && ( +
+ + ); +}; diff --git a/src/component/TodoList.tsx b/src/component/TodoList.tsx new file mode 100644 index 0000000000..6c7fe141c8 --- /dev/null +++ b/src/component/TodoList.tsx @@ -0,0 +1,158 @@ +import classNames from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + loadingTodoIds: number[]; + onToggledTodo: (todo: Todo) => void; + onDelete: (id: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + loadingTodoIds, + onToggledTodo, + onDelete, + onUpdateTodo, +}) => { + const [editingTodo, setEditingTodo] = useState(null); + const visibleTodos = [...todos, ...(tempTodo ? [tempTodo] : [])]; + const handleChangeTitle = (e: React.ChangeEvent) => { + setEditingTodo(prev => (prev ? { ...prev, title: e.target.value } : prev)); + }; + + const inputRef = useRef(null); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setEditingTodo(null); + } + + if (e.key === 'Enter') { + if (editingTodo === null) { + return; + } + + inputRef.current?.blur(); + } + }; + + const handleSaveEdited = async () => { + if (!editingTodo) { + return; + } + + if (loadingTodoIds.includes(editingTodo.id)) { + return; + } + + const original = todos.find(t => t.id === editingTodo.id); + + if (!original) { + setEditingTodo(null); + + return; + } + + const newTitle = editingTodo.title.trim(); + + if (newTitle === original.title) { + setEditingTodo(null); + + return; + } + + if (newTitle === '') { + const success = await onDelete(editingTodo.id); + + if (success) { + setEditingTodo(null); + } + + return; + } + + const success = await onUpdateTodo({ + ...editingTodo, + title: newTitle, + }); + + if (success) { + setEditingTodo(null); + } + }; + + useEffect(() => { + if (editingTodo) { + inputRef.current?.focus(); + } + }, [editingTodo]); + + return ( + <> +
+ {visibleTodos.map(todo => ( +
+ + {editingTodo?.id === todo.id ? ( + + ) : ( + <> + setEditingTodo({ ...todo })} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ))} +
+ + ); +}; 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/ulits/fetchClient.ts b/src/ulits/fetchClient.ts new file mode 100644 index 0000000000..ed09fc6ecc --- /dev/null +++ b/src/ulits/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(`${response.status} ${response.statusText}`); + } + + 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'), +};