From 4d1539e83c1c41bd11742898df87653a7a5184f4 Mon Sep 17 00:00:00 2001 From: Vasyl Pylypchynets Date: Mon, 6 Jan 2025 18:49:04 +0200 Subject: [PATCH 1/6] solution --- README.md | 2 +- src/App.tsx | 516 ++++++++++++++++++- src/api/todos.ts | 25 + src/components/ErrorMessage/ErrorMessage.tsx | 32 ++ src/components/Footer/Footer.tsx | 77 +++ src/components/Header/Header.tsx | 49 ++ src/components/TodoItem/TodoItem.tsx | 135 +++++ src/components/TodoList/TodoList.tsx | 74 +++ src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 ++ 10 files changed, 943 insertions(+), 19 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorMessage/ErrorMessage.tsx create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index d3c3756ab9..4085f03414 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,4 @@ Implement the ability to edit a todo title on double click: - Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). -- Replace `` with your Github username in the [DEMO LINK](https://.github.io/react_todo-app-with-api/) and add it to the PR description. +- Replace `` with your Github username in the [DEMO LINK](https://VasylPylypchynets.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..030b719f2d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,506 @@ -/* 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, { useEffect, useRef, useState } from 'react'; +import { + deleteTodo, + getTodos, + addTodo, + updateTodo, + USER_ID, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { Header } from './components/Header/Header'; +import { TodoList } from './components/TodoList/TodoList'; +import { Footer } from './components/Footer/Footer'; +import { Error } from './components/ErrorMessage/ErrorMessage'; export const App: React.FC = () => { - if (!USER_ID) { - return ; + const [query, setQuery] = useState(''); + const [todos, setTodos] = useState([]); + const [itemsLeft, setItemsLeft] = useState(0); + const [sortBy, setSortBy] = useState('all'); + const [errorMessage, setErrorMessage] = useState(null); + const [deleteTodoId, setDeleteTodoId] = useState(null); + const [isLoadingChange, setIsLoadingChange] = useState(false); + const [cleanCompleted, setCleanCompleted] = useState(false); + const [newTask, setNewTask] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [isSubmiting, setIsSubmiting] = useState(false); + const [isUpdating, setIsUpdating] = useState(null); + const [itemEditingId, setItemEditingId] = useState(null); + const [newTitle, setNewTitle] = useState(''); + const [todosLength, setTodosLength] = useState(0); + const [allTodos, setAllTodos] = useState([]); + + const inputRef = useRef(null); + + function handleFilter(sort: string) { + switch (sort) { + case 'all': + setTodos(currentTodos => currentTodos); + break; + case 'active': + setTodos(currentTodos => currentTodos.filter(todo => !todo.completed)); + break; + case 'completed': + setTodos(currentTodos => currentTodos.filter(todo => todo.completed)); + break; + } + } + + function handleUpadateNewTitle(id: number, title: string) { + const todo = todos.find(currentTodo => currentTodo.id === id); + + if (todo?.title === title) { + setItemEditingId(null); + setNewTitle(''); + + return; + } + + if (title) { + setIsUpdating(id); + setErrorMessage(null); + + if (!todo) { + return; + } + + const updatedTitle = { title: title.trim() }; + + updateTodo(id, updatedTitle) + .then(() => { + setTodos(currentTodos => + currentTodos.map(item => + item.id === id ? { ...item, title: updatedTitle.title } : item, + ), + ); + + setAllTodos(currentTodos => { + return currentTodos.map(item => + item.id === id ? { ...item, title: updatedTitle.title } : item, + ); + }); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + }) + .finally(() => { + setIsUpdating(null); + }); + } + + if (title.trim() === '') { + setDeleteTodoId(id); + } + } + + function handleUpdateTodo(id: number) { + const todo = todos.find(currentTodo => currentTodo.id === id); + + if (!todo) { + return; + } + + const updatedStatus = { completed: !todo.completed }; + + if (updatedStatus) { + setIsUpdating(id); + setErrorMessage(null); + + updateTodo(id, updatedStatus) + .then(() => { + setTodos(currentTodos => + currentTodos.map(item => + item.id === id + ? { ...item, completed: updatedStatus.completed } + : item, + ), + ); + + setAllTodos(currentTodos => + currentTodos.map(item => + item.id === id + ? { ...item, completed: updatedStatus.completed } + : item, + ), + ); + + handleFilter(sortBy); + + if (updatedStatus.completed) { + setItemsLeft(currentItemsLeft => currentItemsLeft - 1); + } else { + setItemsLeft(currentItemsLeft => currentItemsLeft + 1); + } + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + }) + .finally(() => { + setIsUpdating(null); + }); + } } + function handleUpdateAllTodos() { + const hasUncompleteTodo = todos.findIndex(todo => !todo.completed); + + if (hasUncompleteTodo >= 0) { + const updatedStatus = { completed: true }; + + const updateTodos = async () => { + const updatedTodos: Promise[] = todos.map(async todo => { + if (!todo.completed) { + setIsUpdating(todo.id); + setErrorMessage(null); + + try { + await updateTodo(todo.id, updatedStatus); + + setItemsLeft(currentItemsLeft => currentItemsLeft - 1); + + return todo.id; + } catch { + setErrorMessage('Unable to update a todo'); + + return null; + } finally { + setIsUpdating(null); + } + } + + return null; + }); + + const updatedTodo = await Promise.all(updatedTodos); + + const successfulUpdated = updatedTodo.filter( + id => id !== null, + ) as number[]; + + setTodos(currentTodos => { + return currentTodos.map(todo => { + if (successfulUpdated.includes(todo.id)) { + return { + ...todo, + completed: true, + }; + } else { + return todo; + } + }); + }); + + setAllTodos(currentTodos => { + return currentTodos.map(todo => { + if (successfulUpdated.includes(todo.id)) { + return { + ...todo, + completed: true, + }; + } else { + return todo; + } + }); + }); + + handleFilter(sortBy); + }; + + updateTodos(); + } + + if (hasUncompleteTodo < 0) { + const updatedStatus = { completed: false }; + + const updateTodos = async () => { + const updatedTodos: Promise[] = todos.map(async todo => { + setIsUpdating(todo.id); + setErrorMessage(null); + + try { + await updateTodo(todo.id, updatedStatus); + + setItemsLeft(currentItemsLeft => currentItemsLeft + 1); + + return todo.id; + } catch { + setErrorMessage('Unable to update a todo'); + + return null; + } finally { + setIsUpdating(null); + } + }); + + const updatedTodo = await Promise.all(updatedTodos); + + const successfulUpdated = updatedTodo.filter( + id => id !== null, + ) as number[]; + + setTodos(currentTodos => { + return currentTodos.map(todo => { + if (successfulUpdated.includes(todo.id)) { + return { + ...todo, + completed: false, + }; + } else { + return todo; + } + }); + }); + + setAllTodos(currentTodos => { + return currentTodos.map(todo => { + if (successfulUpdated.includes(todo.id)) { + return { + ...todo, + completed: false, + }; + } else { + return todo; + } + }); + }); + + handleFilter(sortBy); + }; + + updateTodos(); + } + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (query.trim() !== '') { + setNewTask(query); + setIsSubmiting(true); + } else { + setErrorMessage('Title should not be empty'); + } + } + + function handleDeleteTodo(id: number) { + setDeleteTodoId(id); + } + + function handleInput(e: React.ChangeEvent) { + setQuery(e.target.value); + } + + function handleCleanCompleted() { + setCleanCompleted(true); + } + + useEffect(() => { + if (inputRef.current || (errorMessage && inputRef.current)) { + inputRef.current.focus(); + } + }, [query, errorMessage, todos]); + + useEffect(() => { + if (newTask.trim() !== '') { + const todo: Omit = { + userId: USER_ID, + title: newTask.trim(), + completed: false, + }; + + setTempTodo({ id: 0, ...todo }); + + addTodo(todo) + .then(receivedTodo => { + setTempTodo(null); + setQuery(''); + setTodos(currentTodos => [...currentTodos, receivedTodo]); + setAllTodos(currentTodos => [...currentTodos, receivedTodo]); + setItemsLeft(currentItemsLeft => currentItemsLeft + 1); + setTodosLength(currentTodosLength => currentTodosLength + 1); + }) + .catch(() => { + setErrorMessage('Unable to add a todo'); + + if (inputRef.current) { + inputRef.current.focus(); + } + }) + .finally(() => { + setTempTodo(null); + setIsSubmiting(false); + setNewTask(''); + }); + } else { + setIsSubmiting(false); + } + }, [newTask]); + + useEffect(() => { + if (errorMessage) { + const timerEmpty = setTimeout(() => setErrorMessage(null), 3000); + + return () => clearTimeout(timerEmpty); + } + }, [errorMessage]); + + useEffect(() => { + async function deleteTodoFromServer() { + if (deleteTodoId !== null) { + setIsLoadingChange(true); + + try { + await deleteTodo(deleteTodoId).then(() => { + const newTodos = todos.filter(todo => todo.id !== deleteTodoId); + + setTodos(newTodos); + setAllTodos(newTodos); + setTodosLength(newTodos.length); + }); + } catch { + setErrorMessage('Unable to delete a todo'); + } finally { + setIsLoadingChange(false); + + const numbersOfItemsLeft: number = todos.filter( + todo => !todo.completed, + ).length; + + setItemsLeft(numbersOfItemsLeft); + } + } + } + + const cleanup = deleteTodoFromServer(); + + return () => { + if (cleanup instanceof Function) { + cleanup(); + } + }; + }, [deleteTodoId, todos]); + + useEffect(() => { + async function deleteTodoFromServer() { + if (cleanCompleted) { + setIsLoadingChange(true); + + const completedTodos = allTodos.filter(todo => todo.completed); + const deletionPromises = completedTodos.map(async todo => { + try { + await deleteTodo(todo.id); + + return todo.id; + } catch (error) { + setErrorMessage('Unable to delete a todo'); + + return null; + } + }); + + const resolvedDeletions = await Promise.all(deletionPromises); + + const successfulDeletions = resolvedDeletions.filter( + id => id !== null, + ) as number[]; + + setTodos(prevTodos => + prevTodos.filter(todo => !successfulDeletions.includes(todo.id)), + ); + + setAllTodos(prevTodos => + prevTodos.filter(todo => !successfulDeletions.includes(todo.id)), + ); + + setTodosLength(prevTodos => prevTodos - successfulDeletions.length); + + const numbersOfItemsLeft: number = todos.filter( + todo => !todo.completed, + ).length; + + setItemsLeft(numbersOfItemsLeft); + } + + setIsLoadingChange(false); + setCleanCompleted(false); + } + + deleteTodoFromServer(); + }, [cleanCompleted, todos]); + + useEffect(() => { + async function getTodosFromServer() { + try { + const todosFromServer = await getTodos(); + + setTodosLength(todosFromServer.length); + + const numbersOfItemsLeft: number = todosFromServer.filter( + todo => !todo.completed, + ).length; + + setItemsLeft(numbersOfItemsLeft); + setErrorMessage(null); + setTodos(todosFromServer); + setAllTodos(todosFromServer); + + handleFilter(sortBy); + } catch { + setErrorMessage('Unable to load todos'); + } + } + + getTodosFromServer(); + }, [sortBy]); + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {/* Hide the footer if there are no todos */} + {todosLength !== 0 && ( +
+ )} +
+ + {/* DON'T use conditional rendering to hide the notification */} + {/* Add the 'hidden' class to hide the message smoothly */} + + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..335aaebd62 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,25 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 2085; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here + +export const deleteTodo = (TODO_ID: number) => { + return client.delete(`/todos/${TODO_ID}`); +}; + +export const addTodo = (todo: Omit) => { + return client.post(`/todos`, todo); +}; + +export const updateTodo = ( + TODO_ID: number, + updatedTodo: Pick | Pick, +) => { + return client.patch(`/todos/${TODO_ID}`, updatedTodo); +}; diff --git a/src/components/ErrorMessage/ErrorMessage.tsx b/src/components/ErrorMessage/ErrorMessage.tsx new file mode 100644 index 0000000000..6e4bd5a034 --- /dev/null +++ b/src/components/ErrorMessage/ErrorMessage.tsx @@ -0,0 +1,32 @@ +import classNames from 'classnames'; + +type ErrorProps = { + errorMessage: string | boolean | null; + onErrorMessage: React.Dispatch>; +}; + +export function Error({ errorMessage, onErrorMessage }: ErrorProps) { + return ( +
+
+ ); +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..547786becb --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,77 @@ +import classNames from 'classnames'; + +type FooterProps = { + itemsLeft: number; + sortBy: string; + onSortBy: (sort: string) => void; + onCleanCompleted: () => void; + todosLength: number; +}; + +export function Footer({ + itemsLeft, + sortBy, + onSortBy, + onCleanCompleted, + todosLength, +}: FooterProps) { + return ( + + ); +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..40bc40b347 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,49 @@ +import classNames from 'classnames'; + +type HeaderProps = { + query: string; + onInput: (e: React.ChangeEvent) => void; + onSubmit: (e: React.FormEvent) => void; + isSubmiting: boolean; + inputRef: React.RefObject; + onUpdateAllTodos: () => void; + itemsLeft: number; +}; + +export function Header({ + query, + onInput, + onSubmit, + isSubmiting, + inputRef, + onUpdateAllTodos, + itemsLeft, +}: HeaderProps) { + return ( +
+ {/* this button should have `active` class only if all todos are completed */} +
+ ); +} diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..c25a870380 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,135 @@ +import classNames from 'classnames'; + +import { useEffect, useRef } from 'react'; +import { Todo } from '../../types/Todo'; + +type TodoItemProps = { + onDeleteTodo: (id: number) => void; + todo: Todo; + isLoadingChange: boolean; + deleteTodoId: number | null; + cleanCompleted: boolean; + isAdding?: boolean; + onUpdateTodo: (id: number) => void; + isUpdating?: null | number; + itemEditingId?: null | number; + onItemEditingId: (id: number | null) => void; + newTitle: string; + onSetNewTitle: (title: string) => void; + onUpdateNewTitle: (id: number, title: string) => void; +}; + +export function TodoItem({ + todo, + onDeleteTodo, + isLoadingChange, + deleteTodoId, + cleanCompleted, + isAdding, + onUpdateTodo, + isUpdating, + itemEditingId, + onItemEditingId, + newTitle, + onSetNewTitle, + onUpdateNewTitle, +}: TodoItemProps) { + const inputRef = useRef(null); + + function handleCancel(e: React.KeyboardEvent) { + if (e.key === 'Escape') { + onItemEditingId(null); + onSetNewTitle(todo.title); + } + } + + useEffect(() => { + if (itemEditingId === todo.id && inputRef.current) { + inputRef.current.focus(); + onSetNewTitle(todo.title); + } + }, [itemEditingId, todo.id]); + + return ( +
+ + + {itemEditingId !== todo.id && ( + <> + onItemEditingId(todo.id)} + > + {todo.title} + + {/* Remove button appears only on hover */} + + + )} + + {/* This todo is being edited */} + + {/* This form is shown instead of the title and remove button */} + {itemEditingId === todo.id && ( +
{ + e.preventDefault(); + onUpdateNewTitle(todo.id, newTitle); + }} + > + onSetNewTitle(e.target.value)} + onBlur={() => { + onItemEditingId(null); + onUpdateNewTitle(todo.id, newTitle); + }} + onKeyUp={handleCancel} + /> +
+ )} + + {/* Overlay will cover the todo while it is being deleted or updated */} +
+
+
+
+
+ ); +} diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..e4b0721acd --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,74 @@ +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +type TodoListProps = { + onDeleteTodo: (id: number) => void; + todos: Todo[]; + isLoadingChange: boolean; + deleteTodoId: number | null; + cleanCompleted: boolean; + tempTodo: Todo | null; + onUpdateTodo: (id: number) => void; + isUpdating: null | number; + itemEditingId: null | number; + onItemEditingId: (id: number | null) => void; + newTitle: string; + onSetNewTitle: (title: string) => void; + onUpdateNewTitle: (id: number, title: string) => void; +}; + +export function TodoList({ + onDeleteTodo, + todos, + isLoadingChange, + deleteTodoId, + cleanCompleted, + tempTodo, + onUpdateTodo, + isUpdating, + itemEditingId, + onItemEditingId, + newTitle, + onSetNewTitle, + onUpdateNewTitle, +}: TodoListProps) { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && ( + + )} +
+ ); +} 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'), +}; From 7fd48ac0c974b3f83704834cbb9b97f3ae61dfcc Mon Sep 17 00:00:00 2001 From: Vasyl Pylypchynets Date: Tue, 7 Jan 2025 22:22:48 +0200 Subject: [PATCH 2/6] bug review --- src/App.tsx | 284 +++++++++------------------ src/api/todos.ts | 2 - src/components/Footer/Footer.tsx | 21 +- src/components/Header/Header.tsx | 20 +- src/components/TodoItem/TodoItem.tsx | 81 +++++--- src/components/TodoList/TodoList.tsx | 38 ++-- 6 files changed, 188 insertions(+), 258 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 030b719f2d..cf897d8dcd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,12 @@ /* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { deleteTodo, getTodos, @@ -14,88 +20,67 @@ import { TodoList } from './components/TodoList/TodoList'; import { Footer } from './components/Footer/Footer'; import { Error } from './components/ErrorMessage/ErrorMessage'; +export enum SortBy { + All = 'all', + Active = 'active', + Completed = 'completed', +} + export const App: React.FC = () => { - const [query, setQuery] = useState(''); const [todos, setTodos] = useState([]); - const [itemsLeft, setItemsLeft] = useState(0); - const [sortBy, setSortBy] = useState('all'); + const [query, setQuery] = useState(''); + const [isLoadingChange, setIsLoadingChange] = useState(false); + const [isSubmiting, setIsSubmiting] = useState(false); + const [isUpdating, setIsUpdating] = useState(null); + + const [sortBy, setSortBy] = useState(SortBy.All); const [errorMessage, setErrorMessage] = useState(null); const [deleteTodoId, setDeleteTodoId] = useState(null); - const [isLoadingChange, setIsLoadingChange] = useState(false); const [cleanCompleted, setCleanCompleted] = useState(false); const [newTask, setNewTask] = useState(''); const [tempTodo, setTempTodo] = useState(null); - const [isSubmiting, setIsSubmiting] = useState(false); - const [isUpdating, setIsUpdating] = useState(null); - const [itemEditingId, setItemEditingId] = useState(null); - const [newTitle, setNewTitle] = useState(''); - const [todosLength, setTodosLength] = useState(0); - const [allTodos, setAllTodos] = useState([]); const inputRef = useRef(null); - function handleFilter(sort: string) { - switch (sort) { - case 'all': - setTodos(currentTodos => currentTodos); - break; - case 'active': - setTodos(currentTodos => currentTodos.filter(todo => !todo.completed)); - break; - case 'completed': - setTodos(currentTodos => currentTodos.filter(todo => todo.completed)); - break; - } - } - - function handleUpadateNewTitle(id: number, title: string) { - const todo = todos.find(currentTodo => currentTodo.id === id); - - if (todo?.title === title) { - setItemEditingId(null); - setNewTitle(''); + useEffect(() => { + async function getTodosFromServer() { + try { + const todosFromServer = await getTodos(); - return; + setTodos(todosFromServer); + setErrorMessage(null); + } catch { + setErrorMessage('Unable to load todos'); + } } - if (title) { - setIsUpdating(id); - setErrorMessage(null); - - if (!todo) { - return; + getTodosFromServer(); + }, []); + + const itemsLeft = useMemo(() => { + return todos.filter(todo => !todo.completed).length; + }, [todos]); + + const handleFilter = useCallback( + (sort: SortBy, tasks: Todo[] = todos) => { + switch (sort) { + case SortBy.All: + return tasks; + case SortBy.Active: + return tasks.filter(todo => !todo.completed); + case SortBy.Completed: + return tasks.filter(todo => todo.completed); } + }, + [todos], + ); - const updatedTitle = { title: title.trim() }; - - updateTodo(id, updatedTitle) - .then(() => { - setTodos(currentTodos => - currentTodos.map(item => - item.id === id ? { ...item, title: updatedTitle.title } : item, - ), - ); - - setAllTodos(currentTodos => { - return currentTodos.map(item => - item.id === id ? { ...item, title: updatedTitle.title } : item, - ); - }); - }) - .catch(() => { - setErrorMessage('Unable to update a todo'); - }) - .finally(() => { - setIsUpdating(null); - }); - } - - if (title.trim() === '') { - setDeleteTodoId(id); - } - } + const filteredTodos: Todo[] = useMemo( + () => handleFilter(sortBy, todos), + [sortBy, todos, handleFilter], + ); - function handleUpdateTodo(id: number) { + function handleUpdateTodoStatus(id: number) { const todo = todos.find(currentTodo => currentTodo.id === id); if (!todo) { @@ -105,7 +90,7 @@ export const App: React.FC = () => { const updatedStatus = { completed: !todo.completed }; if (updatedStatus) { - setIsUpdating(id); + setIsUpdating([id]); setErrorMessage(null); updateTodo(id, updatedStatus) @@ -117,22 +102,6 @@ export const App: React.FC = () => { : item, ), ); - - setAllTodos(currentTodos => - currentTodos.map(item => - item.id === id - ? { ...item, completed: updatedStatus.completed } - : item, - ), - ); - - handleFilter(sortBy); - - if (updatedStatus.completed) { - setItemsLeft(currentItemsLeft => currentItemsLeft - 1); - } else { - setItemsLeft(currentItemsLeft => currentItemsLeft + 1); - } }) .catch(() => { setErrorMessage('Unable to update a todo'); @@ -143,7 +112,7 @@ export const App: React.FC = () => { } } - function handleUpdateAllTodos() { + function handleUpdateAllTodosStatus() { const hasUncompleteTodo = todos.findIndex(todo => !todo.completed); if (hasUncompleteTodo >= 0) { @@ -152,21 +121,23 @@ export const App: React.FC = () => { const updateTodos = async () => { const updatedTodos: Promise[] = todos.map(async todo => { if (!todo.completed) { - setIsUpdating(todo.id); + setIsUpdating(prev => { + if (prev === null) { + return [todo.id]; + } else { + return [...prev, todo.id]; + } + }); setErrorMessage(null); try { await updateTodo(todo.id, updatedStatus); - setItemsLeft(currentItemsLeft => currentItemsLeft - 1); - return todo.id; } catch { setErrorMessage('Unable to update a todo'); return null; - } finally { - setIsUpdating(null); } } @@ -192,20 +163,7 @@ export const App: React.FC = () => { }); }); - setAllTodos(currentTodos => { - return currentTodos.map(todo => { - if (successfulUpdated.includes(todo.id)) { - return { - ...todo, - completed: true, - }; - } else { - return todo; - } - }); - }); - - handleFilter(sortBy); + setIsUpdating(null); }; updateTodos(); @@ -216,21 +174,23 @@ export const App: React.FC = () => { const updateTodos = async () => { const updatedTodos: Promise[] = todos.map(async todo => { - setIsUpdating(todo.id); + setIsUpdating(prev => { + if (prev === null) { + return [todo.id]; + } else { + return [...prev, todo.id]; + } + }); setErrorMessage(null); try { await updateTodo(todo.id, updatedStatus); - setItemsLeft(currentItemsLeft => currentItemsLeft + 1); - return todo.id; } catch { setErrorMessage('Unable to update a todo'); return null; - } finally { - setIsUpdating(null); } }); @@ -252,21 +212,7 @@ export const App: React.FC = () => { } }); }); - - setAllTodos(currentTodos => { - return currentTodos.map(todo => { - if (successfulUpdated.includes(todo.id)) { - return { - ...todo, - completed: false, - }; - } else { - return todo; - } - }); - }); - - handleFilter(sortBy); + setIsUpdating(null); }; updateTodos(); @@ -283,18 +229,10 @@ export const App: React.FC = () => { } } - function handleDeleteTodo(id: number) { - setDeleteTodoId(id); - } - function handleInput(e: React.ChangeEvent) { setQuery(e.target.value); } - function handleCleanCompleted() { - setCleanCompleted(true); - } - useEffect(() => { if (inputRef.current || (errorMessage && inputRef.current)) { inputRef.current.focus(); @@ -316,9 +254,6 @@ export const App: React.FC = () => { setTempTodo(null); setQuery(''); setTodos(currentTodos => [...currentTodos, receivedTodo]); - setAllTodos(currentTodos => [...currentTodos, receivedTodo]); - setItemsLeft(currentItemsLeft => currentItemsLeft + 1); - setTodosLength(currentTodosLength => currentTodosLength + 1); }) .catch(() => { setErrorMessage('Unable to add a todo'); @@ -338,11 +273,13 @@ export const App: React.FC = () => { }, [newTask]); useEffect(() => { - if (errorMessage) { - const timerEmpty = setTimeout(() => setErrorMessage(null), 3000); + let timerEmpty: NodeJS.Timeout; - return () => clearTimeout(timerEmpty); + if (errorMessage) { + timerEmpty = setTimeout(() => setErrorMessage(null), 3000); } + + return () => clearTimeout(timerEmpty); }, [errorMessage]); useEffect(() => { @@ -355,19 +292,11 @@ export const App: React.FC = () => { const newTodos = todos.filter(todo => todo.id !== deleteTodoId); setTodos(newTodos); - setAllTodos(newTodos); - setTodosLength(newTodos.length); }); } catch { setErrorMessage('Unable to delete a todo'); } finally { setIsLoadingChange(false); - - const numbersOfItemsLeft: number = todos.filter( - todo => !todo.completed, - ).length; - - setItemsLeft(numbersOfItemsLeft); } } } @@ -386,7 +315,7 @@ export const App: React.FC = () => { if (cleanCompleted) { setIsLoadingChange(true); - const completedTodos = allTodos.filter(todo => todo.completed); + const completedTodos = todos.filter(todo => todo.completed); const deletionPromises = completedTodos.map(async todo => { try { await deleteTodo(todo.id); @@ -408,18 +337,6 @@ export const App: React.FC = () => { setTodos(prevTodos => prevTodos.filter(todo => !successfulDeletions.includes(todo.id)), ); - - setAllTodos(prevTodos => - prevTodos.filter(todo => !successfulDeletions.includes(todo.id)), - ); - - setTodosLength(prevTodos => prevTodos - successfulDeletions.length); - - const numbersOfItemsLeft: number = todos.filter( - todo => !todo.completed, - ).length; - - setItemsLeft(numbersOfItemsLeft); } setIsLoadingChange(false); @@ -429,31 +346,6 @@ export const App: React.FC = () => { deleteTodoFromServer(); }, [cleanCompleted, todos]); - useEffect(() => { - async function getTodosFromServer() { - try { - const todosFromServer = await getTodos(); - - setTodosLength(todosFromServer.length); - - const numbersOfItemsLeft: number = todosFromServer.filter( - todo => !todo.completed, - ).length; - - setItemsLeft(numbersOfItemsLeft); - setErrorMessage(null); - setTodos(todosFromServer); - setAllTodos(todosFromServer); - - handleFilter(sortBy); - } catch { - setErrorMessage('Unable to load todos'); - } - } - - getTodosFromServer(); - }, [sortBy]); - return (

todos

@@ -465,34 +357,34 @@ export const App: React.FC = () => { onSubmit={handleSubmit} isSubmiting={isSubmiting} inputRef={inputRef} - onUpdateAllTodos={handleUpdateAllTodos} + onUpdateAllTodos={handleUpdateAllTodosStatus} itemsLeft={itemsLeft} + todosLength={todos.length} /> {/* Hide the footer if there are no todos */} - {todosLength !== 0 && ( + {todos.length !== 0 && (
)}
diff --git a/src/api/todos.ts b/src/api/todos.ts index 335aaebd62..863198838e 100644 --- a/src/api/todos.ts +++ b/src/api/todos.ts @@ -7,8 +7,6 @@ export const getTodos = () => { return client.get(`/todos?userId=${USER_ID}`); }; -// Add more methods here - export const deleteTodo = (TODO_ID: number) => { return client.delete(`/todos/${TODO_ID}`); }; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index 547786becb..b92a1e89e2 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -1,10 +1,11 @@ import classNames from 'classnames'; +import { SortBy } from '../../App'; type FooterProps = { itemsLeft: number; sortBy: string; - onSortBy: (sort: string) => void; - onCleanCompleted: () => void; + onSortBy: (sort: SortBy) => void; + onCleanCompleted: (clean: boolean) => void; todosLength: number; }; @@ -24,11 +25,13 @@ export function Footer({