From 5be5049c7a723835adc38c184ec8412913681766 Mon Sep 17 00:00:00 2001 From: Roman Martseniuk Date: Wed, 18 Dec 2024 14:15:54 +0200 Subject: [PATCH 1/4] complete 0.1 --- src/App.tsx | 284 +++++++++++++++++++++++++-- src/api/todos.ts | 39 ++++ src/components/Error/Error.tsx | 33 ++++ src/components/Error/index.tsx | 1 + src/components/Footer/Footer.tsx | 62 ++++++ src/components/Footer/index.tsx | 1 + src/components/Header/Header.tsx | 65 ++++++ src/components/Header/index.tsx | 1 + src/components/TodoItem/TodoItem.tsx | 123 ++++++++++++ src/components/TodoItem/index.tsx | 1 + src/components/TodoList/TodoList.tsx | 45 +++++ src/components/TodoList/index.tsx | 1 + src/types/ErrorMessage.ts | 7 + src/types/FilterBy.tsx | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++ 16 files changed, 705 insertions(+), 15 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Error/Error.tsx create mode 100644 src/components/Error/index.tsx create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.tsx create mode 100644 src/types/ErrorMessage.ts create mode 100644 src/types/FilterBy.tsx create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..2399ab1a30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,280 @@ -/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; + import { UserWarning } from './UserWarning'; -const USER_ID = 0; +import * as todoServices from './api/todos'; + +import { ErrorMessage } from './types/ErrorMessage'; +import { Todo } from './types/Todo'; +import { FilterBy } from './types/FilterBy'; + +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { Error as ErrorCard } from './components/Error'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [filterBy, setFilterBy] = useState(FilterBy.all); + + const [deletingCompleteTodos, setDeletingCompleteTodos] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [completingTodos, setCompletingTodos] = useState(false); + + const [errorMessage, setErrorMessage] = useState(''); + + const [inputText, setInputText] = useState(''); + const inputRef = useRef(null); + + const [currentTodoList, setCurrentTodoList] = useState([]); + + const focusInput = () => { + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + useEffect(() => { + focusInput(); + + todoServices + .getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage('Unable to load todos'); + setTimeout(() => setErrorMessage(''), 3000); + }); + }, []); + + const handleDeleteTodo = (id: number, callback: (arg: boolean) => void) => { + callback(true); + todoServices + .deleteTodo(id) + .then(() => setTodos(todos.filter(todo => todo.id !== id))) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + callback(false); + focusInput(); + }); + }; + + const handleDeleteAllCompletedTodos = async () => { + setDeletingCompleteTodos(true); + + todoServices + .deleteArrOfTodos(todos.filter(todo => todo.completed)) + .then(res => { + const rejectedTodos = res + .map((result, index) => + result.status === 'rejected' + ? todos.filter(todo => todo.completed)[index] + : null, + ) + .filter(todo => todo !== null); + + setTodos( + todos.filter(todo => !todo.completed || rejectedTodos.includes(todo)), + ); + + if (rejectedTodos.length > 0) { + throw new Error('Some todos were not deleted'); + } + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + setDeletingCompleteTodos(false); + focusInput(); + }); + }; + + const handleAddingTodo = () => { + if (inputRef.current) { + inputRef.current.disabled = true; + + if (inputText.trim() === '') { + setErrorMessage('Title should not be empty'); + setTimeout(() => setErrorMessage(''), 3000); + inputRef.current.disabled = false; + focusInput(); + + return; + } + + setTempTodo({ + id: 0, + userId: todoServices.USER_ID, + title: inputText.trim(), + completed: false, + }); + + todoServices + .addPost(inputText.trim()) + .then(newTodo => { + setTodos(list => [...list, newTodo]); + setInputText(''); + }) + .catch(() => { + setErrorMessage('Unable to add a todo'); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + if (inputRef.current) { + inputRef.current.disabled = false; + } + + setTempTodo(null); + focusInput(); + }); + } + }; + + const handleUpdatingTodo = ( + updatedTodo: Todo, + callback: (arg: boolean) => void, + ): Promise => { + callback(true); + + return todoServices + .updateTodo(updatedTodo) + .then(item => + setTodos(currTodos => { + const newTodos = [...currTodos]; + const index = newTodos.findIndex(todo => todo.id === item.id); + + newTodos.splice(index, 1, updatedTodo); + + return newTodos; + }), + ) + .catch(() => { + setErrorMessage('Unable to update a todo'); + setTimeout(() => setErrorMessage(''), 3000); + throw new Error('Unable to update a todo'); + }) + .finally(() => { + callback(false); + focusInput(); + }); + }; + + // const switchTodos = (arr: Todo[]) => { + // // Optimistically update todos + // setTodos(currTodos => { + // return currTodos.map(todo => { + // const updatedTodo = arr.find(t => t.id === todo.id); + + // return updatedTodo + // ? { ...todo, completed: updatedTodo.completed } + // : todo; + // }); + // }); + + // todoServices + // .switchTodosStatus(arr) + // .then(result => { + // const updatedTodos = result + // .filter(res => res.status === 'fulfilled') + // .map(res => res.value); + + // // Now update the state with the API results + // setTodos(currTodos => + // currTodos.map(todo => { + // const updatedTodo = updatedTodos.find(t => t.id === todo.id); + + // return updatedTodo ? { ...todo, ...updatedTodo } : todo; + // }), + // ); + // }) + // .catch(() => { + // setErrorMessage('Unable to update a todo'); + // setTimeout(() => setErrorMessage(''), 3000); + // }) + // .finally(() => { + // setCompletingTodos(false); + // focusInput(); + // }); + // }; + + const handleSwitchTodosStatus = () => { + const activeTodos = todos.filter(item => !item.completed); + + if (activeTodos.length > 0) { + const newTodos = [...todos].map(todo => { + return activeTodos.find(t => t.id === todo.id) + ? { ...todo, completed: true } + : todo; + }); + + setTodos(newTodos); + // switchTodos(activeTodos); + } + }; + + useEffect(() => { + const filteredTodos = todos.filter(item => { + return ( + filterBy === FilterBy.all || + (filterBy === FilterBy.active && !item.completed) || + (filterBy === FilterBy.completed && item.completed) + ); + }); + + setCurrentTodoList(filteredTodos); + }, [filterBy, todos]); + + if (!todoServices.USER_ID) { return ; } 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 */} + {todos.length > 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..9a3933ad54 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,39 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 2088; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const deleteArrOfTodos = async (arr: Todo[]) => { + const result = await Promise.allSettled(arr.map(todo => deleteTodo(todo.id))); + + return result; +}; + +export const addPost = (title: string) => { + return client.post(`/todos`, { + title, + userId: USER_ID, + completed: false, + }); +}; + +export const updateTodo = ({ id, userId, title, completed }: Todo) => { + return client.patch(`/todos/${id}`, { id, userId, title, completed }); +}; + +export const switchTodosStatus = async (arr: Todo[]) => { + const result = await Promise.allSettled( + arr.map(todo => updateTodo({ ...todo, completed: !todo.completed })), + ); + + return result; +}; diff --git a/src/components/Error/Error.tsx b/src/components/Error/Error.tsx new file mode 100644 index 0000000000..f388f1b60e --- /dev/null +++ b/src/components/Error/Error.tsx @@ -0,0 +1,33 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { ErrorMessage } from '../../types/ErrorMessage'; +import classNames from 'classnames'; + +type Props = { + errorMessage: ErrorMessage; + setErrorMessage: (arg: ErrorMessage) => void; +}; + +export const Error: React.FC = ({ errorMessage, setErrorMessage }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Error/index.tsx b/src/components/Error/index.tsx new file mode 100644 index 0000000000..ae6e95d01d --- /dev/null +++ b/src/components/Error/index.tsx @@ -0,0 +1 @@ +export * from './Error'; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..27fcbef6c7 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,62 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { FilterBy } from '../../types/FilterBy'; +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; + +type Props = { + todos: Todo[]; + filterBy: FilterBy; + setFilterBy: (arg: FilterBy) => void; + deleteCompletedTodos: () => void; +}; + +export const Footer: React.FC = ({ + todos = [], + filterBy, + setFilterBy, + deleteCompletedTodos, +}) => { + const completeTodosNum = todos.filter(todo => !todo.completed).length; + + return ( +
+ + {completeTodosNum} items left + + + {/* Active link should have the 'selected' class */} + + + {/* this button should be disabled if there are no completed todos */} + + +
+ ); +}; diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx new file mode 100644 index 0000000000..ddcc5a9cd1 --- /dev/null +++ b/src/components/Footer/index.tsx @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..b3449e34f9 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,65 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import classNames from 'classnames'; + +type Props = { + inputRef: React.RefObject; + inputText: string; + setInputText: (value: string) => void; + createFunc: () => void; + completeFunc: () => void; + todos: Todo[]; + completingTodos: boolean; +}; + +export const Header: React.FC = ({ + inputRef, + inputText, + setInputText, + createFunc, + completeFunc, + todos, + completingTodos, +}) => { + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 0000000000..266dec8a1b --- /dev/null +++ b/src/components/Header/index.tsx @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..991557c214 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,123 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useState } from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; + handleDeleteTodo: (id: number, callback: (arg: boolean) => void) => void; + handleUpdateTodo: (todo: Todo, callback: (arg: boolean) => void) => void; + loading?: boolean; +}; + +export const TodoItem: React.FC = ({ + todo, + handleDeleteTodo, + handleUpdateTodo, + loading = false, +}) => { + const [isLoading, setIsLoading] = useState(loading as boolean); + const [isChanging, setIsChanging] = useState(false); + + const [currentTodo, setCurrentTodo] = useState(todo); + + const changeIsLoading = (state: boolean) => setIsLoading(state); + + const onTodoUpdate = async () => { + try { + await handleUpdateTodo(currentTodo, setIsLoading); + } catch (err) { + setIsChanging(true); + } + }; + + const setTodoCompleted = async () => { + try { + const newTodo = { ...currentTodo, completed: !currentTodo.completed }; + + await handleUpdateTodo(newTodo, setIsLoading); + + setCurrentTodo(newTodo); + } catch (err) { + setCurrentTodo(todo); + } + }; + + const handleDoubleClick = () => { + setIsChanging(true); + }; + + const handleOnChange = (e: React.ChangeEvent) => { + setCurrentTodo({ ...currentTodo, title: e.target.value }); + }; + + const handleOnKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === 'Escape') { + setIsChanging(false); + onTodoUpdate(); + } + }; + + const handleOnBlur = () => { + setIsChanging(false); + onTodoUpdate(); + }; + + return ( +
+ + + {isChanging ? ( + handleOnBlur()} + onChange={e => handleOnChange(e)} + onKeyDown={e => handleOnKeyDown(e)} + autoFocus + /> + ) : ( + handleDoubleClick()} + > + {currentTodo.title} + + )} + + + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.tsx b/src/components/TodoItem/index.tsx new file mode 100644 index 0000000000..21f4abac39 --- /dev/null +++ b/src/components/TodoItem/index.tsx @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..c969718c6b --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,45 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +type Props = { + todos: Todo[]; + handleDeleteTodo: (id: number, callback: (arg: boolean) => void) => void; + handleUpdateTodo: (todo: Todo, callback: (arg: boolean) => void) => void; + deletingCompleteTodos: boolean; + tempTodo: Todo | null; +}; + +export const TodoList: React.FC = ({ + todos, + handleDeleteTodo, + handleUpdateTodo, + deletingCompleteTodos, + tempTodo, +}) => { + return ( +
+ {todos.map(todo => { + return ( + + ); + })} + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/components/TodoList/index.tsx b/src/components/TodoList/index.tsx new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/components/TodoList/index.tsx @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..ed8d297809 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export type ErrorMessage = + | '' + | 'Unable to load todos' + | 'Title should not be empty' + | 'Unable to add a todo' + | 'Unable to delete a todo' + | 'Unable to update a todo'; diff --git a/src/types/FilterBy.tsx b/src/types/FilterBy.tsx new file mode 100644 index 0000000000..768315c0da --- /dev/null +++ b/src/types/FilterBy.tsx @@ -0,0 +1,5 @@ +export enum FilterBy { + all = 'All', + completed = 'Completed', + active = 'Active', +} 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 1e0b79f200a091b86a1c8ca92e3d17ed6c0fd997 Mon Sep 17 00:00:00 2001 From: Roman Martseniuk Date: Thu, 19 Dec 2024 14:25:08 +0200 Subject: [PATCH 2/4] complete 0.2 --- src/App.tsx | 13 +++++++------ src/components/TodoItem/TodoItem.tsx | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2399ab1a30..33a1a87515 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -208,15 +208,16 @@ export const App: React.FC = () => { const activeTodos = todos.filter(item => !item.completed); if (activeTodos.length > 0) { - const newTodos = [...todos].map(todo => { - return activeTodos.find(t => t.id === todo.id) - ? { ...todo, completed: true } - : todo; + setTodos(prevTodos => { + return prevTodos.map(todo => + todo.completed ? todo : { ...todo, completed: true }, + ); }); - setTodos(newTodos); - // switchTodos(activeTodos); + return; } + + setTodos(prev => prev.map(todo => ({ ...todo, completed: false }))); }; useEffect(() => { diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index 991557c214..33e3e95428 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -76,7 +76,7 @@ export const TodoItem: React.FC = ({ type="checkbox" className="todo__status" checked={currentTodo.completed} - onClick={() => setTodoCompleted()} + onChange={() => setTodoCompleted()} /> From f22ac5f2cb05d94b3cc782f7370ea0f0fdf1ec55 Mon Sep 17 00:00:00 2001 From: Roman Martseniuk Date: Thu, 19 Dec 2024 17:38:20 +0200 Subject: [PATCH 3/4] complete 1.0 --- src/App.tsx | 86 +++++++++++----------------- src/components/Header/Header.tsx | 8 +-- src/components/TodoItem/TodoItem.tsx | 59 ++++++++++++++----- src/components/TodoList/TodoList.tsx | 6 +- 4 files changed, 85 insertions(+), 74 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 33a1a87515..570b366a4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,10 +19,7 @@ export const App: React.FC = () => { const [todos, setTodos] = useState([]); const [tempTodo, setTempTodo] = useState(null); const [filterBy, setFilterBy] = useState(FilterBy.all); - - const [deletingCompleteTodos, setDeletingCompleteTodos] = useState(false); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [completingTodos, setCompletingTodos] = useState(false); + const [loadingTodos, setLoadingTodos] = useState([]); const [errorMessage, setErrorMessage] = useState(''); @@ -65,8 +62,7 @@ export const App: React.FC = () => { }; const handleDeleteAllCompletedTodos = async () => { - setDeletingCompleteTodos(true); - + setLoadingTodos(todos.filter(todo => todo.completed).map(todo => todo.id)); todoServices .deleteArrOfTodos(todos.filter(todo => todo.completed)) .then(res => { @@ -91,7 +87,7 @@ export const App: React.FC = () => { setTimeout(() => setErrorMessage(''), 3000); }) .finally(() => { - setDeletingCompleteTodos(false); + setLoadingTodos([]); focusInput(); }); }; @@ -166,58 +162,44 @@ export const App: React.FC = () => { }); }; - // const switchTodos = (arr: Todo[]) => { - // // Optimistically update todos - // setTodos(currTodos => { - // return currTodos.map(todo => { - // const updatedTodo = arr.find(t => t.id === todo.id); - - // return updatedTodo - // ? { ...todo, completed: updatedTodo.completed } - // : todo; - // }); - // }); - - // todoServices - // .switchTodosStatus(arr) - // .then(result => { - // const updatedTodos = result - // .filter(res => res.status === 'fulfilled') - // .map(res => res.value); - - // // Now update the state with the API results - // setTodos(currTodos => - // currTodos.map(todo => { - // const updatedTodo = updatedTodos.find(t => t.id === todo.id); - - // return updatedTodo ? { ...todo, ...updatedTodo } : todo; - // }), - // ); - // }) - // .catch(() => { - // setErrorMessage('Unable to update a todo'); - // setTimeout(() => setErrorMessage(''), 3000); - // }) - // .finally(() => { - // setCompletingTodos(false); - // focusInput(); - // }); - // }; + const switchTodos = (arr: Todo[]) => { + setLoadingTodos(arr.map(todo => todo.id)); + todoServices + .switchTodosStatus(arr) + .then(result => { + const updatedTodos = result + .filter(res => res.status === 'fulfilled') + .map(res => res.value); + + // Now update the state with the API results + setTodos(currTodos => + currTodos.map(todo => { + const updatedTodo = updatedTodos.find(t => t.id === todo.id); + + return updatedTodo ? { ...todo, ...updatedTodo } : todo; + }), + ); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + setLoadingTodos([]); + focusInput(); + }); + }; const handleSwitchTodosStatus = () => { const activeTodos = todos.filter(item => !item.completed); if (activeTodos.length > 0) { - setTodos(prevTodos => { - return prevTodos.map(todo => - todo.completed ? todo : { ...todo, completed: true }, - ); - }); + switchTodos(activeTodos); return; } - setTodos(prev => prev.map(todo => ({ ...todo, completed: false }))); + switchTodos(todos); }; useEffect(() => { @@ -248,14 +230,14 @@ export const App: React.FC = () => { createFunc={handleAddingTodo} todos={todos} completeFunc={handleSwitchTodosStatus} - completingTodos={completingTodos} + loadingTodos={loadingTodos} /> diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index b3449e34f9..a26f87cf9a 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -11,7 +11,7 @@ type Props = { createFunc: () => void; completeFunc: () => void; todos: Todo[]; - completingTodos: boolean; + loadingTodos: number[]; }; export const Header: React.FC = ({ @@ -21,7 +21,7 @@ export const Header: React.FC = ({ createFunc, completeFunc, todos, - completingTodos, + loadingTodos, }) => { return (
@@ -30,13 +30,13 @@ export const Header: React.FC = ({ + {!isChanging && ( + + )}
void) => void; handleUpdateTodo: (todo: Todo, callback: (arg: boolean) => void) => void; - deletingCompleteTodos: boolean; + loadingTodos: number[]; tempTodo: Todo | null; }; @@ -16,7 +16,7 @@ export const TodoList: React.FC = ({ todos, handleDeleteTodo, handleUpdateTodo, - deletingCompleteTodos, + loadingTodos, tempTodo, }) => { return ( @@ -28,7 +28,7 @@ export const TodoList: React.FC = ({ todo={todo} handleDeleteTodo={handleDeleteTodo} handleUpdateTodo={handleUpdateTodo} - loading={deletingCompleteTodos && todo.completed} + loading={loadingTodos.includes(todo.id)} /> ); })} From 8f5128f4717ff01585fbd6a9e7cce1d432fd3cf0 Mon Sep 17 00:00:00 2001 From: Roman Martseniuk Date: Sat, 21 Dec 2024 12:40:30 +0200 Subject: [PATCH 4/4] complete 2.0 --- src/App.tsx | 4 ++-- src/components/Header/Header.tsx | 23 ++++++++++------------- src/components/TodoItem/TodoItem.tsx | 7 ++----- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 570b366a4f..1bfad25d88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -227,9 +227,9 @@ export const App: React.FC = () => { inputRef={inputRef} inputText={inputText} setInputText={setInputText} - createFunc={handleAddingTodo} + handleCreateTodo={handleAddingTodo} todos={todos} - completeFunc={handleSwitchTodosStatus} + handleCompleteTodo={handleSwitchTodosStatus} loadingTodos={loadingTodos} /> diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index a26f87cf9a..793764219b 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -8,8 +8,8 @@ type Props = { inputRef: React.RefObject; inputText: string; setInputText: (value: string) => void; - createFunc: () => void; - completeFunc: () => void; + handleCreateTodo: () => void; + handleCompleteTodo: () => void; todos: Todo[]; loadingTodos: number[]; }; @@ -18,11 +18,13 @@ export const Header: React.FC = ({ inputRef, inputText, setInputText, - createFunc, - completeFunc, + handleCreateTodo, + handleCompleteTodo, todos, loadingTodos, }) => { + const includesActiveTodos = todos.filter(todo => !todo.completed).length > 0; + return (
{/* this button should have `active` class only if all todos are completed */} @@ -30,12 +32,10 @@ export const Header: React.FC = ({