diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..1b1d222259 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,311 @@ -/* 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 { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import React, { FormEvent, useEffect, useRef, useState } from 'react'; +// import { UserWarning } from './UserWarning'; +import { + deleteTodos, + editTodos, + getTodos, + postTodos, + USER_ID, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { Footer } from './Footer'; +import { TodoList } from './TodoList'; +import classNames from 'classnames'; +import { Filters } from './types/Filters'; +import { TodoItem } from './TodoItem'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [deletingTodoId, setDeletingTodoId] = useState(null); + const [error, setError] = useState(null); + const [addTodoTitle, setAddTodoTitle] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [filterSelected, setFilterSelected] = useState(Filters.All); + const [tempTodo, setTempTodo] = useState(null); + const [updatingStatusIds, setUpdatingStatusIds] = useState>( + new Set(), + ); + const [clearingCompletedIds, setClearingCompletedIds] = useState>( + new Set(), + ); + + const [allCompleted, setAllCompleted] = useState(false); + + const ref = useRef(null); + + const updateTodos = (todoId, title) => { + // prettier-ignore + setTodos(prevState => + title === null + ? prevState.filter(todo => todo.id !== todoId) + : prevState.map(todo => todoId === todo.id + ? { ...todo, title: title } : todo,), + ); + }; + + const handleDelete = async (todoId: number) => { + setDeletingTodoId(todoId); + try { + await deleteTodos(todoId); + updateTodos(todoId, null); + ref.current.focus(); + } catch (Error) { + setError('Unable to delete a todo'); + } + }; + + const updateStatus = (todoId, completed) => { + // prettier-ignore + setTodos(prevState => + prevState.map(todo => todoId === todo.id + ? { ...todo, completed: completed } : todo,), + ); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const trimmedTitle = addTodoTitle.trim(); + + if (!trimmedTitle) { + setError('Title should not be empty'); + + return; + } + + setTempTodo({ + id: 0, + title: trimmedTitle, + completed: false, + userId: USER_ID, + }); + + setIsSubmitting(true); + try { + const newTodo = { + title: trimmedTitle, + completed: false, + userId: USER_ID, + }; + const createdTodo = await postTodos(newTodo); + + setTodos(prevState => [...prevState, createdTodo]); + setTempTodo(null); + setAddTodoTitle(''); + } catch { + setError('Unable to add a todo'); + + setTempTodo(null); + } finally { + setIsSubmitting(false); + } + }; + + const handleStatus = async (todoId, completed) => { + setUpdatingStatusIds(prev => new Set(prev.add(todoId))); + const todoToUpdate = todos.find(todo => todo.id === todoId); + + const updatedTodo = { + title: todoToUpdate.title, + completed: !completed, + todoId, + }; + + try { + await editTodos(updatedTodo); + updateStatus(todoId, !completed); + } catch (Error) { + setError('Unable to update a todo'); + } finally { + setUpdatingStatusIds(prev => { + const updated = new Set(prev); + + updated.delete(todoId); + + return updated; + }); + } + }; + + const handleToggleAll = async () => { + const IsAllCompleted = todos.every(todo => todo.completed); + const todosToUpdate = todos.filter( + todo => todo.completed !== !allCompleted, + ); + + setIsSubmitting(true); + + try { + const updatePromises = todosToUpdate.map(async todo => { + setUpdatingStatusIds(prev => new Set(prev.add(todo.id))); + await editTodos({ + title: todo.title, + completed: !IsAllCompleted, + todoId: todo.id, + }); + updateStatus(todo.id, !IsAllCompleted); + setUpdatingStatusIds(prev => { + const updated = new Set(prev); + + updated.delete(todo.id); + + return updated; + }); + }); + + await Promise.all(updatePromises); + } catch (Error) { + setError('Unable to update a todo'); + } finally { + setIsSubmitting(false); + } + }; + + const handleClearCompleted = async () => { + const completedTodos = todos.filter(todo => todo.completed); + + setClearingCompletedIds(new Set(completedTodos.map(todo => todo.id))); + try { + await Promise.all( + completedTodos.map(async todo => { + await handleDelete(todo.id); + }), + ); + } catch { + setError('Unable to delete a todo'); + } finally { + setClearingCompletedIds(new Set()); + } + }; + + const filteredTodos = () => { + if (filterSelected === Filters.Active) { + return todos.filter(todo => !todo.completed); + } + + if (filterSelected === Filters.Completed) { + return todos.filter(todo => todo.completed); + } + + return todos; + }; + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => { + setError('Unable to load todos'); + }); + }, []); + + useEffect(() => { + const IsAllCompleted = todos.every(todo => todo.completed); + + setAllCompleted(IsAllCompleted); + }, [todos]); + + useEffect(() => { + if (error) { + const timer = setTimeout(() => { + setError(null); + }, 3000); + + return () => clearTimeout(timer); + } + }, [error]); + + useEffect(() => { + if (!isSubmitting && ref.current) { + ref.current.focus(); + } + }, [isSubmitting]); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {todos.length !== 0 && ( +
+ + + + {tempTodo && ( + {}} + setTodos={() => {}} + setError={setError} + handleDelete={() => {}} + tempTodo={tempTodo} + /> + )} + {!!todos.length && ( +
+ )} +
+ +
+
+
); }; diff --git a/src/Footer.tsx b/src/Footer.tsx new file mode 100644 index 0000000000..47231ec99c --- /dev/null +++ b/src/Footer.tsx @@ -0,0 +1,52 @@ +import { Todo } from './types/Todo'; +import classNames from 'classnames'; +import { Filters } from './types/Filters'; + +type Props = { + todos: Todo[]; +}; + +export const Footer: React.FC = ({ + todos, + filterSelected, + setFilterSelected, + handleClearCompleted, +}) => { + const remainingTodosCount = todos.filter(todo => !todo.completed).length; + + const filterOptions: Filters[] = Object.values(Filters) as Filters[]; + + return ( + + ); +}; diff --git a/src/TodoItem.tsx b/src/TodoItem.tsx new file mode 100644 index 0000000000..beeac32ec6 --- /dev/null +++ b/src/TodoItem.tsx @@ -0,0 +1,208 @@ +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { editTodos } from './api/todos'; +import { Todo } from './types/Todo'; + +type Props = { + todo: Todo; + todos: Todo[]; +}; + +export const TodoItem: React.FC = ({ + todo: { id, title, completed }, + todos, + updateTodos, + setError, + handleDelete, + tempTodo, + isDeleting, + handleStatus, + updatingStatusIds, + clearingCompletedIds, +}) => { + const [editingId, setEditingId] = useState(null); + const [editingVal, setEditingVal] = useState(''); + const [isUpdatingTitle, setIsUpdatingTitle] = useState(false); + + const ref = useRef(null); + + const handleDouble = (todoId: number, todoTitle: string) => { + setEditingId(todoId); + setEditingVal(todoTitle); + }; + + const handleSubmit = async (todoId: number) => { + if (isUpdatingTitle) { + return; + } + + if (editingVal.trim() === '') { + handleDelete(todoId); + + return; + } + + if (editingVal.trim() === title) { + setEditingId(null); + + return; + } + + const todoToUpdate = todos.find(todo => todo.id === todoId); + const updatedTodo = { + title: editingVal, + todoId: todoToUpdate.id, + completed: completed, + }; + + setIsUpdatingTitle(true); + + try { + await editTodos(updatedTodo); + updateTodos(todoId, editingVal.trim()); + setEditingId(null); + setIsUpdatingTitle(true); + } catch (error) { + setError('Unable to update a todo'); + } finally { + setIsUpdatingTitle(false); + } + }; + + const handleBlur = (todoId: number) => { + if (!isUpdatingTitle) { + handleSubmit(todoId); + } + }; + + const handleKeyDown = ( + event: React.KeyboardEvent, + todoId: number, + ) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSubmit(todoId); + } else if (event.key === 'Escape') { + setEditingId(null); + setEditingVal(title); + } + }; + + useEffect(() => { + if (editingId !== null && ref.current) { + (ref.current as HTMLInputElement).focus(); + } + }, [editingId]); + + if (tempTodo && tempTodo.id === id) { + return ( +
+ + + {tempTodo.title} + + +
+
+
+
+
+ ); + } + + return ( +
+ + + {editingId === id ? ( +
{ + e.preventDefault(); + handleSubmit(id); + }} + > + { + setEditingVal(event.target.value); + }} + onBlur={() => { + handleBlur(id); + }} + onKeyDown={event => handleKeyDown(event, id)} + /> +
+ ) : ( + handleDouble(id, title)} + > + {title} + + )} + {editingId !== id && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/TodoList.tsx b/src/TodoList.tsx new file mode 100644 index 0000000000..73b563e573 --- /dev/null +++ b/src/TodoList.tsx @@ -0,0 +1,40 @@ +import { Todo } from './types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; +}; + +export const TodoList: React.FC = ({ + deletingTodoId, + todos, + updateTodos, + setTodos, + setError, + updateStatus, + handleDelete, + handleStatus, + updatingStatusIds, + clearingCompletedIds, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..d42336d97e --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,24 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1879; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const postTodos = (data: Omit) => { + return client.post(`/todos`, data); +}; + +export const editTodos = (data: { + title: string; + completed: boolean; + todoId: number; +}) => { + return client.patch(`/todos/${data.todoId}`, data); +}; + +export const deleteTodos = (id: number) => { + return client.delete(`/todos/${id}`); +}; diff --git a/src/types/Filters.ts b/src/types/Filters.ts new file mode 100644 index 0000000000..6c26945612 --- /dev/null +++ b/src/types/Filters.ts @@ -0,0 +1,5 @@ +export enum Filters { + 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'), +};