From 74d64c2db46e35a8a1bec343eb56d6af38e3f86c Mon Sep 17 00:00:00 2001 From: daviinnchi Date: Mon, 18 Sep 2023 09:02:02 +0200 Subject: [PATCH 01/10] Create todo app using API --- src/App.tsx | 160 ++++++++++++++++-- src/api/todos.ts | 23 +++ .../Contexts/ErrorMessageContext.tsx | 38 +++++ .../Contexts/LoadingTodosContext.tsx | 36 ++++ src/components/Contexts/TodosContext.tsx | 34 ++++ src/components/NewTodo/NewTodo.tsx | 145 ++++++++++++++++ src/components/Todo/TodoTask.tsx | 137 +++++++++++++++ src/components/TodoFilter/TodoFilter.tsx | 55 ++++++ src/components/TodoList/TodoList.tsx | 50 ++++++ src/index.tsx | 15 +- src/styles/todoapp.scss | 4 + src/types/LoadingTodo.ts | 4 + src/types/Todo.ts | 6 + src/types/UpdateCompleted.ts | 3 + src/types/UpdateTitle.ts | 3 + src/utils/Filters.ts | 5 + src/utils/countTodos.ts | 5 + src/utils/fetchClient.ts | 48 ++++++ src/utils/userToken.ts | 1 + 19 files changed, 754 insertions(+), 18 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Contexts/ErrorMessageContext.tsx create mode 100644 src/components/Contexts/LoadingTodosContext.tsx create mode 100644 src/components/Contexts/TodosContext.tsx create mode 100644 src/components/NewTodo/NewTodo.tsx create mode 100644 src/components/Todo/TodoTask.tsx create mode 100644 src/components/TodoFilter/TodoFilter.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/types/LoadingTodo.ts create mode 100644 src/types/Todo.ts create mode 100644 src/types/UpdateCompleted.ts create mode 100644 src/types/UpdateTitle.ts create mode 100644 src/utils/Filters.ts create mode 100644 src/utils/countTodos.ts create mode 100644 src/utils/fetchClient.ts create mode 100644 src/utils/userToken.ts diff --git a/src/App.tsx b/src/App.tsx index 5749bdf784..d1c43d11d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,150 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import classNames from 'classnames'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Todo } from './types/Todo'; +import { Filters } from './utils/Filters'; +import { NewTodo } from './components/NewTodo/NewTodo'; +import { TodoList } from './components/TodoList/TodoList'; +import { TodoFilter } from './components/TodoFilter/TodoFilter'; +import { + deleteTodo, + getTodos, +} from './api/todos'; +import { countTodos } from './utils/countTodos'; +import { + useTodos, +} from './components/Contexts/TodosContext'; +import { USER_ID } from './utils/userToken'; +import { + useLoadingTodos, +} from './components/Contexts/LoadingTodosContext'; +import { + useErrorMessage, +} from './components/Contexts/ErrorMessageContext'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const { todos, setTodos } = useTodos(); + + const [tempTodo, setTempTodo] = useState(null); + const { setLoadingTodos } = useLoadingTodos(); + const { + errorMessage, + setErrorMessage, + isErrorHidden, + setIsErrorHidden, + } = useErrorMessage(); + + const [filterParam, setFilterParam] = useState(Filters.All); + + const clearCompletedTodos = () => { + const completedTodos = countTodos(todos, true); + + completedTodos.forEach(({ id }) => { + setLoadingTodos(prev => [...prev, { todoId: id, isLoading: true }]); + deleteTodo(id) + .then(() => { + setTodos(prev => prev.filter((todo) => id !== todo.id)); + }) + .catch((error) => { + setErrorMessage(JSON.parse(error.message).error); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }) + .finally(() => { + setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); + }); + }); + }; + + useEffect(() => { + getTodos(USER_ID) + .then(setTodos) + .catch((error) => { + setErrorMessage(JSON.parse(error.message).error); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }); + }, []); + + const visibleTodos = useMemo(() => { + switch (filterParam) { + case Filters.Active: + return todos.filter(({ completed }) => !completed); + + case Filters.Completed: + return todos.filter(({ completed }) => completed); + + case Filters.All: + return todos; + + default: + return todos; + } + }, [filterParam, todos]); return ( -
-

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

+
+

todos

+ +
+ + +
+ + + {tempTodo && ( +
+ -

Styles are already copied

-
+ {tempTodo.title} + + +
+
+
+
+
+ )} +
+ + {!!todos?.length && ( + setFilterParam(newFilter)} + clearCompleted={clearCompletedTodos} + /> + )} + + +
+
+ ); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..2961061c39 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,23 @@ +import { Todo } from '../types/Todo'; +import { UpdateCompleted } from '../types/UpdateCompleted'; +import { UpdateTitle } from '../types/UpdateTitle'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +// Add more methods here + +export const addTodo = (userId: number, newTodo: Todo) => { + return client.post(`/todos?userId=${userId}`, newTodo); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, + data: UpdateCompleted | UpdateTitle) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/components/Contexts/ErrorMessageContext.tsx b/src/components/Contexts/ErrorMessageContext.tsx new file mode 100644 index 0000000000..f6d44ac3c4 --- /dev/null +++ b/src/components/Contexts/ErrorMessageContext.tsx @@ -0,0 +1,38 @@ +import React, { useContext, useState } from 'react'; + +interface ErrorContextType { + errorMessage: string, + setErrorMessage: React.Dispatch> + isErrorHidden: boolean, + setIsErrorHidden: React.Dispatch>, +} + +export const ErrorMessageContext = React.createContext({} as ErrorContextType); + +type Props = { + children: React.ReactNode, +}; + +export const ErrorMessageContextProvider: React.FC = ({ children }) => { + const [errorMessage, setErrorMessage] = useState(''); + const [isErrorHidden, setIsErrorHidden] = useState(true); + + const value = { + errorMessage, + setErrorMessage, + isErrorHidden, + setIsErrorHidden, + }; + + return ( + + {children} + + ); +}; + +export const useErrorMessage = () => { + const errorMessage = useContext(ErrorMessageContext); + + return errorMessage; +}; diff --git a/src/components/Contexts/LoadingTodosContext.tsx b/src/components/Contexts/LoadingTodosContext.tsx new file mode 100644 index 0000000000..63cee6111c --- /dev/null +++ b/src/components/Contexts/LoadingTodosContext.tsx @@ -0,0 +1,36 @@ +import React, { useContext, useState } from 'react'; +import { LoadingTodo } from '../../types/LoadingTodo'; + +interface LoadingTodosContextType { + loadingTodos: LoadingTodo[], + setLoadingTodos: React.Dispatch> +} + +export const LoadingTodosContext = React.createContext( + {} as LoadingTodosContextType, +); + +type Props = { + children: React.ReactNode, +}; + +export const LoadingTodosContextProvider: React.FC = ({ children }) => { + const [loadingTodos, setLoadingTodos] = useState([]); + + const value = { + loadingTodos, + setLoadingTodos, + }; + + return ( + + {children} + + ); +}; + +export const useLoadingTodos = () => { + const loadingTodos = useContext(LoadingTodosContext); + + return loadingTodos; +}; diff --git a/src/components/Contexts/TodosContext.tsx b/src/components/Contexts/TodosContext.tsx new file mode 100644 index 0000000000..69e426b5c8 --- /dev/null +++ b/src/components/Contexts/TodosContext.tsx @@ -0,0 +1,34 @@ +import React, { useContext, useState } from 'react'; +import { Todo } from '../../types/Todo'; + +interface TodosContextType { + todos: Todo[], + setTodos: React.Dispatch> +} + +export const TodosContext = React.createContext({} as TodosContextType); + +type Props = { + children: React.ReactNode, +}; + +export const TodosContextProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + + const value = { + todos, + setTodos, + }; + + return ( + + {children} + + ); +}; + +export const useTodos = () => { + const todos = useContext(TodosContext); + + return todos; +}; diff --git a/src/components/NewTodo/NewTodo.tsx b/src/components/NewTodo/NewTodo.tsx new file mode 100644 index 0000000000..51567a8226 --- /dev/null +++ b/src/components/NewTodo/NewTodo.tsx @@ -0,0 +1,145 @@ +import classNames from 'classnames'; +import { useState } from 'react'; +import { countTodos } from '../../utils/countTodos'; +import { useTodos } from '../Contexts/TodosContext'; +import { useErrorMessage } from '../Contexts/ErrorMessageContext'; +import { USER_ID } from '../../utils/userToken'; +import { useLoadingTodos } from '../Contexts/LoadingTodosContext'; +import { addTodo, updateTodo } from '../../api/todos'; +import { Todo } from '../../types/Todo'; + +type Props = { + onWaiting: (tempTodo: Todo | null) => void, +}; + +export const NewTodo: React.FC = ({ onWaiting }) => { + const { todos, setTodos } = useTodos(); + const { setLoadingTodos } = useLoadingTodos(); + + const { setErrorMessage, setIsErrorHidden } = useErrorMessage(); + const [newTitle, setNewTitle] = useState(''); + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!newTitle) { + setErrorMessage('Title can\'t be empty'); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + + return; + } + + onWaiting({ + id: 0, + completed: false, + title: newTitle, + userId: USER_ID, + }); + + addTodo(USER_ID, { + id: 0, + completed: false, + title: newTitle, + userId: USER_ID, + }) + .then(newTodo => { + setTodos(prev => [...prev, newTodo]); + }) + .catch((error) => { + setErrorMessage(JSON.parse(error.message).error); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }) + .finally(() => { + onWaiting(null); + setNewTitle(''); + }); + }; + + const completeAll = () => { + const uncompletedTodos = countTodos(todos, false); + + if (!uncompletedTodos.length) { + todos.forEach(({ id, completed }) => { + setLoadingTodos(prev => [...prev, { todoId: id, isLoading: true }]); + updateTodo(id, { completed: !completed }) + .then((todo) => { + setTodos(prev => prev.map((currentTodo) => { + if (currentTodo.id === id) { + return todo; + } + + return currentTodo; + })); + }) + .catch((error) => { + setErrorMessage(JSON.parse(error.message).error); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }) + .finally(() => { + setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); + }); + }); + + return; + } + + uncompletedTodos.forEach(({ id, completed }) => { + setLoadingTodos(prev => [...prev, { todoId: id, isLoading: true }]); + updateTodo(id, { completed: !completed }) + .then((todo) => { + setTodos(prev => prev.map((currentTodo) => { + if (currentTodo.id === id) { + return todo; + } + + return currentTodo; + })); + }) + .catch((error) => { + setErrorMessage(JSON.parse(error.message).error); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }) + .finally(() => { + setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); + }); + }); + }; + + return ( +
+
+ ); +}; diff --git a/src/components/Todo/TodoTask.tsx b/src/components/Todo/TodoTask.tsx new file mode 100644 index 0000000000..69f1aba78f --- /dev/null +++ b/src/components/Todo/TodoTask.tsx @@ -0,0 +1,137 @@ +import classNames from 'classnames'; +import { useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { useTodos } from '../Contexts/TodosContext'; +import { useLoadingTodos } from '../Contexts/LoadingTodosContext'; +import { updateTodo } from '../../api/todos'; +import { useErrorMessage } from '../Contexts/ErrorMessageContext'; + +type Props = { + todo: Todo, + onDelete: (todoId: number) => void, +}; + +export const TodoTask: React.FC = ({ + todo, + onDelete, +}) => { + const { setTodos } = useTodos(); + const { loadingTodos, setLoadingTodos } = useLoadingTodos(); + const { setErrorMessage, setIsErrorHidden } = useErrorMessage(); + + const [editingTodoTitle, setEditingTodoTitle] = useState(todo.title); + const [isEditing, setIsEditing] = useState(false); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + setLoadingTodos(prev => [...prev, { todoId: todo.id, isLoading: true }]); + + if (editingTodoTitle) { + updateTodo(todo.id, { title: editingTodoTitle }) + .then(updatedTodo => { + setTodos(prev => prev.map((currentTodo) => { + if (currentTodo.id === updatedTodo.id) { + return updatedTodo; + } + + return currentTodo; + })); + }) + .catch((error) => { + setErrorMessage(JSON.parse(error.message).error); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }) + .finally(() => { + setLoadingTodos(prev => prev.filter(newTodo => ( + newTodo.todoId !== todo.id))); + + setIsEditing(false); + }); + } + }; + + const onComplete = (todoId: number, + event: React.ChangeEvent) => { + setLoadingTodos(prev => [...prev, { todoId, isLoading: true }]); + updateTodo(todoId, { completed: event.target.checked }) + .then((newTodo) => { + setTodos(prev => prev.map((currentTodo) => { + if (currentTodo.id === todoId) { + return newTodo; + } + + return currentTodo; + })); + }) + .catch((error) => { + setErrorMessage(JSON.parse(error.message).error); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }) + .finally(() => { + setLoadingTodos(prev => prev.filter(currentTodo => ( + currentTodo.todoId !== todoId))); + }); + }; + + return ( +
+ + {isEditing ? ( +
+ setEditingTodoTitle(event.target.value)} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + )} + +
( + todoId === todo.id))?.isLoading, + }, + )} + > +
+
+
+
+ ); +}; diff --git a/src/components/TodoFilter/TodoFilter.tsx b/src/components/TodoFilter/TodoFilter.tsx new file mode 100644 index 0000000000..bbfde44ab7 --- /dev/null +++ b/src/components/TodoFilter/TodoFilter.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import classNames from 'classnames'; +import { countTodos } from '../../utils/countTodos'; +import { Filters } from '../../utils/Filters'; +import { Todo } from '../../types/Todo'; + +type Props = { + todos: Todo[], + filterParam: string, + onFilterChange: (newFilter:Filters) => void, + clearCompleted: () => void, +}; + +export const TodoFilter: React.FC = ({ + todos, + filterParam, + onFilterChange, + clearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..49efaf6a71 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoTask } from '../Todo/TodoTask'; +import { useLoadingTodos } from '../Contexts/LoadingTodosContext'; +import { deleteTodo } from '../../api/todos'; +import { useTodos } from '../Contexts/TodosContext'; +import { useErrorMessage } from '../Contexts/ErrorMessageContext'; + +type Props = { + todos: Todo[] +}; + +export const TodoList: React.FC = ({ + todos, +}) => { + const { setTodos } = useTodos(); + const { setLoadingTodos } = useLoadingTodos(); + const { setErrorMessage, setIsErrorHidden } = useErrorMessage(); + + const onDelete = (todoId: number) => { + setLoadingTodos(prev => [...prev, { todoId, isLoading: true }]); + deleteTodo(todoId) + .then(() => { + setTodos(prev => prev.filter(({ id }) => id !== todoId)); + }) + .catch((error) => { + setErrorMessage(JSON.parse(error.message).error); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }) + .finally(() => { + setLoadingTodos(prev => prev.filter(todo => todo.todoId !== todoId)); + }); + }; + + return ( + <> + {todos?.map(todo => ( + + ))} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index 7de19e0c70..8bdd4aabd3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,19 @@ import '@fortawesome/fontawesome-free/css/all.css'; import './styles/index.scss'; import { App } from './App'; +import { TodosContextProvider } from './components/Contexts/TodosContext'; +import { LoadingTodosContextProvider } from + './components/Contexts/LoadingTodosContext'; +import { ErrorMessageContextProvider } from + './components/Contexts/ErrorMessageContext'; createRoot(document.getElementById('root') as HTMLDivElement) - .render(); + .render( + + + + + + + , + ); diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index 836166156b..69a09c44c1 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -129,5 +129,9 @@ &:active { text-decoration: none; } + + &:disabled { + opacity: 0; + } } } diff --git a/src/types/LoadingTodo.ts b/src/types/LoadingTodo.ts new file mode 100644 index 0000000000..1db63f5aac --- /dev/null +++ b/src/types/LoadingTodo.ts @@ -0,0 +1,4 @@ +export interface LoadingTodo { + todoId: number, + isLoading: boolean, +} 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/UpdateCompleted.ts b/src/types/UpdateCompleted.ts new file mode 100644 index 0000000000..97fc841fa4 --- /dev/null +++ b/src/types/UpdateCompleted.ts @@ -0,0 +1,3 @@ +export interface UpdateCompleted { + completed: boolean, +} diff --git a/src/types/UpdateTitle.ts b/src/types/UpdateTitle.ts new file mode 100644 index 0000000000..c4ed22e9ef --- /dev/null +++ b/src/types/UpdateTitle.ts @@ -0,0 +1,3 @@ +export interface UpdateTitle { + title: string +} diff --git a/src/utils/Filters.ts b/src/utils/Filters.ts new file mode 100644 index 0000000000..6c26945612 --- /dev/null +++ b/src/utils/Filters.ts @@ -0,0 +1,5 @@ +export enum Filters { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/utils/countTodos.ts b/src/utils/countTodos.ts new file mode 100644 index 0000000000..dc511ea35d --- /dev/null +++ b/src/utils/countTodos.ts @@ -0,0 +1,5 @@ +import { Todo } from '../types/Todo'; + +export function countTodos(todos: Todo[], isCompleted: boolean) { + return todos.filter(({ completed }) => completed === isCompleted); +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..de511fb8e2 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,48 @@ +/* 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', + }; + } + + // we wait for testing purpose to see loaders + return wait(300) + .then(() => fetch(BASE_URL + url, options)) + .then(response => { + if (!response.ok) { + return response.text().then(error => { + throw new Error(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'), +}; diff --git a/src/utils/userToken.ts b/src/utils/userToken.ts new file mode 100644 index 0000000000..c2a8b3afc9 --- /dev/null +++ b/src/utils/userToken.ts @@ -0,0 +1 @@ +export const USER_ID = 11437; From cfb8bcb053305d920f6d32154d52e6994281b6f4 Mon Sep 17 00:00:00 2001 From: daviinnchi Date: Mon, 18 Sep 2023 09:03:46 +0200 Subject: [PATCH 02/10] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index af7dae81f6..c8f91959fe 100644 --- a/README.md +++ b/README.md @@ -41,4 +41,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://davinnchii.github.io/react_todo-app-with-api/) and add it to the PR description. From e8c4b7cbf16069da9a5012d91f84ffd9789b67f8 Mon Sep 17 00:00:00 2001 From: daviinnchi Date: Thu, 21 Sep 2023 11:39:41 +0200 Subject: [PATCH 03/10] fix comments, refactored requests to async await --- src/api/todos.ts | 2 - src/components/NewTodo/NewTodo.tsx | 107 ++++++++++----------- src/components/Todo/TodoTask.tsx | 138 +++++++++++++++++---------- src/components/TodoList/TodoList.tsx | 28 ------ src/utils/fetchClient.ts | 26 ++--- 5 files changed, 153 insertions(+), 148 deletions(-) diff --git a/src/api/todos.ts b/src/api/todos.ts index 2961061c39..e6856d7a10 100644 --- a/src/api/todos.ts +++ b/src/api/todos.ts @@ -7,8 +7,6 @@ export const getTodos = (userId: number) => { return client.get(`/todos?userId=${userId}`); }; -// Add more methods here - export const addTodo = (userId: number, newTodo: Todo) => { return client.post(`/todos?userId=${userId}`, newTodo); }; diff --git a/src/components/NewTodo/NewTodo.tsx b/src/components/NewTodo/NewTodo.tsx index 51567a8226..ad2d5586d6 100644 --- a/src/components/NewTodo/NewTodo.tsx +++ b/src/components/NewTodo/NewTodo.tsx @@ -19,7 +19,7 @@ export const NewTodo: React.FC = ({ onWaiting }) => { const { setErrorMessage, setIsErrorHidden } = useErrorMessage(); const [newTitle, setNewTitle] = useState(''); - const onSubmit = (event: React.FormEvent) => { + const onSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!newTitle) { setErrorMessage('Title can\'t be empty'); @@ -39,84 +39,81 @@ export const NewTodo: React.FC = ({ onWaiting }) => { userId: USER_ID, }); - addTodo(USER_ID, { + const newTodo = await addTodo(USER_ID, { id: 0, completed: false, title: newTitle, userId: USER_ID, - }) - .then(newTodo => { - setTodos(prev => [...prev, newTodo]); - }) - .catch((error) => { - setErrorMessage(JSON.parse(error.message).error); - setIsErrorHidden(false); + }); - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); - }) - .finally(() => { - onWaiting(null); - setNewTitle(''); - }); + try { + setTodos(prev => [...prev, newTodo]); + } catch { + setErrorMessage('Unable to add todo'); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + } + + onWaiting(null); + setNewTitle(''); }; const completeAll = () => { const uncompletedTodos = countTodos(todos, false); if (!uncompletedTodos.length) { - todos.forEach(({ id, completed }) => { + todos.forEach(async ({ id, completed }) => { setLoadingTodos(prev => [...prev, { todoId: id, isLoading: true }]); - updateTodo(id, { completed: !completed }) - .then((todo) => { - setTodos(prev => prev.map((currentTodo) => { - if (currentTodo.id === id) { - return todo; - } - - return currentTodo; - })); - }) - .catch((error) => { - setErrorMessage(JSON.parse(error.message).error); - setIsErrorHidden(false); - - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); - }) - .finally(() => { - setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); - }); - }); + const updatedTodo = await updateTodo(id, { completed: !completed }); - return; - } - - uncompletedTodos.forEach(({ id, completed }) => { - setLoadingTodos(prev => [...prev, { todoId: id, isLoading: true }]); - updateTodo(id, { completed: !completed }) - .then((todo) => { + try { setTodos(prev => prev.map((currentTodo) => { if (currentTodo.id === id) { - return todo; + return updatedTodo; } return currentTodo; })); - }) - .catch((error) => { - setErrorMessage(JSON.parse(error.message).error); + } catch { + setErrorMessage('Something went wrong'); setIsErrorHidden(false); setTimeout(() => { setIsErrorHidden(true); }, 3000); - }) - .finally(() => { - setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); - }); + } + + setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); + }); + + return; + } + + uncompletedTodos.forEach(async ({ id, completed }) => { + setLoadingTodos(prev => [...prev, { todoId: id, isLoading: true }]); + const updatedTodo = await updateTodo(id, { completed: !completed }); + + try { + setTodos(prev => prev.map((currentTodo) => { + if (currentTodo.id === id) { + return updatedTodo; + } + + return currentTodo; + })); + } catch { + setErrorMessage('Something went wrong'); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + } + + setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); }); }; diff --git a/src/components/Todo/TodoTask.tsx b/src/components/Todo/TodoTask.tsx index 69f1aba78f..ca5bc735d3 100644 --- a/src/components/Todo/TodoTask.tsx +++ b/src/components/Todo/TodoTask.tsx @@ -3,17 +3,15 @@ import { useState } from 'react'; import { Todo } from '../../types/Todo'; import { useTodos } from '../Contexts/TodosContext'; import { useLoadingTodos } from '../Contexts/LoadingTodosContext'; -import { updateTodo } from '../../api/todos'; +import { deleteTodo, updateTodo } from '../../api/todos'; import { useErrorMessage } from '../Contexts/ErrorMessageContext'; type Props = { todo: Todo, - onDelete: (todoId: number) => void, }; export const TodoTask: React.FC = ({ todo, - onDelete, }) => { const { setTodos } = useTodos(); const { loadingTodos, setLoadingTodos } = useLoadingTodos(); @@ -22,63 +20,102 @@ export const TodoTask: React.FC = ({ const [editingTodoTitle, setEditingTodoTitle] = useState(todo.title); const [isEditing, setIsEditing] = useState(false); - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setLoadingTodos(prev => [...prev, { todoId: todo.id, isLoading: true }]); if (editingTodoTitle) { - updateTodo(todo.id, { title: editingTodoTitle }) - .then(updatedTodo => { - setTodos(prev => prev.map((currentTodo) => { - if (currentTodo.id === updatedTodo.id) { - return updatedTodo; - } - - return currentTodo; - })); - }) - .catch((error) => { - setErrorMessage(JSON.parse(error.message).error); - setIsErrorHidden(false); - - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); - }) - .finally(() => { - setLoadingTodos(prev => prev.filter(newTodo => ( - newTodo.todoId !== todo.id))); - - setIsEditing(false); - }); - } - }; + const updatedTodo = await updateTodo(todo.id, + { title: editingTodoTitle }); - const onComplete = (todoId: number, - event: React.ChangeEvent) => { - setLoadingTodos(prev => [...prev, { todoId, isLoading: true }]); - updateTodo(todoId, { completed: event.target.checked }) - .then((newTodo) => { + try { setTodos(prev => prev.map((currentTodo) => { - if (currentTodo.id === todoId) { - return newTodo; + if (currentTodo.id === updatedTodo.id) { + return updatedTodo; } return currentTodo; })); - }) - .catch((error) => { - setErrorMessage(JSON.parse(error.message).error); + } catch (error) { + setErrorMessage('Encounted error while trying to update Todo'); setIsErrorHidden(false); setTimeout(() => { setIsErrorHidden(true); }, 3000); - }) - .finally(() => { - setLoadingTodos(prev => prev.filter(currentTodo => ( - currentTodo.todoId !== todoId))); - }); + } + } + + if (!editingTodoTitle) { + await deleteTodo(todo.id); + try { + setTodos(prev => prev.filter(({ id }) => id !== todo.id)); + } catch { + setErrorMessage('Encounted error while trying to delete Todo'); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + } + + setLoadingTodos(prev => prev.filter(newTodo => ( + newTodo.todoId !== todo.id))); + + setIsEditing(false); + } + + setLoadingTodos(prev => prev.filter(newTodo => ( + newTodo.todoId !== todo.id))); + + setIsEditing(false); + }; + + const onComplete = async (todoId: number, + event: React.ChangeEvent) => { + setLoadingTodos(prev => [...prev, { todoId, isLoading: true }]); + const updatedTodo = await updateTodo(todoId, + { completed: event.target.checked }); + + // eslint-disable-next-line no-console + console.log(todoId); + + try { + setTodos(prev => prev.map(currentTodo => { + if (currentTodo.id === todoId) { + return updatedTodo; + } + + return currentTodo; + })); + } catch { + setErrorMessage('There is an error when completing todo'); + setIsErrorHidden(false); + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + } + + setLoadingTodos(prev => prev.filter(currentTodo => ( + currentTodo.todoId !== todoId))); + }; + + const onDelete = async (todoId: number) => { + setLoadingTodos(prev => [...prev, { todoId, isLoading: true }]); + await deleteTodo(todoId); + + try { + setTodos(prev => prev.filter(({ id }) => id !== todoId)); + } catch { + setErrorMessage('There is an error when deleting todo'); + setIsErrorHidden(false); + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + } + + setLoadingTodos(prev => prev.filter(currentTodo => ( + currentTodo.todoId !== todoId))); }; return ( @@ -91,6 +128,7 @@ export const TodoTask: React.FC = ({ onComplete(todo.id, event)} /> @@ -122,12 +160,10 @@ export const TodoTask: React.FC = ({ )} -
( - todoId === todo.id))?.isLoading, - }, - )} +
( + todoId === todo.id))?.isLoading, + })} >
diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx index 49efaf6a71..0c0e20ebd1 100644 --- a/src/components/TodoList/TodoList.tsx +++ b/src/components/TodoList/TodoList.tsx @@ -1,10 +1,6 @@ import React from 'react'; import { Todo } from '../../types/Todo'; import { TodoTask } from '../Todo/TodoTask'; -import { useLoadingTodos } from '../Contexts/LoadingTodosContext'; -import { deleteTodo } from '../../api/todos'; -import { useTodos } from '../Contexts/TodosContext'; -import { useErrorMessage } from '../Contexts/ErrorMessageContext'; type Props = { todos: Todo[] @@ -13,34 +9,10 @@ type Props = { export const TodoList: React.FC = ({ todos, }) => { - const { setTodos } = useTodos(); - const { setLoadingTodos } = useLoadingTodos(); - const { setErrorMessage, setIsErrorHidden } = useErrorMessage(); - - const onDelete = (todoId: number) => { - setLoadingTodos(prev => [...prev, { todoId, isLoading: true }]); - deleteTodo(todoId) - .then(() => { - setTodos(prev => prev.filter(({ id }) => id !== todoId)); - }) - .catch((error) => { - setErrorMessage(JSON.parse(error.message).error); - setIsErrorHidden(false); - - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); - }) - .finally(() => { - setLoadingTodos(prev => prev.filter(todo => todo.todoId !== todoId)); - }); - }; - return ( <> {todos?.map(todo => ( diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts index de511fb8e2..6f8682fc6d 100644 --- a/src/utils/fetchClient.ts +++ b/src/utils/fetchClient.ts @@ -11,11 +11,13 @@ function wait(delay: number) { // To have autocompletion and avoid mistypes type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; -function request( +async function request( url: string, method: RequestMethod = 'GET', data: any = null, // we can send any data to the server ): Promise { + await wait(300); + const options: RequestInit = { method }; if (data) { @@ -27,17 +29,17 @@ function request( } // we wait for testing purpose to see loaders - return wait(300) - .then(() => fetch(BASE_URL + url, options)) - .then(response => { - if (!response.ok) { - return response.text().then(error => { - throw new Error(error); - }); - } - - return response.json(); - }); + const response = await fetch(BASE_URL + url, options); + + if (!response.ok) { + const error = await response.text(); + + throw new Error(error); + } + + const resp = await response.json(); + + return resp; } export const client = { From 455b1f5732766c0a93f97e6c4c556e238d749757 Mon Sep 17 00:00:00 2001 From: daviinnchi Date: Thu, 21 Sep 2023 11:40:53 +0200 Subject: [PATCH 04/10] Fix comments --- src/App.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d1c43d11d1..78797d91a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -92,14 +92,10 @@ export const App: React.FC = () => {

todos

- +
- + {tempTodo && (
From 7d0ba5181c9b6ebf8694fc5322a17725b4a433e9 Mon Sep 17 00:00:00 2001 From: daviinnchi Date: Thu, 21 Sep 2023 14:16:53 +0200 Subject: [PATCH 05/10] fix comments --- src/App.tsx | 36 ++++-------- .../Contexts/ErrorMessageContext.tsx | 9 +++ src/components/NewTodo/NewTodo.tsx | 57 +++---------------- src/components/Todo/TodoTask.tsx | 3 - src/components/TodoFilter/TodoFilter.tsx | 43 ++++++++------ src/utils/Filters.ts | 6 +- 6 files changed, 57 insertions(+), 97 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 78797d91a8..da5cd7df52 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,9 +28,9 @@ export const App: React.FC = () => { const { setLoadingTodos } = useLoadingTodos(); const { errorMessage, - setErrorMessage, isErrorHidden, setIsErrorHidden, + handleShowError, } = useErrorMessage(); const [filterParam, setFilterParam] = useState(Filters.All); @@ -38,36 +38,24 @@ export const App: React.FC = () => { const clearCompletedTodos = () => { const completedTodos = countTodos(todos, true); - completedTodos.forEach(({ id }) => { + completedTodos.forEach(async ({ id }) => { setLoadingTodos(prev => [...prev, { todoId: id, isLoading: true }]); - deleteTodo(id) - .then(() => { - setTodos(prev => prev.filter((todo) => id !== todo.id)); - }) - .catch((error) => { - setErrorMessage(JSON.parse(error.message).error); - setIsErrorHidden(false); - - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); - }) - .finally(() => { - setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); - }); + await deleteTodo(id); + try { + setTodos(prev => prev.filter((todo) => id !== todo.id)); + } catch { + handleShowError('Unable to delete todo'); + } + + setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); }); }; useEffect(() => { getTodos(USER_ID) .then(setTodos) - .catch((error) => { - setErrorMessage(JSON.parse(error.message).error); - setIsErrorHidden(false); - - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); + .catch(() => { + handleShowError('Unable to load todos'); }); }, []); diff --git a/src/components/Contexts/ErrorMessageContext.tsx b/src/components/Contexts/ErrorMessageContext.tsx index f6d44ac3c4..7841510364 100644 --- a/src/components/Contexts/ErrorMessageContext.tsx +++ b/src/components/Contexts/ErrorMessageContext.tsx @@ -5,6 +5,7 @@ interface ErrorContextType { setErrorMessage: React.Dispatch> isErrorHidden: boolean, setIsErrorHidden: React.Dispatch>, + handleShowError: (error: string) => void, } export const ErrorMessageContext = React.createContext({} as ErrorContextType); @@ -22,6 +23,14 @@ export const ErrorMessageContextProvider: React.FC = ({ children }) => { setErrorMessage, isErrorHidden, setIsErrorHidden, + handleShowError(error: string) { + setErrorMessage(error); + setIsErrorHidden(false); + + setTimeout(() => { + setIsErrorHidden(true); + }, 3000); + }, }; return ( diff --git a/src/components/NewTodo/NewTodo.tsx b/src/components/NewTodo/NewTodo.tsx index ad2d5586d6..408bf31e3e 100644 --- a/src/components/NewTodo/NewTodo.tsx +++ b/src/components/NewTodo/NewTodo.tsx @@ -16,18 +16,13 @@ export const NewTodo: React.FC = ({ onWaiting }) => { const { todos, setTodos } = useTodos(); const { setLoadingTodos } = useLoadingTodos(); - const { setErrorMessage, setIsErrorHidden } = useErrorMessage(); + const { handleShowError } = useErrorMessage(); const [newTitle, setNewTitle] = useState(''); const onSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!newTitle) { - setErrorMessage('Title can\'t be empty'); - setIsErrorHidden(false); - - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); + handleShowError('Title can\'t be empty'); return; } @@ -49,12 +44,7 @@ export const NewTodo: React.FC = ({ onWaiting }) => { try { setTodos(prev => [...prev, newTodo]); } catch { - setErrorMessage('Unable to add todo'); - setIsErrorHidden(false); - - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); + handleShowError('Unable to add todo'); } onWaiting(null); @@ -62,37 +52,11 @@ export const NewTodo: React.FC = ({ onWaiting }) => { }; const completeAll = () => { - const uncompletedTodos = countTodos(todos, false); - - if (!uncompletedTodos.length) { - todos.forEach(async ({ id, completed }) => { - setLoadingTodos(prev => [...prev, { todoId: id, isLoading: true }]); - const updatedTodo = await updateTodo(id, { completed: !completed }); - - try { - setTodos(prev => prev.map((currentTodo) => { - if (currentTodo.id === id) { - return updatedTodo; - } - - return currentTodo; - })); - } catch { - setErrorMessage('Something went wrong'); - setIsErrorHidden(false); - - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); - } + const changingTodos = countTodos(todos, false).length + ? countTodos(todos, false) + : todos; - setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); - }); - - return; - } - - uncompletedTodos.forEach(async ({ id, completed }) => { + changingTodos.forEach(async ({ id, completed }) => { setLoadingTodos(prev => [...prev, { todoId: id, isLoading: true }]); const updatedTodo = await updateTodo(id, { completed: !completed }); @@ -105,12 +69,7 @@ export const NewTodo: React.FC = ({ onWaiting }) => { return currentTodo; })); } catch { - setErrorMessage('Something went wrong'); - setIsErrorHidden(false); - - setTimeout(() => { - setIsErrorHidden(true); - }, 3000); + handleShowError('Something went wrong'); } setLoadingTodos(prev => prev.filter(todo => todo.todoId !== id)); diff --git a/src/components/Todo/TodoTask.tsx b/src/components/Todo/TodoTask.tsx index ca5bc735d3..7a62d760d6 100644 --- a/src/components/Todo/TodoTask.tsx +++ b/src/components/Todo/TodoTask.tsx @@ -77,9 +77,6 @@ export const TodoTask: React.FC = ({ const updatedTodo = await updateTodo(todoId, { completed: event.target.checked }); - // eslint-disable-next-line no-console - console.log(todoId); - try { setTodos(prev => prev.map(currentTodo => { if (currentTodo.id === todoId) { diff --git a/src/components/TodoFilter/TodoFilter.tsx b/src/components/TodoFilter/TodoFilter.tsx index bbfde44ab7..d89cf58985 100644 --- a/src/components/TodoFilter/TodoFilter.tsx +++ b/src/components/TodoFilter/TodoFilter.tsx @@ -7,7 +7,7 @@ import { Todo } from '../../types/Todo'; type Props = { todos: Todo[], filterParam: string, - onFilterChange: (newFilter:Filters) => void, + onFilterChange: (newFilter: Filters) => void, clearCompleted: () => void, }; @@ -17,29 +17,36 @@ export const TodoFilter: React.FC = ({ onFilterChange, clearCompleted, }) => { + const itemsLeft = `${countTodos(todos, false).length} item${countTodos(todos, false).length === 1 ? '' : 's'} left`; + return (
- {`${countTodos(todos, false).length} items left`} + {itemsLeft} - -
-
-
-
-
+ )}
@@ -127,7 +110,6 @@ export const App: React.FC = () => { onClick={() => setIsErrorHidden(true)} aria-label="Delete Button" /> - {errorMessage}
diff --git a/src/components/Contexts/ErrorMessageContext.tsx b/src/components/Contexts/ErrorMessageContext.tsx index d431693991..5f89bde79d 100644 --- a/src/components/Contexts/ErrorMessageContext.tsx +++ b/src/components/Contexts/ErrorMessageContext.tsx @@ -25,9 +25,9 @@ export const ErrorMessageContextProvider: React.FC = ({ children }) => { setIsErrorHidden, handleShowError(error: string) { setErrorMessage(error); - + setIsErrorHidden(false); setTimeout(() => { - setErrorMessage(''); + setIsErrorHidden(true); }, 3000); }, }; diff --git a/src/components/NewTodo/NewTodo.tsx b/src/components/NewTodo/NewTodo.tsx index 40a3c8b2f2..38dd930dd9 100644 --- a/src/components/NewTodo/NewTodo.tsx +++ b/src/components/NewTodo/NewTodo.tsx @@ -21,29 +21,28 @@ export const NewTodo: React.FC = ({ onWaiting }) => { const { handleShowError } = useErrorMessage(); const [newTitle, setNewTitle] = useState(''); + const uncompletedTodos = countTodos(todos, false); + const onSubmit = async (event: React.FormEvent) => { event.preventDefault(); - if (!newTitle) { + if (!newTitle.trim()) { handleShowError('Title can\'t be empty'); return; } - onWaiting({ + const tempTodo = { id: 0, completed: false, - title: newTitle, + title: newTitle.trim(), userId: USER_ID, - }); + }; + + onWaiting(tempTodo); setIsAdding(true); try { - const newTodo = await addTodo(USER_ID, { - id: 0, - completed: false, - title: newTitle.trim(), - userId: USER_ID, - }); + const newTodo = await addTodo(USER_ID, tempTodo); setTodos(prev => [...prev, newTodo]); setNewTitle(''); @@ -56,8 +55,8 @@ export const NewTodo: React.FC = ({ onWaiting }) => { }; const completeAll = async () => { - const changingTodos = countTodos(todos, false).length - ? countTodos(todos, false) + const changingTodos = uncompletedTodos.length + ? uncompletedTodos : todos; const todosPromises = changingTodos.map(async ({ id, completed }) => { @@ -78,8 +77,8 @@ export const NewTodo: React.FC = ({ onWaiting }) => { return currentTodo; })); - setLoadingTodos(prevLoads => prevLoads - .filter(id => id !== updatedTodo.id)); + setLoadingTodos(prevLoads => ( + prevLoads.filter(id => id !== updatedTodo.id))); }); } catch { handleShowError('Something went wrong'); @@ -91,7 +90,7 @@ export const NewTodo: React.FC = ({ onWaiting }) => { + +
+
+
+
+
+ ); +}; diff --git a/src/components/Todo/TodoTask.tsx b/src/components/Todo/TodoTask.tsx index 9f5948bca6..f86003e335 100644 --- a/src/components/Todo/TodoTask.tsx +++ b/src/components/Todo/TodoTask.tsx @@ -29,14 +29,16 @@ export const TodoTask: React.FC = ({ }, [isEditing]); const handleChangeTodoTitle = async () => { - if (editingTodoTitle === todo.title) { + const normalizedTitle = editingTodoTitle.trim(); + + if (normalizedTitle === todo.title.trim()) { setIsEditing(false); return; } setLoadingTodos(prev => [...prev, todo.id]); - if (editingTodoTitle) { + if (normalizedTitle) { try { const updatedTodo = await updateTodo(todo.id, { title: editingTodoTitle }); @@ -53,7 +55,7 @@ export const TodoTask: React.FC = ({ } } - if (!editingTodoTitle) { + if (!normalizedTitle) { try { await deleteTodo(todo.id); setTodos(prev => prev.filter(({ id }) => id !== todo.id)); @@ -62,8 +64,7 @@ export const TodoTask: React.FC = ({ } } - setLoadingTodos(prev => prev.filter(id => ( - id !== todo.id))); + setLoadingTodos(prev => prev.filter(id => id !== todo.id)); setIsEditing(false); }; @@ -91,8 +92,7 @@ export const TodoTask: React.FC = ({ handleShowError('There is an error when completing todo'); } - setLoadingTodos(prev => prev.filter(id => ( - id !== todo.id))); + setLoadingTodos(prev => prev.filter(id => id !== todo.id)); }; const onDelete = async (todoId: number) => { @@ -105,8 +105,7 @@ export const TodoTask: React.FC = ({ handleShowError('There is an error when deleting todo'); } - setLoadingTodos(prev => prev.filter(id => ( - id !== todo.id))); + setLoadingTodos(prev => prev.filter(id => id !== todo.id)); }; const handlePressEsc = (event: React.KeyboardEvent) => { @@ -115,6 +114,9 @@ export const TodoTask: React.FC = ({ } }; + const isTodoLoading = loadingTodos.some((id) => ( + id === todo.id)); + return (
= ({ )}
( - id === todo.id)), + 'is-active': isTodoLoading, })} >