diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..2ecaf2d911 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,482 @@ -/* 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, { useEffect, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { + getTodos, + USER_ID, + deleteTodo, + addTodo, + completeTodo, + renameTodo, +} from './api/todos'; +import { Todo } from './types/Todo'; +import classNames from 'classnames'; -const USER_ID = 0; +enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState(Filter.All); + const [errorMessage, setErrorMessage] = useState(''); + const [loadingTodoId, setLoadingTodoId] = useState(null); + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [isAdding, setIsAdding] = useState(false); + const [editingTodoId, setEditingTodoId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + + const activeCount = todos.filter(todo => todo.completed === false); + const completedTodos = todos.filter(todo => todo.completed); + + const inputRef = useRef(null); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => setErrorMessage('Unable to load todos')); + }, []); + + useEffect(() => { + if (!isAdding && inputRef.current) { + inputRef.current.focus(); + } + }, [isAdding]); + + function filterTodos() { + switch (filter) { + case Filter.Active: + return todos.filter(todo => !todo.completed); + case Filter.Completed: + return todos.filter(todo => todo.completed); + case Filter.All: + default: + return todos; + } + } + + const handleAddTodo = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedTitle = newTodoTitle.trim(); + + if (!trimmedTitle) { + setErrorMessage('Title should not be empty'); + + return; + } + + const newTodo: Omit = { + title: trimmedTitle, + userId: USER_ID, + completed: false, + }; + + setTempTodo({ ...newTodo, id: 0 }); + + setIsAdding(true); + addTodo(newTodo) + .then(todoItem => { + setTodos(prevTodos => [...prevTodos, todoItem]); + setNewTodoTitle(''); + }) + .catch(() => { + setErrorMessage('Unable to add a todo'); + }) + .finally(() => { + setTempTodo(null); + setIsAdding(false); + inputRef.current?.focus(); + }); + }; + + const handleDeleteCompleted = () => { + const completedIds = completedTodos.map(todo => todo.id); + + Promise.allSettled(completedIds.map(id => deleteTodo(id))) + .then(results => { + const failedDeletions = completedIds.filter( + (_, index) => results[index].status === 'rejected', + ); + + setTodos(currentTodos => + currentTodos.filter( + todo => + !completedIds.includes(todo.id) || + failedDeletions.includes(todo.id), + ), + ); + + if (failedDeletions.length > 0) { + setErrorMessage('Unable to delete a todo'); + } + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }) + .finally(() => inputRef.current?.focus()); + }; + + const handleDelete = (todoId: number) => { + setLoadingTodoId(todoId); + deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + }) + .finally(() => { + inputRef.current?.focus(); + setLoadingTodoId(null); + }); + }; + + const handleComplete = (todoId: number) => { + const currentTodo = todos.find(todo => todo.id === todoId); + + if (!currentTodo) { + return; + } + + const newCompletionState = !currentTodo.completed; + + setLoadingTodoId(todoId); + completeTodo(todoId, newCompletionState) + .then(() => { + setTodos(prevTodos => + prevTodos.map(todo => + todo.id === todoId + ? { ...todo, completed: newCompletionState } + : todo, + ), + ); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + }) + .finally(() => { + setLoadingTodoId(null); + }); + }; + + const handleCompleteAll = () => { + const allCompleted = todos.every(todo => todo.completed); + const newCompletionState = !allCompleted; + + const todosToUpdate = newCompletionState + ? todos.filter(todo => !todo.completed) + : todos; + + const todoIdsToUpdate = todosToUpdate.map(todo => todo.id); + + Promise.allSettled( + todoIdsToUpdate.map(id => completeTodo(id, newCompletionState)), + ) + .then(results => { + const failedUpdates = todoIdsToUpdate.filter( + (_, index) => results[index].status === 'rejected', + ); + + setTodos(currentTodos => + currentTodos.map(todo => + todoIdsToUpdate.includes(todo.id) + ? { ...todo, completed: newCompletionState } + : todo, + ), + ); + + if (failedUpdates.length > 0) { + setErrorMessage('Unable to update todos'); + } + }) + .catch(() => { + setErrorMessage('Unable to update todos'); + }); + }; + + const filteredTodos = filterTodos(); + + useEffect(() => { + if (errorMessage) { + const timer = setTimeout(() => setErrorMessage(''), 3000); + + return () => clearTimeout(timer); + } + + return; + }, [errorMessage]); + if (!USER_ID) { return ; } + const handleSaveEdit = (todoId: number) => { + const trimmedTitle = editingTitle.trim(); + + const currentTodo = todos.find(todo => todo.id === todoId); + + if (currentTodo && trimmedTitle === currentTodo.title) { + setEditingTodoId(null); + + return; + } + + if (trimmedTitle === '') { + handleDelete(todoId); + } else { + setLoadingTodoId(todoId); + + renameTodo(todoId, trimmedTitle) + .then(() => { + setTodos(prevTodos => + prevTodos.map(todo => + todo.id === todoId ? { ...todo, title: trimmedTitle } : todo, + ), + ); + setEditingTodoId(null); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + }) + .finally(() => { + setLoadingTodoId(null); + }); + } + }; + + const handleCancelEdit = () => { + setEditingTodoId(null); + setEditingTitle(''); + }; + + const handleEditTodo = (todoId: number, title: string) => { + setEditingTodoId(todoId); + setEditingTitle(title); + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ +
+ {filteredTodos.map(todo => ( +
+ + {editingTodoId === todo.id ? ( + <> +
e.preventDefault()}> + setEditingTitle(e.target.value)} + onBlur={() => handleSaveEdit(todo.id)} + onKeyUp={e => { + if (e.key === 'Enter') { + handleSaveEdit(todo.id); + } else if (e.key === 'Escape') { + handleCancelEdit(); + } + }} + autoFocus + /> +
+
+
+
+
+ + ) : ( + <> + handleEditTodo(todo.id, todo.title)} + > + {todo.title} + + + {/* Remove button appears only on hover */} + + + {/* overlay will cover the todo while it is being deleted or updated */} +
+
+
+
+ + )} +
+ ))} + {tempTodo && ( +
+ + + {tempTodo.title} + +
+
+
+
+
+ )} +
+ + {/* Hide the footer if there are no todos */} + {todos.length > 0 && ( + + )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..697a2987f2 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,36 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 2129; + +export const getTodos = () => { + const baseUrl = `/todos?userId=${USER_ID}`; + + return client.get(baseUrl); +}; + +export const deleteTodo = (id: number) => { + const baseUrl = `/todos/${id}`; + + return client.delete(baseUrl); +}; + +export const addTodo = (newTodo: Omit) => { + const baseUrl = `/todos`; + + return client.post(baseUrl, newTodo); +}; + +export const completeTodo = (id: number, completed: boolean) => { + const baseUrl = `/todos/${id}`; + + return client.patch(baseUrl, { completed }); +}; + +export const renameTodo = (id: number, title: string) => { + const baseUrl = `/todos/${id}`; + + return client.patch(baseUrl, { title: title }); +}; + +// Add more methods here diff --git a/src/styles/index.scss b/src/styles/index.scss index bccd80c8bc..35583d0c4a 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -20,6 +20,10 @@ body { pointer-events: none; } +.hidden { + display: none +} + @import "./todoapp"; @import "./todo"; @import "./filter"; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index ad28bcb2fe..27b57251a4 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,4 +1,3 @@ - .todoapp { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 24px; @@ -69,7 +68,7 @@ border: none; background: rgba(0, 0, 0, 0.01); - box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); + box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); &::placeholder { font-style: italic; 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'), +};