diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..0178c1ba6b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,252 @@ /* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useEffect, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import * as postService from './api/todos'; +import { Todo } from './Types/Todo'; +import { TodoFooter } from './Components/Footer/TodoFooter'; +import { TodoItem } from './Components/TodoItem/TodoItem'; +import { ErrorNotification } from './Components/Errors/ErrorNotification'; +import { ErrorType, FilterStatus } from './Types/Types'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [filterStatus, setFilterStatus] = useState( + FilterStatus.All, + ); + const [newTodoTask, setNewTodoTask] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const [isProcessing, setIsProcessing] = useState([]); + + const visibleTodos = todos.filter(todo => { + if (filterStatus === FilterStatus.Active) { + return !todo.completed; + } + + if (filterStatus === FilterStatus.Completed) { + return todo.completed; + } + + return true; + }); + + const isAllCompleted = + todos.length > 0 && todos.every(todo => todo.completed); + const activeCount = todos.filter(todo => !todo.completed).length; + const hasCompleted = todos.some(todo => todo.completed); + + function loadTodos() { + setLoading(true); + + postService + .getTodos() + .then(setTodos) + .catch(() => setErrorMessage(ErrorType.Load)) + .finally(() => setLoading(false)); + } + + useEffect(loadTodos, []); + + const handleTaskChange = (event: React.ChangeEvent) => { + setNewTodoTask(event.target.value); + setErrorMessage(''); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimSpacesTitle = newTodoTask.trim(); + + if (!trimSpacesTitle) { + setErrorMessage(ErrorType.EmptyTitle); + + return; + } + + setIsSubmitting(true); + + const newTempTodo: Todo = { + id: 0, + title: trimSpacesTitle, + userId: postService.USER_ID, + completed: false, + }; + + setTempTodo(newTempTodo); + + postService + .createTodo({ + title: trimSpacesTitle, + userId: postService.USER_ID, + completed: false, + }) + + .then(newTodo => { + setTodos(currentTodo => [...currentTodo, newTodo]); + setNewTodoTask(''); + }) + .catch(() => setErrorMessage(ErrorType.Add)) + .finally(() => { + setTempTodo(null); + setIsSubmitting(false); + }); + }; + + const field = useRef(null); + + useEffect(() => { + if (!isSubmitting && !loading && isProcessing.length === 0) { + const timeoutId = setTimeout(() => { + field.current?.focus(); + }, 50); + + return () => clearTimeout(timeoutId); + } + + return undefined; + }, [isSubmitting, loading, isProcessing]); + + async function deleteItem(todoId: number) { + setLoading(true); + setIsProcessing(current => [...current, todoId]); + try { + await postService.deleteTodo(todoId); + + setTodos(currentItem => currentItem.filter(todo => todo.id !== todoId)); + } catch (error) { + setErrorMessage(ErrorType.Delete); + throw error; + } finally { + setIsProcessing(current => current.filter(id => id !== todoId)); + setLoading(false); + } + } + + const clearCompleted = () => { + const completedIds = todos.filter(todo => todo.completed); + + const deletePromises = completedIds.map(todo => { + return deleteItem(todo.id); + }); + + Promise.all(deletePromises) + .then(() => { + setTodos(prev => prev.filter(t => !t.completed)); + }) + .catch(() => setErrorMessage(ErrorType.Delete)); + }; + + async function handleUpdate(todoId: number, data: Partial) { + setIsProcessing(current => [...current, todoId]); + try { + try { + const updatedTodo = await postService.updateTodo(todoId, data); + + setTodos(prev => + prev.map(todo => (todo.id === todoId ? updatedTodo : todo)), + ); + } catch (error) { + setErrorMessage(ErrorType.Update); + throw error; + } + } finally { + setIsProcessing(current_1 => current_1.filter(id => id !== todoId)); + } + } + + const toggleAll = () => { + const shouldCompleteAll = !isAllCompleted; + const todosToUpdate = todos.filter( + todo => todo.completed !== shouldCompleteAll, + ); + + const updatedTodos = todosToUpdate.map(todo => { + return handleUpdate(todo.id, { completed: shouldCompleteAll }); + }); + + Promise.all(updatedTodos).catch(() => { + setErrorMessage(ErrorType.Update); + }); + }; + + if (!postService.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 => ( + + + + ))} + {tempTodo && ( + + {}} + isLoading={true} + updateTodo={() => Promise.resolve()} + /> + + )} + +
+ + {todos.length > 0 && ( + + )} + + setErrorMessage('')} + /> +
+
); }; diff --git a/src/Components/Errors/ErrorNotification.tsx b/src/Components/Errors/ErrorNotification.tsx new file mode 100644 index 0000000000..005513d08c --- /dev/null +++ b/src/Components/Errors/ErrorNotification.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; + +type Props = { + message: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ message, onClose }) => { + useEffect(() => { + if (!message) { + return; + } + + const timer = setTimeout(onClose, 3000); + + return () => clearTimeout(timer); + }, [message, onClose]); + + return ( + + ); +}; diff --git a/src/Components/Footer/TodoFooter.tsx b/src/Components/Footer/TodoFooter.tsx new file mode 100644 index 0000000000..6268fedf53 --- /dev/null +++ b/src/Components/Footer/TodoFooter.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import '../../styles/todoapp.scss'; +import { FilterStatus } from '../../Types/Types'; + +type Props = { + activeCount: number; + hasCompleted: boolean; + filterStatus: FilterStatus; + onFilterChange: (status: FilterStatus) => void; + onClearCompleted: () => void; +}; + +export const TodoFooter: React.FC = ({ + activeCount, + hasCompleted, + filterStatus, + onFilterChange, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/Components/Loader/Loader.tsx b/src/Components/Loader/Loader.tsx new file mode 100644 index 0000000000..4ffab630ab --- /dev/null +++ b/src/Components/Loader/Loader.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +type Props = { + isLoading?: boolean; +}; + +export const Loader: React.FC = ({ isLoading = true }) => { + return ( +
+
+
+
+ ); +}; diff --git a/src/Components/TodoItem/TodoItem.tsx b/src/Components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..f54e839274 --- /dev/null +++ b/src/Components/TodoItem/TodoItem.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; +import { Todo } from '../../Types/Todo'; +import '../../styles/todo.scss'; +import { Loader } from '../Loader/Loader'; + +type Props = { + todo: Todo; + onDelete: (todoId: number) => void; + isLoading?: boolean; + updateTodo: (todoId: number, data: Partial) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + onDelete, + isLoading = false, + updateTodo, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + + const field = useRef(null); + + useEffect(() => { + if (isEditing && field.current) { + field.current.focus(); + } + }, [isEditing]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedTitle = newTitle.trim(); + + if (trimmedTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (!trimmedTitle) { + onDelete(todo.id); + + return; + } + + updateTodo(todo.id, { title: trimmedTitle }) + .then(() => { + setIsEditing(false); + }) + .catch(() => {}); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setNewTitle(todo.title); + setIsEditing(false); + } + }; + + return ( +
+ + + {isEditing ? ( +
+ setNewTitle(e.target.value)} + onBlur={handleSubmit} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + setIsEditing(true)} + > + {todo.title} + + )} + + {!isEditing && ( + + )} + +
+ ); +}; 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/Types/Types.ts b/src/Types/Types.ts new file mode 100644 index 0000000000..2b9924d30d --- /dev/null +++ b/src/Types/Types.ts @@ -0,0 +1,13 @@ +export enum FilterStatus { + All = 'all', + Active = 'active', + Completed = 'completed', +} + +export enum ErrorType { + Load = 'Unable to load todos', + EmptyTitle = 'Title should not be empty', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', +} diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..e3fa5353bf --- /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 = 4109; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = ({ title, userId, completed }: Omit) => { + return client.post(`/todos`, { title, userId, completed }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, data: Partial) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/styles/animations.scss b/src/styles/animations.scss new file mode 100644 index 0000000000..04f5318372 --- /dev/null +++ b/src/styles/animations.scss @@ -0,0 +1,46 @@ +.item-enter { + max-height: 0; +} + +.item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.item-exit { + max-height: 58px; +} + +.item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-enter { + max-height: 0; +} + +.temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-exit { + max-height: 58px; +} + +.temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; +} + +.has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..94e06c36ba --- /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(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: any) => request(url, 'POST', data), + patch: (url: string, data: any) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +};