From 488b346dca5af3da894f5f3fd32890c42725fa5a Mon Sep 17 00:00:00 2001 From: Diana Honcharova Date: Mon, 25 Sep 2023 21:07:17 +0300 Subject: [PATCH 01/14] initial commit --- src/App.tsx | 101 ++++++++++++++++++++++----- src/Context/TodoContext.tsx | 123 +++++++++++++++++++++++++++++++++ src/api/todos.ts | 14 ++++ src/components/TodoElement.tsx | 67 ++++++++++++++++++ src/components/TodoFooter.tsx | 58 ++++++++++++++++ src/components/TodoHeader.tsx | 94 +++++++++++++++++++++++++ src/components/TodoList.tsx | 29 ++++++++ src/index.tsx | 7 +- src/types/CurrentError.tsx | 8 +++ src/types/Todo.ts | 6 ++ src/types/TodoFilter.ts | 5 ++ src/utils/constants.ts | 1 + src/utils/fetchClient.ts | 46 ++++++++++++ src/utils/getActiveTodos.ts | 5 ++ src/utils/getCompletedTodos.ts | 5 ++ src/utils/getFilteredTodos.tsx | 26 +++++++ 16 files changed, 578 insertions(+), 17 deletions(-) create mode 100644 src/Context/TodoContext.tsx create mode 100644 src/api/todos.ts create mode 100644 src/components/TodoElement.tsx create mode 100644 src/components/TodoFooter.tsx create mode 100644 src/components/TodoHeader.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/types/CurrentError.tsx create mode 100644 src/types/Todo.ts create mode 100644 src/types/TodoFilter.ts create mode 100644 src/utils/constants.ts create mode 100644 src/utils/fetchClient.ts create mode 100644 src/utils/getActiveTodos.ts create mode 100644 src/utils/getCompletedTodos.ts create mode 100644 src/utils/getFilteredTodos.tsx diff --git a/src/App.tsx b/src/App.tsx index 5749bdf784..7bdd76b241 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,93 @@ -/* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; +import React, { + useEffect, + useState, + useMemo, + useContext, +} from 'react'; +import classNames from 'classnames'; -const USER_ID = 0; +import { TodoFilter } from './types/TodoFilter'; +import { TodoList } from './components/TodoList'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoFooter } from './components/TodoFooter'; +import { getFilteredTodos } from './utils/getFilteredTodos'; +import { CurrentError } from './types/CurrentError'; +import { TodoContext } from './Context/TodoContext'; +import { USER_ID } from './utils/constants'; +import * as todoService from './api/todos'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todoFilter, setTodoFilter] = useState(TodoFilter.All); + + const { + todos, + setTodos, + error, + setError, + } = useContext(TodoContext); + + useEffect(() => { + todoService.getTodos(USER_ID) + .then(setTodos) + .catch(() => { + setError(CurrentError.LoadingError); + }); + }, []); + + useEffect(() => { + if (error) { + setTimeout(() => { + setError(CurrentError.Default); + }, 3000); + } + }, [error]); + + const filteredTodos = useMemo(() => { + return getFilteredTodos(todos, todoFilter); + }, [todos, todoFilter]); + + const handleSetTodoFilter = (filter: TodoFilter) => ( + setTodoFilter(filter) + ); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ + + + + {!!todos.length && ( + + )} +
+ +
+
+
); }; diff --git a/src/Context/TodoContext.tsx b/src/Context/TodoContext.tsx new file mode 100644 index 0000000000..47e1d73259 --- /dev/null +++ b/src/Context/TodoContext.tsx @@ -0,0 +1,123 @@ +import React, { + createContext, + useMemo, + useState, +} from 'react'; +import { CurrentError } from '../types/CurrentError'; +import * as todoService from '../api/todos'; +import { Todo } from '../types/Todo'; +import { getCompletedTodos } from '../utils/getCompletedTodos'; +import { getActiveTodos } from '../utils/getActiveTodos'; + +type Props = { + children: React.ReactNode +}; + +interface TodoContextInterface { + todos: Todo[], + setTodos: React.Dispatch>, + tempTodo: Todo | null; + setTempTodo: React.Dispatch>; + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; + error: CurrentError, + setError: (error: CurrentError) => void; + deleteTodoHandler: (id: number) => void; + addTodoHandler: (newTodo: Omit) => Promise + completedTodos: Todo[]; + activeTodos: Todo[]; + clearCompleted: () => void; + todosIdToDelete: number[]; + setTodosIdToDelete: (todoIdsToDelete: number[]) => void; +} + +const initalContext: TodoContextInterface = { + todos: [], + setTodos: () => {}, + tempTodo: null, + setTempTodo: () => {}, + isLoading: false, + setIsLoading: () => {}, + error: CurrentError.Default, + setError: () => {}, + deleteTodoHandler: () => {}, + addTodoHandler: async () => {}, + completedTodos: [], + activeTodos: [], + clearCompleted: () => {}, + todosIdToDelete: [], + setTodosIdToDelete: () => {}, +}; + +export const TodoContext = createContext(initalContext); + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [error, setError] = useState(CurrentError.Default); + const [isLoading, setIsLoading] = useState(false); + const [todosIdToDelete, setTodosIdToDelete] = useState([]); + + const completedTodos = getCompletedTodos(todos); + const activeTodos = getActiveTodos(todos); + + const deleteTodoHandler = (todoId: number) => { + setIsLoading(true); + setTodosIdToDelete(prevState => [...prevState, todoId]); + todoService.deleteTodo(todoId) + .then(() => { + setTodos(prevTodos => { + return prevTodos.filter(todo => todo.id !== todoId); + }); + }) + .catch(() => setError(CurrentError.DeleteError)) + .finally(() => setIsLoading(false)); + }; + + const addTodoHandler = (newTodo: Omit) => { + setIsLoading(true); + + return todoService.addTodo(newTodo) + .then(createdTodo => { + setTodos((prevTodos) => [...prevTodos, createdTodo]); + }) + .catch(() => { + setError(CurrentError.AddError); + throw new Error(); + }) + .finally(() => { + setIsLoading(false); + setTempTodo(null); + }); + }; + + const clearCompleted = () => { + completedTodos.forEach(({ id }) => deleteTodoHandler(id)); + }; + + const value = useMemo(() => ({ + todos, + setTodos, + tempTodo, + setTempTodo, + isLoading, + setIsLoading, + error, + setError, + deleteTodoHandler, + addTodoHandler, + completedTodos, + activeTodos, + clearCompleted, + todosIdToDelete, + setTodosIdToDelete, + }), [todos, error, isLoading, tempTodo]); + + return ( + + {children} + + ); +}; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..7ca7abcc11 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,14 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const addTodo = (todo: Omit) => { + return client.post('/todos', todo); +}; diff --git a/src/components/TodoElement.tsx b/src/components/TodoElement.tsx new file mode 100644 index 0000000000..0864aded60 --- /dev/null +++ b/src/components/TodoElement.tsx @@ -0,0 +1,67 @@ +import React, { useContext, useState } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { TodoContext } from '../Context/TodoContext'; + +type Props = { + todo: Todo, +}; + +export const TodoElement: React.FC = ({ todo }) => { + const { completed, title, id } = todo; + const { deleteTodoHandler, todosIdToDelete } = useContext(TodoContext); + + const [isChecked, setIsChecked] = useState(completed); + const shouldDisplayLoader = id === 0 || todosIdToDelete.includes(id); + + return ( +
+ + + + {title} + + + + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 0000000000..2711adade4 --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,58 @@ +import React, { useContext } from 'react'; +import classNames from 'classnames'; +import { TodoFilter } from '../types/TodoFilter'; +import { TodoContext } from '../Context/TodoContext'; + +type Props = { + filter: TodoFilter; + setFilter: (newFilter: TodoFilter) => void; +}; + +export const TodoFooter: React.FC = ({ + filter, + setFilter, +}) => { + const { + activeTodos, + completedTodos, + clearCompleted, + } = useContext(TodoContext); + + const activeTodosCount = activeTodos.length; + const completedTodosCount = completedTodos.length; + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 0000000000..48243dba27 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,94 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import { + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { CurrentError } from '../types/CurrentError'; +import { TodoContext } from '../Context/TodoContext'; +import { USER_ID } from '../utils/constants'; + +type Props = {}; + +export const TodoHeader: React.FC = () => { + const { + setError, + addTodoHandler, + setTempTodo, + activeTodos, + completedTodos, + isLoading, + } = useContext(TodoContext); + const [title, setTitle] = useState(''); + const inputField = useRef(null); + + useEffect(() => { + if (!isLoading) { + inputField.current?.focus(); + } + }, [isLoading]); + + const handleTitleChange = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + setError(CurrentError.EmptyTitleError); + + return; + } + + const newTodo = { + userId: USER_ID, + title: title.trim(), + completed: false, + }; + + setTempTodo({ id: 0, ...newTodo }); + addTodoHandler(newTodo) + .then(() => { + setTitle(''); + }) + .catch(() => { + setError(CurrentError.AddError); + }); + }; + + const activeTodosCount = activeTodos.length; + const completedTodosCount = completedTodos.length; + + return ( +
+ {!!activeTodosCount && ( +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..d832f4f824 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,29 @@ +import React, { useContext } from 'react'; +import { Todo } from '../types/Todo'; +import { TodoElement } from './TodoElement'; +import { TodoContext } from '../Context/TodoContext'; + +type Props = { + todos: Todo[], +}; + +export const TodoList: React.FC = ({ todos }) => { + const { tempTodo } = useContext(TodoContext); + + return ( +
+ {todos?.map(todo => ( + + ))} + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index 7de19e0c70..d3ca54266f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,11 @@ import '@fortawesome/fontawesome-free/css/all.css'; import './styles/index.scss'; import { App } from './App'; +import { TodoProvider } from './Context/TodoContext'; createRoot(document.getElementById('root') as HTMLDivElement) - .render(); + .render( + + + , + ); diff --git a/src/types/CurrentError.tsx b/src/types/CurrentError.tsx new file mode 100644 index 0000000000..6c3c62f07e --- /dev/null +++ b/src/types/CurrentError.tsx @@ -0,0 +1,8 @@ +export enum CurrentError { + Default = '', + LoadingError = 'Unable to load todos', + EmptyTitleError = 'Title should not be empty', + AddError = 'Unable to add a todo', + DeleteError = 'Unable to delete a todo', + UpdateError = 'Unable to update a todo', +} 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/TodoFilter.ts b/src/types/TodoFilter.ts new file mode 100644 index 0000000000..6b7e03b793 --- /dev/null +++ b/src/types/TodoFilter.ts @@ -0,0 +1,5 @@ +export enum TodoFilter { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000000..b9d96964ef --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1 @@ +export const USER_ID = 11522; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..d3e2fcc51a --- /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', + }; + } + + // we wait for testing purpose to see loaders + return wait(300) + .then(() => fetch(BASE_URL + url, options)) + .then(response => { + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + 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/getActiveTodos.ts b/src/utils/getActiveTodos.ts new file mode 100644 index 0000000000..9afc5b93e6 --- /dev/null +++ b/src/utils/getActiveTodos.ts @@ -0,0 +1,5 @@ +import { Todo } from '../types/Todo'; + +export const getActiveTodos = ( + todos: Todo[], +) => todos.filter(({ completed }) => !completed); diff --git a/src/utils/getCompletedTodos.ts b/src/utils/getCompletedTodos.ts new file mode 100644 index 0000000000..818b8a8279 --- /dev/null +++ b/src/utils/getCompletedTodos.ts @@ -0,0 +1,5 @@ +import { Todo } from '../types/Todo'; + +export const getCompletedTodos = ( + todos: Todo[], +) => todos.filter(({ completed }) => completed); diff --git a/src/utils/getFilteredTodos.tsx b/src/utils/getFilteredTodos.tsx new file mode 100644 index 0000000000..bcabd0d91a --- /dev/null +++ b/src/utils/getFilteredTodos.tsx @@ -0,0 +1,26 @@ +import { Todo } from '../types/Todo'; +import { TodoFilter } from '../types/TodoFilter'; + +export const getFilteredTodos = ( + todos: Todo[], + selectedFilter: string, +) => { + let preparedTodos = [...todos]; + + if (selectedFilter !== TodoFilter.All) { + preparedTodos = preparedTodos.filter(({ completed }) => { + switch (selectedFilter) { + case TodoFilter.Active: + return !completed; + + case TodoFilter.Completed: + return completed; + + default: + throw new Error('Something went wrong :('); + } + }); + } + + return preparedTodos; +}; From 0e6e58e4317a16d273899898fa31e7a39cec8513 Mon Sep 17 00:00:00 2001 From: Diana Honcharova Date: Tue, 26 Sep 2023 09:21:20 +0300 Subject: [PATCH 02/14] updateTodoTitle functionality --- src/App.tsx | 13 ++++-- src/Context/TodoContext.tsx | 38 +++++++++++---- src/api/todos.ts | 4 ++ src/components/TodoElement.tsx | 84 ++++++++++++++++++++++++++++------ src/components/TodoHeader.tsx | 8 ++-- 5 files changed, 115 insertions(+), 32 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7bdd76b241..568d0d8f53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import React, { useState, useMemo, useContext, + useRef, } from 'react'; import classNames from 'classnames'; @@ -35,12 +36,16 @@ export const App: React.FC = () => { }); }, []); + const timerId = useRef(0); + useEffect(() => { - if (error) { - setTimeout(() => { - setError(CurrentError.Default); - }, 3000); + if (timerId.current) { + window.clearTimeout(timerId.current); } + + timerId.current = window.setTimeout(() => { + setError(CurrentError.Default); + }, 3000); }, [error]); const filteredTodos = useMemo(() => { diff --git a/src/Context/TodoContext.tsx b/src/Context/TodoContext.tsx index 47e1d73259..169784ede7 100644 --- a/src/Context/TodoContext.tsx +++ b/src/Context/TodoContext.tsx @@ -22,8 +22,9 @@ interface TodoContextInterface { setIsLoading: (isLoading: boolean) => void; error: CurrentError, setError: (error: CurrentError) => void; - deleteTodoHandler: (id: number) => void; - addTodoHandler: (newTodo: Omit) => Promise + handleTodoDelete: (id: number) => void; + handleTodoAdd: (newTodo: Omit) => Promise + handleTodoUpdate: (todo: Todo, newTodoTitle: string) => Promise, completedTodos: Todo[]; activeTodos: Todo[]; clearCompleted: () => void; @@ -40,8 +41,9 @@ const initalContext: TodoContextInterface = { setIsLoading: () => {}, error: CurrentError.Default, setError: () => {}, - deleteTodoHandler: () => {}, - addTodoHandler: async () => {}, + handleTodoDelete: () => {}, + handleTodoAdd: async () => {}, + handleTodoUpdate: async () => {}, completedTodos: [], activeTodos: [], clearCompleted: () => {}, @@ -61,7 +63,7 @@ export const TodoProvider: React.FC = ({ children }) => { const completedTodos = getCompletedTodos(todos); const activeTodos = getActiveTodos(todos); - const deleteTodoHandler = (todoId: number) => { + const handleTodoDelete = (todoId: number) => { setIsLoading(true); setTodosIdToDelete(prevState => [...prevState, todoId]); todoService.deleteTodo(todoId) @@ -74,7 +76,7 @@ export const TodoProvider: React.FC = ({ children }) => { .finally(() => setIsLoading(false)); }; - const addTodoHandler = (newTodo: Omit) => { + const handleTodoAdd = (newTodo: Omit) => { setIsLoading(true); return todoService.addTodo(newTodo) @@ -91,8 +93,25 @@ export const TodoProvider: React.FC = ({ children }) => { }); }; + const handleTodoUpdate = (todo: Todo, newTodoTitle: string) => { + return todoService + .updateTodo({ + id: todo.id, + title: newTodoTitle, + userId: todo.userId, + completed: todo.completed, + }) + .then(updatedTodo => { + setTodos(prevState => prevState.map(currTodo => { + return currTodo.id !== updatedTodo.id + ? currTodo + : updatedTodo; + })); + }); + }; + const clearCompleted = () => { - completedTodos.forEach(({ id }) => deleteTodoHandler(id)); + completedTodos.forEach(({ id }) => handleTodoDelete(id)); }; const value = useMemo(() => ({ @@ -104,8 +123,9 @@ export const TodoProvider: React.FC = ({ children }) => { setIsLoading, error, setError, - deleteTodoHandler, - addTodoHandler, + handleTodoDelete, + handleTodoAdd, + handleTodoUpdate, completedTodos, activeTodos, clearCompleted, diff --git a/src/api/todos.ts b/src/api/todos.ts index 7ca7abcc11..305310eb02 100644 --- a/src/api/todos.ts +++ b/src/api/todos.ts @@ -12,3 +12,7 @@ export const deleteTodo = (todoId: number) => { export const addTodo = (todo: Omit) => { return client.post('/todos', todo); }; + +export const updateTodo = (todoToUpdate: Todo): Promise => { + return client.patch(`/todos/${todoToUpdate.id}`, todoToUpdate); +}; diff --git a/src/components/TodoElement.tsx b/src/components/TodoElement.tsx index 0864aded60..dd0f09c787 100644 --- a/src/components/TodoElement.tsx +++ b/src/components/TodoElement.tsx @@ -9,13 +9,42 @@ type Props = { export const TodoElement: React.FC = ({ todo }) => { const { completed, title, id } = todo; - const { deleteTodoHandler, todosIdToDelete } = useContext(TodoContext); + const { + handleTodoDelete, + todosIdToDelete, + handleTodoUpdate, + } = useContext(TodoContext); const [isChecked, setIsChecked] = useState(completed); + const [isEditing, setIsEditing] = useState(false); + const [todoTitle, setTodoTitle] = useState(title); const shouldDisplayLoader = id === 0 || todosIdToDelete.includes(id); + const handleTodoDoubleClick = () => { + setIsEditing(true); + }; + + const handleTodoChange = async (event: React.FormEvent) => { + event.preventDefault(); + + if (todoTitle) { + await handleTodoUpdate(todo, todoTitle); + } else { + await handleTodoDelete(id); + } + + setIsEditing(false); + }; + + const handleTodoTitleChange = ( + event: React.ChangeEvent, + ) => { + setTodoTitle(event.target.value); + }; + return (
= ({ todo }) => { /> - - {title} - + {isEditing + ? ( +
+ +
+ ) + : ( + <> + { + setIsEditing(false); + }} + > + {title} + - + + + )}
= () => { const { setError, - addTodoHandler, + handleTodoAdd, setTempTodo, activeTodos, completedTodos, @@ -50,7 +50,7 @@ export const TodoHeader: React.FC = () => { }; setTempTodo({ id: 0, ...newTodo }); - addTodoHandler(newTodo) + handleTodoAdd(newTodo) .then(() => { setTitle(''); }) @@ -83,9 +83,7 @@ export const TodoHeader: React.FC = () => { className="todoapp__new-todo" placeholder="What needs to be done?" value={title} - onChange={(event) => { - handleTitleChange(event); - }} + onChange={handleTitleChange} disabled={isLoading} /> From 26e82df14c076dba9161e0a77281d33d5642793b Mon Sep 17 00:00:00 2001 From: Diana Honcharova Date: Tue, 26 Sep 2023 09:59:38 +0300 Subject: [PATCH 03/14] fix focus on edit input --- src/components/TodoElement.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/TodoElement.tsx b/src/components/TodoElement.tsx index dd0f09c787..1fb539c44d 100644 --- a/src/components/TodoElement.tsx +++ b/src/components/TodoElement.tsx @@ -1,4 +1,9 @@ -import React, { useContext, useState } from 'react'; +import React, { + useContext, + useEffect, + useRef, + useState, +} from 'react'; import classNames from 'classnames'; import { Todo } from '../types/Todo'; import { TodoContext } from '../Context/TodoContext'; @@ -24,7 +29,9 @@ export const TodoElement: React.FC = ({ todo }) => { setIsEditing(true); }; - const handleTodoChange = async (event: React.FormEvent) => { + const handleTodoChange = async ( + event: React.FormEvent, + ) => { event.preventDefault(); if (todoTitle) { @@ -42,6 +49,14 @@ export const TodoElement: React.FC = ({ todo }) => { setTodoTitle(event.target.value); }; + const titleInput = useRef(null); + + useEffect(() => { + if (isEditing && titleInput.current) { + titleInput.current.focus(); + } + }, [isEditing]); + return (
= ({ todo }) => { onBlur={handleTodoChange} > Date: Tue, 26 Sep 2023 11:23:35 +0300 Subject: [PATCH 04/14] Implemented the ability to edit a todo title on double click --- README.md | 18 +++++------ src/App.tsx | 1 + src/Context/TodoContext.tsx | 59 +++++++++++++++++++++++----------- src/components/TodoElement.tsx | 32 ++++++++++++------ src/components/TodoFooter.tsx | 4 +-- src/components/TodoHeader.tsx | 4 +-- src/components/TodoList.tsx | 1 - 7 files changed, 78 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index af7dae81f6..61943330a9 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,16 @@ Add the ability to toggle the completed status of all the todos with the `toggle Implement the ability to edit a todo title on double click: -- show the edit form instead of the title and remove button; -- saves changes on the form submit (just press `Enter`); -- save changes when the field loses focus (`onBlur`); -- if the new title is the same as the old one just cancel editing; ++ show the edit form instead of the title and remove button; ++ saves changes on the form submit (just press `Enter`); ++ save changes when the field loses focus (`onBlur`); ++ if the new title is the same as the old one just cancel editing; - cancel editing on `Esс` key `keyup` event; -- if the new title is empty delete the todo the same way the `x` button does it; -- if the title was changed show the loader while waiting for the API response; -- update the todo title on success; -- show `Unable to update a todo` in case of API error; -- or the deletion error message if we tried to delete the todo. ++ if the new title is empty delete the todo the same way the `x` button does it; ++ if the title was changed show the loader while waiting for the API response; ++ update the todo title on success; ++ show `Unable to update a todo` in case of API error; ++ or the deletion error message if we tried to delete the todo. ## Instructions diff --git a/src/App.tsx b/src/App.tsx index 568d0d8f53..c6e10d56fb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ export const App: React.FC = () => { .then(setTodos) .catch(() => { setError(CurrentError.LoadingError); + throw new Error(); }); }, []); diff --git a/src/Context/TodoContext.tsx b/src/Context/TodoContext.tsx index 169784ede7..59b6c9913d 100644 --- a/src/Context/TodoContext.tsx +++ b/src/Context/TodoContext.tsx @@ -24,12 +24,12 @@ interface TodoContextInterface { setError: (error: CurrentError) => void; handleTodoDelete: (id: number) => void; handleTodoAdd: (newTodo: Omit) => Promise - handleTodoUpdate: (todo: Todo, newTodoTitle: string) => Promise, + handleTodoRename: (todo: Todo, newTodoTitle: string) => Promise, completedTodos: Todo[]; activeTodos: Todo[]; - clearCompleted: () => void; - todosIdToDelete: number[]; - setTodosIdToDelete: (todoIdsToDelete: number[]) => void; + handleClearCompleted: () => void; + processingTodoIds: number[]; + setProcessingTodoIds: (todoIdsToDelete: number[]) => void; } const initalContext: TodoContextInterface = { @@ -43,12 +43,12 @@ const initalContext: TodoContextInterface = { setError: () => {}, handleTodoDelete: () => {}, handleTodoAdd: async () => {}, - handleTodoUpdate: async () => {}, + handleTodoRename: async () => {}, completedTodos: [], activeTodos: [], - clearCompleted: () => {}, - todosIdToDelete: [], - setTodosIdToDelete: () => {}, + handleClearCompleted: () => {}, + processingTodoIds: [], + setProcessingTodoIds: () => {}, }; export const TodoContext = createContext(initalContext); @@ -58,22 +58,31 @@ export const TodoProvider: React.FC = ({ children }) => { const [tempTodo, setTempTodo] = useState(null); const [error, setError] = useState(CurrentError.Default); const [isLoading, setIsLoading] = useState(false); - const [todosIdToDelete, setTodosIdToDelete] = useState([]); + const [processingTodoIds, setProcessingTodoIds] = useState([]); const completedTodos = getCompletedTodos(todos); const activeTodos = getActiveTodos(todos); const handleTodoDelete = (todoId: number) => { setIsLoading(true); - setTodosIdToDelete(prevState => [...prevState, todoId]); + setProcessingTodoIds(prevState => [...prevState, todoId]); + todoService.deleteTodo(todoId) .then(() => { setTodos(prevTodos => { return prevTodos.filter(todo => todo.id !== todoId); }); }) - .catch(() => setError(CurrentError.DeleteError)) - .finally(() => setIsLoading(false)); + .catch(() => { + setError(CurrentError.DeleteError); + throw new Error(); + }) + .finally(() => { + setProcessingTodoIds( + (prevState) => prevState.filter(id => id !== todoId), + ); + setIsLoading(false); + }); }; const handleTodoAdd = (newTodo: Omit) => { @@ -93,7 +102,15 @@ export const TodoProvider: React.FC = ({ children }) => { }); }; - const handleTodoUpdate = (todo: Todo, newTodoTitle: string) => { + const handleTodoRename = (todo: Todo, newTodoTitle: string) => { + if (todo.title === newTodoTitle) { + return Promise.resolve(); + } + + setIsLoading(true); + setProcessingTodoIds(prevState => [...prevState, todo.id]); + + // eslint-disable-next-line consistent-return return todoService .updateTodo({ id: todo.id, @@ -107,10 +124,16 @@ export const TodoProvider: React.FC = ({ children }) => { ? currTodo : updatedTodo; })); + }) + .finally(() => { + setProcessingTodoIds( + (prevState) => prevState.filter(id => id !== todo.id), + ); + setIsLoading(false); }); }; - const clearCompleted = () => { + const handleClearCompleted = () => { completedTodos.forEach(({ id }) => handleTodoDelete(id)); }; @@ -125,12 +148,12 @@ export const TodoProvider: React.FC = ({ children }) => { setError, handleTodoDelete, handleTodoAdd, - handleTodoUpdate, + handleTodoRename, completedTodos, activeTodos, - clearCompleted, - todosIdToDelete, - setTodosIdToDelete, + handleClearCompleted, + processingTodoIds, + setProcessingTodoIds, }), [todos, error, isLoading, tempTodo]); return ( diff --git a/src/components/TodoElement.tsx b/src/components/TodoElement.tsx index 1fb539c44d..f7de6d9e1c 100644 --- a/src/components/TodoElement.tsx +++ b/src/components/TodoElement.tsx @@ -7,6 +7,7 @@ import React, { import classNames from 'classnames'; import { Todo } from '../types/Todo'; import { TodoContext } from '../Context/TodoContext'; +import { CurrentError } from '../types/CurrentError'; type Props = { todo: Todo, @@ -16,14 +17,15 @@ export const TodoElement: React.FC = ({ todo }) => { const { completed, title, id } = todo; const { handleTodoDelete, - todosIdToDelete, - handleTodoUpdate, + processingTodoIds, + handleTodoRename, + setError, } = useContext(TodoContext); const [isChecked, setIsChecked] = useState(completed); const [isEditing, setIsEditing] = useState(false); const [todoTitle, setTodoTitle] = useState(title); - const shouldDisplayLoader = id === 0 || todosIdToDelete.includes(id); + const shouldDisplayLoader = id === 0 || processingTodoIds.includes(id); const handleTodoDoubleClick = () => { setIsEditing(true); @@ -34,13 +36,25 @@ export const TodoElement: React.FC = ({ todo }) => { ) => { event.preventDefault(); - if (todoTitle) { - await handleTodoUpdate(todo, todoTitle); - } else { - await handleTodoDelete(id); - } + try { + if (todoTitle) { + await handleTodoRename(todo, todoTitle); + } else { + await handleTodoDelete(id); + } + + setIsEditing(false); + } catch (error) { + if (error === CurrentError.UpdateError) { + setError(CurrentError.UpdateError); + throw new Error(); + } - setIsEditing(false); + if (error === CurrentError.DeleteError) { + setError(CurrentError.DeleteError); + throw new Error(); + } + } }; const handleTodoTitleChange = ( diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx index 2711adade4..2ea35f07d6 100644 --- a/src/components/TodoFooter.tsx +++ b/src/components/TodoFooter.tsx @@ -15,7 +15,7 @@ export const TodoFooter: React.FC = ({ const { activeTodos, completedTodos, - clearCompleted, + handleClearCompleted, } = useContext(TodoContext); const activeTodosCount = activeTodos.length; @@ -49,7 +49,7 @@ export const TodoFooter: React.FC = ({ data-cy="ClearCompletedButton" disabled={completedTodosCount === 0} style={{ visibility: completedTodos.length ? 'visible' : 'hidden' }} - onClick={clearCompleted} + onClick={handleClearCompleted} > Clear completed diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx index 7ed506d9ca..accb23125d 100644 --- a/src/components/TodoHeader.tsx +++ b/src/components/TodoHeader.tsx @@ -8,7 +8,6 @@ import { import classNames from 'classnames'; import { CurrentError } from '../types/CurrentError'; import { TodoContext } from '../Context/TodoContext'; -import { USER_ID } from '../utils/constants'; type Props = {}; @@ -44,7 +43,7 @@ export const TodoHeader: React.FC = () => { } const newTodo = { - userId: USER_ID, + userId: 0, title: title.trim(), completed: false, }; @@ -56,6 +55,7 @@ export const TodoHeader: React.FC = () => { }) .catch(() => { setError(CurrentError.AddError); + throw new Error(); }); }; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index d832f4f824..0cd8a134b9 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -20,7 +20,6 @@ export const TodoList: React.FC = ({ todos }) => { ))} {tempTodo && ( )} From a49405fefadb9135f6818f0a4a39ddfd8089bf80 Mon Sep 17 00:00:00 2001 From: Diana Honcharova Date: Tue, 26 Sep 2023 11:39:42 +0300 Subject: [PATCH 05/14] cancel edit on esc --- README.md | 2 +- src/components/TodoElement.tsx | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 61943330a9..c78671869c 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Implement the ability to edit a todo title on double click: + saves changes on the form submit (just press `Enter`); + save changes when the field loses focus (`onBlur`); + if the new title is the same as the old one just cancel editing; -- cancel editing on `Esс` key `keyup` event; ++ cancel editing on `Esс` key `keyup` event; + if the new title is empty delete the todo the same way the `x` button does it; + if the title was changed show the loader while waiting for the API response; + update the todo title on success; diff --git a/src/components/TodoElement.tsx b/src/components/TodoElement.tsx index f7de6d9e1c..72af7bf8a8 100644 --- a/src/components/TodoElement.tsx +++ b/src/components/TodoElement.tsx @@ -63,11 +63,18 @@ export const TodoElement: React.FC = ({ todo }) => { setTodoTitle(event.target.value); }; - const titleInput = useRef(null); + const handleOnKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setTodoTitle(title); + } + }; + + const titleInputRef = useRef(null); useEffect(() => { - if (isEditing && titleInput.current) { - titleInput.current.focus(); + if (isEditing && titleInputRef.current) { + titleInputRef.current.focus(); } }, [isEditing]); @@ -99,13 +106,16 @@ export const TodoElement: React.FC = ({ todo }) => { onBlur={handleTodoChange} > { + handleOnKeyUp(event); + }} /> ) From 7e87b2451ef341aaac766c736360a0198773c676 Mon Sep 17 00:00:00 2001 From: Diana Honcharova Date: Tue, 26 Sep 2023 13:29:56 +0300 Subject: [PATCH 06/14] small fix --- README.md | 2 +- src/components/TodoHeader.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c78671869c..6f2e3ff3cd 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://polinavafik.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx index accb23125d..687855cae8 100644 --- a/src/components/TodoHeader.tsx +++ b/src/components/TodoHeader.tsx @@ -8,6 +8,7 @@ import { import classNames from 'classnames'; import { CurrentError } from '../types/CurrentError'; import { TodoContext } from '../Context/TodoContext'; +import { USER_ID } from '../utils/constants'; type Props = {}; @@ -43,7 +44,7 @@ export const TodoHeader: React.FC = () => { } const newTodo = { - userId: 0, + userId: USER_ID, title: title.trim(), completed: false, }; From e56d05bac65a9b3e687dcc12a7d5d234b657eb1b Mon Sep 17 00:00:00 2001 From: Diana Honcharova Date: Tue, 26 Sep 2023 19:55:45 +0300 Subject: [PATCH 07/14] toggle functionality --- README.md | 14 ++++++------ src/Context/TodoContext.tsx | 39 +++++++++++++++++++++++++++++++--- src/components/TodoElement.tsx | 10 ++++----- src/components/TodoHeader.tsx | 26 ++++++++++++++++++----- 4 files changed, 69 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6f2e3ff3cd..5f3a5a2130 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ and implement the ability to toggle and rename todos. Toggle the `completed` status on `TodoStatus` change: -- covered the todo with a loader overlay while waiting for API response; -- the status should be changed on success; -- show the `Unable to update a todo` notification in case of API error. ++ covered the todo with a loader overlay while waiting for API response; ++ the status should be changed on success; ++ show the `Unable to update a todo` notification in case of API error. Add the ability to toggle the completed status of all the todos with the `toggleAll` checkbox: -- `toggleAll` button should have `active` class only if all the todos are completed; -- `toggleAll` click changes its status to the opposite one, and sets this new status to all the todos; -- it should work the same as several individual updates of the todos which statuses were actually changed; -- do send requests for the todos that were not changed; ++ `toggleAll` button should have `active` class only if all the todos are completed; ++ `toggleAll` click changes its status to the opposite one, and sets this new status to all the todos; +🤨 it should work the same as several individual updates of the todos which statuses were actually changed; +🤨 do send requests for the todos that were not changed; ## Renaming a todo diff --git a/src/Context/TodoContext.tsx b/src/Context/TodoContext.tsx index 59b6c9913d..0a16811088 100644 --- a/src/Context/TodoContext.tsx +++ b/src/Context/TodoContext.tsx @@ -22,6 +22,7 @@ interface TodoContextInterface { setIsLoading: (isLoading: boolean) => void; error: CurrentError, setError: (error: CurrentError) => void; + handleToggleChange: (todo: Todo) => void; handleTodoDelete: (id: number) => void; handleTodoAdd: (newTodo: Omit) => Promise handleTodoRename: (todo: Todo, newTodoTitle: string) => Promise, @@ -41,6 +42,7 @@ const initalContext: TodoContextInterface = { setIsLoading: () => {}, error: CurrentError.Default, setError: () => {}, + handleToggleChange: () => {}, handleTodoDelete: () => {}, handleTodoAdd: async () => {}, handleTodoRename: async () => {}, @@ -113,10 +115,8 @@ export const TodoProvider: React.FC = ({ children }) => { // eslint-disable-next-line consistent-return return todoService .updateTodo({ - id: todo.id, + ...todo, title: newTodoTitle, - userId: todo.userId, - completed: todo.completed, }) .then(updatedTodo => { setTodos(prevState => prevState.map(currTodo => { @@ -125,6 +125,38 @@ export const TodoProvider: React.FC = ({ children }) => { : updatedTodo; })); }) + .catch(() => { + setError(CurrentError.UpdateError); + throw new Error(CurrentError.UpdateError); + }) + .finally(() => { + setProcessingTodoIds( + (prevState) => prevState.filter(id => id !== todo.id), + ); + setIsLoading(false); + }); + }; + + const handleToggleChange = (todo: Todo) => { + setIsLoading(true); + setProcessingTodoIds(prevState => [...prevState, todo.id]); + + todoService.updateTodo({ + ...todo, + completed: !todo.completed, + }) + .then((updatedTodo) => { + setTodos(prevState => prevState + .map(currTodo => ( + currTodo.id === updatedTodo.id + ? updatedTodo + : currTodo + ))); + }) + .catch(() => { + setError(CurrentError.UpdateError); + throw new Error(CurrentError.UpdateError); + }) .finally(() => { setProcessingTodoIds( (prevState) => prevState.filter(id => id !== todo.id), @@ -146,6 +178,7 @@ export const TodoProvider: React.FC = ({ children }) => { setIsLoading, error, setError, + handleToggleChange, handleTodoDelete, handleTodoAdd, handleTodoRename, diff --git a/src/components/TodoElement.tsx b/src/components/TodoElement.tsx index 72af7bf8a8..dc67792be1 100644 --- a/src/components/TodoElement.tsx +++ b/src/components/TodoElement.tsx @@ -20,9 +20,9 @@ export const TodoElement: React.FC = ({ todo }) => { processingTodoIds, handleTodoRename, setError, + handleToggleChange, } = useContext(TodoContext); - const [isChecked, setIsChecked] = useState(completed); const [isEditing, setIsEditing] = useState(false); const [todoTitle, setTodoTitle] = useState(title); const shouldDisplayLoader = id === 0 || processingTodoIds.includes(id); @@ -84,7 +84,7 @@ export const TodoElement: React.FC = ({ todo }) => { data-cy="Todo" className={classNames( 'todo', - { completed: isChecked }, + { completed }, )} > diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx index 687855cae8..858f0bab7d 100644 --- a/src/components/TodoHeader.tsx +++ b/src/components/TodoHeader.tsx @@ -2,6 +2,7 @@ import { useContext, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -14,22 +15,39 @@ type Props = {}; export const TodoHeader: React.FC = () => { const { + todos, setError, handleTodoAdd, setTempTodo, activeTodos, completedTodos, isLoading, + handleToggleChange, } = useContext(TodoContext); const [title, setTitle] = useState(''); + + const activeTodosCount = activeTodos.length; + const inputField = useRef(null); + const isAllCompleted = useMemo(() => ( + todos.every(el => el.completed) + ), [todos]); + useEffect(() => { if (!isLoading) { inputField.current?.focus(); } }, [isLoading]); + const handleToggleAll = () => { + if (activeTodosCount) { + activeTodos.forEach(todo => handleToggleChange(todo)); + } else { + completedTodos.forEach(todo => handleToggleChange(todo)); + } + }; + const handleTitleChange = (event: React.ChangeEvent) => { setTitle(event.target.value); }; @@ -60,19 +78,17 @@ export const TodoHeader: React.FC = () => { }); }; - const activeTodosCount = activeTodos.length; - const completedTodosCount = completedTodos.length; - return (
- {!!activeTodosCount && ( + {!!todos.length && (