From a5cdd3e9e7024a2eaa4a9f4472733fe7b2d2340a Mon Sep 17 00:00:00 2001 From: Nataliia Fedorchuk Date: Fri, 20 Dec 2024 13:39:12 +0200 Subject: [PATCH 1/5] add React Transition Group --- src/App.tsx | 176 ++++++++++++++++-- src/api/todos.ts | 20 ++ .../ErrorNotification/ErrorNotification.tsx | 43 +++++ src/components/ErrorNotification/index.ts | 1 + src/components/Footer/Footer.tsx | 55 ++++++ src/components/Header/Header.tsx | 81 ++++++++ src/components/TodoItem/TodoItem.tsx | 123 ++++++++++++ src/components/TodoList/TodoList.tsx | 54 ++++++ src/styles/index.scss | 47 +++++ src/types/Errors.ts | 8 + src/types/FilterType.ts | 5 + src/types/Todo.ts | 6 + src/types/htmlFormElements.ts | 3 + src/types/rest.tsx | 80 ++++++++ src/utils/fetchClient.ts | 46 +++++ 15 files changed, 732 insertions(+), 16 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification/ErrorNotification.tsx create mode 100644 src/components/ErrorNotification/index.ts 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/Errors.ts create mode 100644 src/types/FilterType.ts create mode 100644 src/types/Todo.ts create mode 100644 src/types/htmlFormElements.ts create mode 100644 src/types/rest.tsx create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..6b686f2d49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,170 @@ -/* 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'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import * as todoService from './api/todos'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +import { Header } from './components/Header/Header'; +import { Footer } from './components/Footer/Footer'; +import { UserWarning } from './UserWarning'; +import { Errors } from './types/Errors'; +import { ErrorNotification } from './components/ErrorNotification'; +import { FilterType } from './types/FilterType'; +import { TodoList } from './components/TodoList/TodoList'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(Errors.Empty); + const [filteredField, setFilteredField] = useState( + FilterType.All, + ); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const inputAddRef = useRef(null); + + const todosActiveNumber = useMemo(() => { + return todos.filter(todo => !todo.completed).length; + }, [todos]); + + const areAllTodosCompleted = useMemo(() => { + return todos.every(todo => todo.completed === true); + }, [todos]); + + useEffect(() => { + (async () => { + try { + const data = await todoService.getTodos(); + + setTodos(data); + } catch (err) { + setErrorMessage(Errors.UnableToLoad); + } + })(); + }, []); + + const onAddTodo = async (todoTitle: string) => { + setTempTodo({ + id: 0, + title: todoTitle, + completed: false, + userId: todoService.USER_ID, + }); + try { + const newTodo = await todoService.addTodo({ + title: todoTitle, + completed: false, + }); + + setTodos(prev => [...prev, newTodo]); + } catch (err) { + setErrorMessage(Errors.UnableToAdd); + inputAddRef?.current?.focus(); + throw err; + } finally { + setTempTodo(null); + } + }; + + const onRemoveTodo = async (todoId: number) => { + setLoadingTodoIds(prev => [...prev, todoId]); + try { + await todoService.deleteTodo(todoId); + + setTodos(prev => prev.filter(todo => todo.id !== todoId)); + } catch (err) { + setErrorMessage(Errors.UnableToDelete); + inputAddRef?.current?.focus(); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); + } + }; + + const onUpdateTodo = async (todoToUpdate: Todo) => { + setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); + try { + const updatedTodo = await todoService.updateTodo(todoToUpdate); + + setTodos(prev => + prev.map(todo => (todo.id === updatedTodo.id ? updatedTodo : todo)), + ); + } catch (err) { + setErrorMessage(Errors.UnableToUpdate); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoToUpdate.id)); + } + }; + + const onToggleAll = async () => { + if (todosActiveNumber > 0) { + todos + .filter(todo => !todo.completed) + .forEach(item => onUpdateTodo({ ...item, completed: true })); + } else { + todos.forEach(todo => onUpdateTodo({ ...todo, completed: false })); + } + }; + + const onClearCompleted = async () => { + const completedTodo = todos.filter(todo => todo.completed); + + completedTodo.forEach(todo => onRemoveTodo(todo.id)); + }; + + const filteredTodos = todos.filter(todo => { + switch (filteredField) { + case FilterType.Active: + return !todo.completed; + case FilterType.Completed: + return todo.completed; + default: + return todos; + } + }); + + if (!todoService.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 || tempTodo) && ( + <> + +
+ + )} +
+ +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..0100558efb --- /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 = 2136; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const addTodo = (newTodo: Omit) => { + return client.post('/todos', { ...newTodo, userId: USER_ID }); +}; + +export function updateTodo(todo: Todo) { + return client.patch(`/todos/${todo.id}`, todo); +} diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..af8d3eeef9 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames'; +import { Errors } from '../../types/Errors'; +import { Dispatch, SetStateAction, useEffect } from 'react'; + +type Props = { + error: Errors; + setErrorMessage: Dispatch>; +}; +export const ErrorNotification: React.FC = props => { + const { error, setErrorMessage } = props; + + useEffect(() => { + if (error === Errors.Empty) { + return; + } + + const timerId = setTimeout(() => { + setErrorMessage(Errors.Empty); + }, 3000); + + return () => { + clearTimeout(timerId); + }; + }, [error, setErrorMessage]); + + return ( +
+
+ ); +}; diff --git a/src/components/ErrorNotification/index.ts b/src/components/ErrorNotification/index.ts new file mode 100644 index 0000000000..8cb4787920 --- /dev/null +++ b/src/components/ErrorNotification/index.ts @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..debe17876d --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; + +import { Dispatch, SetStateAction } from 'react'; +import { Todo } from '../../types/Todo'; +import { FilterType } from '../../types/FilterType'; + +type Props = { + todos: Todo[]; + activeTodo: number; + filteredField: FilterType; + setFilteredField: Dispatch>; + onClearCompleted: () => Promise; +}; + +export const Footer: React.FC = ({ + todos, + activeTodo, + filteredField, + setFilteredField, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..dfd0eaf4c7 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,81 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import cn from 'classnames'; +import { Errors } from '../../types/Errors'; + +type Props = { + setErrorMessage: Dispatch>; + onAddTodo: (value: string) => Promise; + isInputDisabled: boolean; + onToggleAll: () => Promise; + todosLength: number; + inputRef: React.RefObject | null; + areAllTodosCompleted: boolean; +}; + +export const Header: React.FC = props => { + const { + setErrorMessage, + onAddTodo, + isInputDisabled, + onToggleAll, + todosLength, + inputRef, + areAllTodosCompleted, + } = props; + const [inputValue, setInputValue] = useState(''); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (inputValue.trim() === '') { + setErrorMessage(Errors.EmptyTitle); + + return; + } + + try { + await onAddTodo(inputValue.trim()); + setInputValue(''); + } catch (err) {} + }; + + useEffect(() => { + inputRef?.current?.focus(); + }, [todosLength, inputRef]); + + useEffect(() => { + if (!isInputDisabled) { + inputRef?.current?.focus(); + } + }, [isInputDisabled, inputRef]); + + return ( +
+ {todosLength !== 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..e54423a587 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,123 @@ +import cn from 'classnames'; +import { Todo } from '../../types/Todo'; +import { Dispatch, SetStateAction, useRef, useState } from 'react'; +import { SubmitFormEvent } from '../../types/htmlFormElements'; + +type Props = { + isLoading?: boolean; + todo: Todo; + onRemoveTodo: (todoId: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; + isInEditMode?: boolean; + setEditedTodoId: Dispatch>; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + onRemoveTodo, + onUpdateTodo, + isInEditMode, + setEditedTodoId, +}) => { + const [todoTitleValue, setTodoTitleValue] = useState(todo.title); + + const inputRef = useRef(null); + + const onCheckTodo = () => { + const todoToUpdate = { ...todo, completed: !todo.completed }; + + onUpdateTodo(todoToUpdate); + }; + + const onDoubleClick = () => { + setEditedTodoId(todo.id); + }; + + const onBlur = async (event: SubmitFormEvent) => { + event.preventDefault(); + + const normalizedTitle = todoTitleValue.trim(); + + if (normalizedTitle === todo.title) { + setEditedTodoId(null); + + return; + } + + try { + if (normalizedTitle === '') { + await onRemoveTodo(todo.id); + } else { + await onUpdateTodo({ ...todo, title: todoTitleValue.trim() }); + } + + setEditedTodoId(null); + } catch (err) { + inputRef?.current?.focus(); + } + }; + + const onKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditedTodoId(null); + setTodoTitleValue(todo.title); + } + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + {isInEditMode ? ( +
+ setTodoTitleValue(event?.target.value)} + onKeyUp={onKeyUp} + ref={inputRef} + /> +
+ ) : ( + <> + + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..490cbad71f --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { Todo } from '../../types/Todo'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; + +type Props = { + isLoading?: boolean; + todos: Todo[]; + onRemoveTodo: (todoId: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; + tempTodo: Todo | null; + loadingTodoIds: number[]; +}; + +export const TodoList: React.FC = ({ + todos, + onRemoveTodo, + onUpdateTodo, + tempTodo, + loadingTodoIds, +}) => { + const [editedTodoId, setEditedTodoId] = useState(null); + + return ( + +
+ {todos.map(todo => ( + + + + ))} + {tempTodo && ( + + + + )} +
+
+ ); +}; diff --git a/src/styles/index.scss b/src/styles/index.scss index bccd80c8bc..f2f19f4f7f 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -23,3 +23,50 @@ body { @import "./todoapp"; @import "./todo"; @import "./filter"; + +.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/types/Errors.ts b/src/types/Errors.ts new file mode 100644 index 0000000000..b0f28f9f6d --- /dev/null +++ b/src/types/Errors.ts @@ -0,0 +1,8 @@ +export enum Errors { + Empty = '', + UnableToLoad = 'Unable to load todos', + EmptyTitle = 'Title should not be empty', + UnableToAdd = 'Unable to add a todo', + UnableToDelete = 'Unable to delete a todo', + UnableToUpdate = 'Unable to update a todo', +} diff --git a/src/types/FilterType.ts b/src/types/FilterType.ts new file mode 100644 index 0000000000..1c3630f736 --- /dev/null +++ b/src/types/FilterType.ts @@ -0,0 +1,5 @@ +export enum FilterType { + Completed = 'Completed', + Active = 'Active', + All = 'All', +} 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/htmlFormElements.ts b/src/types/htmlFormElements.ts new file mode 100644 index 0000000000..63e88781e2 --- /dev/null +++ b/src/types/htmlFormElements.ts @@ -0,0 +1,3 @@ +export type SubmitFormEvent = + | React.FocusEvent + | React.FormEvent; diff --git a/src/types/rest.tsx b/src/types/rest.tsx new file mode 100644 index 0000000000..a94f5bb8c6 --- /dev/null +++ b/src/types/rest.tsx @@ -0,0 +1,80 @@ +// const updateTodo = (itemId, updatedTitle) => { +// const unmodifiedTodo = todos.find(todo => todo.id === itemId); + +// if (!unmodifiedTodo) { +// endChaning(itemId); + +// return; +// } + +// if (updatedTitle === unmodifiedTodo.title) { +// return; +// } + +// const updatedTodo = { +// ...unmodifiedTodo, +// title: updatedTitle !== null ? updatedTitle.trim() : unmodifiedTodo.title, +// completed: +// updatedTitle === null +// ? !unmodifiedTodo.completed +// : unmodifiedTodo.completed, +// }; + +// if (updatedTitle !== null && updatedTodo.title === '') { +// return deleteTodo(itemId); +// } + +// setTodos(prev => +// prev.map(todo => (todo.id === itemId ? updatedTodo : todo)), +// ); + +// return todoService +// .updateTodo(updateTodo) +// .then(res => +// setTodos(prev => prev.map(todo => (todo.id === itemId ? res : todo))), +// ) +// .catch(err => { +// setTodos(prev => +// prev.map(todo => (todo.id === itemId ? unmodifiedTodo : todo)), +// ); +// showErrorMes('Unable to update a todo'); +// throw err; +// }) +// .finally(() => endChaning(itemId)); +// }; + +// const clearComleted = () => { +// const completedIds = todos +// .filter(todo => todo.completed) +// .map(todo => todo.id); + +// completedIds.forEach(id => deleteTodo(id)); +// }; + +// function updateTodo(todoToUpdate: Todo) { +// postService.updateTodo(todoToUpdate).then(updatedTodo => { +// setSelectedTodo(null); + +// setTodos(currentTodos => { +// return currentTodos.map(todo => +// todo.id === updatedTodo.id ? updatedTodo : todo, +// ); +// }); +// }); +// } + +// function updatePost(postToUpdate: Post) { +// setErrorMessage(''); + +// return postService.updatePost(postToUpdate) +// .then(updatedPost => { +// setSelectedPost(null); + +// setPosts(currentPosts => { +// return currentPosts.map(post => +// post.id === updatedPost.id ? updatedPost : post, +// ); +// }); +// }) +// .catch(() => setErrorMessage('Failed to update post')); +// } 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 e9f2932dda4c71d29877e6cc98490980bb279b96 Mon Sep 17 00:00:00 2001 From: Nataliia Fedorchuk Date: Mon, 23 Dec 2024 21:55:02 +0200 Subject: [PATCH 2/5] Update src/components/Header/Header.tsx If I do, the tests fail Co-authored-by: VolodymyrKirichenko <109915550+VolodymyrKirichenko@users.noreply.github.com> --- src/components/Header/Header.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index dfd0eaf4c7..ea7e1f65b3 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -33,10 +33,8 @@ export const Header: React.FC = props => { return; } - try { await onAddTodo(inputValue.trim()); setInputValue(''); - } catch (err) {} }; useEffect(() => { From 4f5787ba708c2ca10600f022a9e6c5cc23280c2c Mon Sep 17 00:00:00 2001 From: Nataliia Fedorchuk Date: Thu, 26 Dec 2024 13:44:41 +0200 Subject: [PATCH 3/5] add changes after comments --- src/App.tsx | 163 ++++++++++-------- src/api/todos.ts | 14 ++ .../ErrorNotification/ErrorNotification.tsx | 2 +- src/components/Footer/Footer.tsx | 2 +- src/components/Header/Header.tsx | 7 +- src/types/rest.tsx | 80 --------- src/utils/fetchClient.ts | 1 + 7 files changed, 110 insertions(+), 159 deletions(-) delete mode 100644 src/types/rest.tsx diff --git a/src/App.tsx b/src/App.tsx index 6b686f2d49..adfb3f0e72 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, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import * as todoService from './api/todos'; import { Todo } from './types/Todo'; @@ -31,7 +37,7 @@ export const App: React.FC = () => { }, [todos]); useEffect(() => { - (async () => { + const getAllTodos = async () => { try { const data = await todoService.getTodos(); @@ -39,89 +45,94 @@ export const App: React.FC = () => { } catch (err) { setErrorMessage(Errors.UnableToLoad); } - })(); + }; + + getAllTodos(); }, []); - const onAddTodo = async (todoTitle: string) => { - setTempTodo({ - id: 0, - title: todoTitle, - completed: false, - userId: todoService.USER_ID, - }); - try { - const newTodo = await todoService.addTodo({ + const handleAddTodo = useCallback( + async (todoTitle: string) => { + setTempTodo({ + id: 0, title: todoTitle, completed: false, + userId: todoService.USER_ID, }); - setTodos(prev => [...prev, newTodo]); - } catch (err) { - setErrorMessage(Errors.UnableToAdd); - inputAddRef?.current?.focus(); - throw err; - } finally { - setTempTodo(null); - } - }; - - const onRemoveTodo = async (todoId: number) => { - setLoadingTodoIds(prev => [...prev, todoId]); - try { - await todoService.deleteTodo(todoId); - - setTodos(prev => prev.filter(todo => todo.id !== todoId)); - } catch (err) { - setErrorMessage(Errors.UnableToDelete); - inputAddRef?.current?.focus(); - throw err; - } finally { - setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); - } - }; - - const onUpdateTodo = async (todoToUpdate: Todo) => { - setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); - try { - const updatedTodo = await todoService.updateTodo(todoToUpdate); - - setTodos(prev => - prev.map(todo => (todo.id === updatedTodo.id ? updatedTodo : todo)), - ); - } catch (err) { - setErrorMessage(Errors.UnableToUpdate); - throw err; - } finally { - setLoadingTodoIds(prev => prev.filter(id => id !== todoToUpdate.id)); - } - }; + try { + const newTodo = await todoService.addTodo({ + title: todoTitle, + completed: false, + }); + + setTodos(prev => [...prev, newTodo]); + } catch (err) { + setErrorMessage(Errors.UnableToAdd); + inputAddRef?.current?.focus(); + throw err; + } finally { + setTempTodo(null); + } + }, + [setTempTodo, setTodos, setErrorMessage, inputAddRef], + ); - const onToggleAll = async () => { + const handleRemoveTodo = useCallback( + async (todoId: number) => { + setLoadingTodoIds(prev => [...prev, todoId]); + try { + await todoService.deleteTodo(todoId); + + setTodos(prev => prev.filter(todo => todo.id !== todoId)); + } catch (err) { + setErrorMessage(Errors.UnableToDelete); + inputAddRef?.current?.focus(); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); + } + }, + [setLoadingTodoIds, setTodos, setErrorMessage, inputAddRef], + ); + + const handleUpdateTodo = useCallback( + async (todoToUpdate: Todo) => { + setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); + try { + const updatedTodo = await todoService.updateTodo(todoToUpdate); + + setTodos(prev => + prev.map(todo => (todo.id === updatedTodo.id ? updatedTodo : todo)), + ); + } catch (err) { + setErrorMessage(Errors.UnableToUpdate); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoToUpdate.id)); + } + }, + [setLoadingTodoIds, setTodos, setErrorMessage], + ); + + const handleToggleAll = useCallback(async () => { if (todosActiveNumber > 0) { todos .filter(todo => !todo.completed) - .forEach(item => onUpdateTodo({ ...item, completed: true })); + .forEach(item => handleUpdateTodo({ ...item, completed: true })); } else { - todos.forEach(todo => onUpdateTodo({ ...todo, completed: false })); + todos.forEach(todo => handleUpdateTodo({ ...todo, completed: false })); } - }; + }, [todos, handleUpdateTodo, todosActiveNumber]); - const onClearCompleted = async () => { + const handleClearCompleted = useCallback(async () => { const completedTodo = todos.filter(todo => todo.completed); - completedTodo.forEach(todo => onRemoveTodo(todo.id)); - }; - - const filteredTodos = todos.filter(todo => { - switch (filteredField) { - case FilterType.Active: - return !todo.completed; - case FilterType.Completed: - return todo.completed; - default: - return todos; - } - }); + completedTodo.forEach(todo => handleRemoveTodo(todo.id)); + }, [todos, handleRemoveTodo]); + + const filteredTodos = useMemo(() => { + return todoService.filterTodos(todos, filteredField); + }, [todos, filteredField]); if (!todoService.USER_ID) { return ; @@ -134,20 +145,20 @@ export const App: React.FC = () => {
- {(todos.length > 0 || tempTodo) && ( + {(!!todos.length || tempTodo) && ( <> @@ -156,7 +167,7 @@ export const App: React.FC = () => { filteredField={filteredField} setFilteredField={setFilteredField} activeTodo={todosActiveNumber} - onClearCompleted={onClearCompleted} + onClearCompleted={handleClearCompleted} /> )} diff --git a/src/api/todos.ts b/src/api/todos.ts index 0100558efb..c2fb9ef1e4 100644 --- a/src/api/todos.ts +++ b/src/api/todos.ts @@ -1,5 +1,6 @@ import { Todo } from '../types/Todo'; import { client } from '../utils/fetchClient'; +import { FilterType } from '../types/FilterType'; export const USER_ID = 2136; @@ -18,3 +19,16 @@ export const addTodo = (newTodo: Omit) => { export function updateTodo(todo: Todo) { return client.patch(`/todos/${todo.id}`, todo); } + +export const filterTodos = (todos: Todo[], filteredField: FilterType) => { + return todos.filter(todo => { + switch (filteredField) { + case FilterType.Active: + return !todo.completed; + case FilterType.Completed: + return todo.completed; + default: + return todos; + } + }); +}; diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx index af8d3eeef9..af12e23793 100644 --- a/src/components/ErrorNotification/ErrorNotification.tsx +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -10,7 +10,7 @@ export const ErrorNotification: React.FC = props => { const { error, setErrorMessage } = props; useEffect(() => { - if (error === Errors.Empty) { + if (!error.length) { return; } diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index debe17876d..3994830dad 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -29,7 +29,7 @@ export const Footer: React.FC = ({ {Object.values(FilterType).map(filter => ( = props => { return; } + try { await onAddTodo(inputValue.trim()); setInputValue(''); + } catch (err) { + // eslint-disable-next-line + console.error(err); + } }; useEffect(() => { @@ -49,7 +54,7 @@ export const Header: React.FC = props => { return (
- {todosLength !== 0 && ( + {!!todosLength && (