From 7b29fc313954b1ecd890251612c8c5c2f3e4d934 Mon Sep 17 00:00:00 2001 From: Kristina Bekher Date: Thu, 17 Aug 2023 20:31:47 +0200 Subject: [PATCH 1/9] add toggle all todos status function --- src/App.tsx | 236 +++++++++++++++++++++-- src/TodosContext.tsx | 63 ++++++ src/api/todos.ts | 22 +++ src/components/TodoFilter/TodoFilter.tsx | 51 +++++ src/components/TodoItem/TodoItem.tsx | 157 +++++++++++++++ src/components/TodoList/TodoList.tsx | 43 +++++ src/components/TodoList/animations.scss | 46 +++++ src/hooks/useTodo.ts | 5 + src/index.tsx | 7 +- src/styles/todo.scss | 13 +- src/styles/todoapp.scss | 2 + src/types/ErrorMessage.ts | 8 + src/types/Status.ts | 5 + src/types/Todo.ts | 6 + src/types/TodosContext.ts | 17 ++ src/utils/fetchClient.ts | 46 +++++ 16 files changed, 708 insertions(+), 19 deletions(-) create mode 100644 src/TodosContext.tsx create mode 100644 src/api/todos.ts create mode 100644 src/components/TodoFilter/TodoFilter.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/animations.scss create mode 100644 src/hooks/useTodo.ts create mode 100644 src/types/ErrorMessage.ts create mode 100644 src/types/Status.ts create mode 100644 src/types/Todo.ts create mode 100644 src/types/TodosContext.ts create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 5749bdf784..33188a2130 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,236 @@ -/* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { + useEffect, useMemo, useRef, useState, +} from 'react'; +import classNames from 'classnames'; + import { UserWarning } from './UserWarning'; +import * as todoService from './api/todos'; +import { TodoList } from './components/TodoList/TodoList'; +import { TodoFilter } from './components/TodoFilter/TodoFilter'; +import { useTodo } from './hooks/useTodo'; +import { ErrorMessage } from './types/ErrorMessage'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +const USER_ID = 11340; export const App: React.FC = () => { + const { + todos, + setTodos, + isChecked, + setIsChecked, + errorMessage, + setErrorMessage, + setIsProcessing, + } = useTodo(); + + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }); + + useEffect(() => { + todoService.getTodos(USER_ID) + .then(setTodos) + .catch(() => setErrorMessage(ErrorMessage.LOAD_ERROR)); + }, []); + + useEffect(() => { + const timer = setTimeout(() => { + setErrorMessage(ErrorMessage.DEFAULT); + }, 3000); + + return () => clearTimeout(timer); + }, [errorMessage]); + + setIsChecked(todos.every(todo => todo.completed) && todos.length > 0); + + const handleTitleChange = (event: React.ChangeEvent) => { + setErrorMessage(ErrorMessage.DEFAULT); + setNewTodoTitle(event.target.value); + }; + + const addNewTodo = (newTitle: string) => { + const newTodoToAdd = { + id: 0, + userId: USER_ID, + title: (newTitle), + completed: false, + }; + + setTempTodo(newTodoToAdd); + + todoService.addTodo(newTodoToAdd) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + }) + .catch((error) => { + setErrorMessage(ErrorMessage.ADD_ERROR); + throw error; + }) + .finally(() => { + setTempTodo(null); + setNewTodoTitle(''); + }); + }; + + const deleteSelectedTodo = (todoId: number): void => { + setIsProcessing(currentIds => [...currentIds, todoId]); + + todoService.deleteTodo(todoId) + .then(() => { + setTodos(curentTodos => curentTodos.filter(item => item.id !== todoId)); + }) + .catch((error) => { + setTodos(todos); + setErrorMessage(ErrorMessage.DELETE_ERROR); + throw error; + }) + .finally(() => setIsProcessing([])); + }; + + const updateSelectedTodo = (updatedTodo: Todo): void => { + setIsProcessing(currentIds => [...currentIds, updatedTodo.id]); + + todoService.updateTodo(updatedTodo) + .then(todo => { + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = newTodos.findIndex(post => post.id === updatedTodo.id); + + newTodos.splice(index, 1, todo); + + return newTodos; + }); + }) + .catch((error) => { + setErrorMessage(ErrorMessage.UPDATE_ERROR); + throw error; + }) + .finally(() => { + setIsProcessing([]); + setIsChecked(!isChecked); + }); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const newTitle = newTodoTitle.trim(); + + if (!newTitle) { + setErrorMessage(ErrorMessage.TITLE_ERROR); + setNewTodoTitle(''); + + return; + } + + addNewTodo(newTitle); + }; + + const handleCheckAllTodos = () => { + todos.forEach(todo => { + const updatedTodo = { + ...todo, + completed: !isChecked, + }; + + updateSelectedTodo(updatedTodo); + }); + }; + + const activeTodosCounter = useMemo(() => { + return todos.filter(todo => !todo.completed).length; + }, [todos]); + + const completedTodosCounter = useMemo(() => { + return todos.filter(todo => todo.completed).length; + }, [todos]); + + const clearCompletedTodos = () => { + todos.forEach(todo => todo.completed && deleteSelectedTodo(todo.id)); + }; + + const handleDeleteErrorMessage = () => { + setErrorMessage(ErrorMessage.DEFAULT); + }; + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+
+ + {(todos.length > 0 || tempTodo !== null) && ( + <> + + +
+ + {`${activeTodosCounter} item${activeTodosCounter === 1 ? '' : 's'} left`} + + + + + +
+ + )} +
+ + {errorMessage && ( +
+
+ )} +
); }; diff --git a/src/TodosContext.tsx b/src/TodosContext.tsx new file mode 100644 index 0000000000..1fb99b51a4 --- /dev/null +++ b/src/TodosContext.tsx @@ -0,0 +1,63 @@ +import React, { useMemo, useState } from 'react'; +import { Todo } from './types/Todo'; +import { Status } from './types/Status'; +import { TodosContextType } from './types/TodosContext'; +import { ErrorMessage } from './types/ErrorMessage'; + +export const TodosContext = React.createContext({ + todos: [], + setTodos: () => {}, + visibleTodos: [], + filter: Status.ALL, + setFilter: () => { }, + isChecked: false, + setIsChecked: () => {}, + errorMessage: ErrorMessage.DEFAULT, + setErrorMessage: () => {}, + isProcessing: [], + setIsProcessing: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [isChecked, setIsChecked] = useState(false); + const [filter, setFilter] = useState(Status.ALL); + const [errorMessage, setErrorMessage] = useState(ErrorMessage.DEFAULT); + const [isProcessing, setIsProcessing] = useState([]); + + const visibleTodos = useMemo(() => { + if (filter === Status.ACTIVE) { + return todos.filter(todo => !todo.completed); + } + + if (filter === Status.COMPLETED) { + return todos.filter(todo => todo.completed); + } + + return todos; + }, [filter, todos]); + + const value = { + todos, + setTodos, + visibleTodos, + isChecked, + setIsChecked, + filter, + setFilter, + errorMessage, + setErrorMessage, + isProcessing, + setIsProcessing, + }; + + return ( + + {children} + + ); +}; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..329b2be021 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,22 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const addTodo = ({ userId, title, completed }: Omit) => { + return client.post('/todos', { title, userId, completed }); +}; + +export const updateTodo = ({ + id, + title, + completed, +}: Todo) => { + return client.patch(`/todos/${id}`, { title, completed }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; diff --git a/src/components/TodoFilter/TodoFilter.tsx b/src/components/TodoFilter/TodoFilter.tsx new file mode 100644 index 0000000000..df45dd4551 --- /dev/null +++ b/src/components/TodoFilter/TodoFilter.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import classNames from 'classnames'; +import { useTodo } from '../../hooks/useTodo'; +import { Status } from '../../types/Status'; + +export const TodoFilter: React.FC = () => { + const { filter, setFilter } = useTodo(); + + const handleClick = ( + event: React.MouseEvent, + ) => { + const target = event.target as HTMLElement; + const newFilter = target.innerText; + + setFilter(newFilter as Status); + }; + + return ( + + ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..0fbea42709 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,157 @@ +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../../types/Todo'; +import { useTodo } from '../../hooks/useTodo'; +import { ErrorMessage } from '../../types/ErrorMessage'; +import { deleteTodo } from '../../api/todos'; + +type Props = { + todo: Todo; + loading?: boolean; +}; + +export const TodoItem: React.FC = ({ todo, loading }) => { + const { + todos, + setTodos, + setIsChecked, + setErrorMessage, + isProcessing, + setIsProcessing, + } = useTodo(); + const [title, setTitle] = useState(todo.title); + const [isEditing, setIsEditing] = useState(false); + const [focus, setFocus] = useState(false); + + const inputRef = useRef(null); + + useEffect(() => { + if (focus && inputRef.current) { + inputRef.current.focus(); + } + }, [focus]); + + const checkTodo = (todoId: number): void => { + const updatedTodos = todos.map(item => ( + item.id === todoId + ? { ...item, completed: !item.completed } + : item)); + + setTodos(updatedTodos); + setIsChecked(updatedTodos.every(item => item.completed)); + }; + + const handleDoubleClick = useCallback(() => { + setIsEditing(true); + setFocus(true); + }, [isEditing]); + + const deleteSelectedTodo = (todoId: number): void => { + setIsProcessing(currentIds => [...currentIds, todoId]); + + deleteTodo(todoId) + .then(() => { + setTodos(curentTodos => curentTodos.filter(item => item.id !== todoId)); + }) + .catch((error) => { + setTodos(todos); + setErrorMessage(ErrorMessage.DELETE_ERROR); + throw error; + }) + .finally(() => setIsProcessing([])); + }; + + const handleBlur = () => { + const newTitle = title.trim(); + + if (!newTitle) { + deleteSelectedTodo(todo.id); + + return; + } + + setTodos(todos.map(item => ( + item.id === todo.id + ? { + ...todo, + title: newTitle, + } + : item))); + + setTitle(newTitle); + setIsEditing(false); + setFocus(false); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setTitle(todo.title); + setIsEditing(false); + setFocus(false); + } + + if (event.key === 'Enter') { + handleBlur(); + } + }; + + return ( +
+ + + {!isEditing ? ( + <> + + {todo.title} + + + + ) : ( +
+ setTitle(event.target.value)} + value={title} + onBlur={handleBlur} + onKeyUp={handleKeyUp} + /> +
+ )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..61c90bda3a --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { useTodo } from '../../hooks/useTodo'; +import { Todo } from '../../types/Todo'; +import './animations.scss'; + +type Props = { + tempTodo: Todo | null; +}; + +export const TodoList: React.FC = ({ tempTodo }) => { + const { visibleTodos } = useTodo(); + + return ( +
+ + {visibleTodos.map(todo => ( + + + + ))} + + {tempTodo && ( + + + + )} + +
+ ); +}; diff --git a/src/components/TodoList/animations.scss b/src/components/TodoList/animations.scss new file mode 100644 index 0000000000..04f5318372 --- /dev/null +++ b/src/components/TodoList/animations.scss @@ -0,0 +1,46 @@ +.item-enter { + max-height: 0; +} + +.item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.item-exit { + max-height: 58px; +} + +.item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-enter { + max-height: 0; +} + +.temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-exit { + max-height: 58px; +} + +.temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; +} + +.has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; +} diff --git a/src/hooks/useTodo.ts b/src/hooks/useTodo.ts new file mode 100644 index 0000000000..9e441735cc --- /dev/null +++ b/src/hooks/useTodo.ts @@ -0,0 +1,5 @@ +import React from 'react'; +import { TodosContext } from '../TodosContext'; +import { TodosContextType } from '../types/TodosContext'; + +export const useTodo = (): TodosContextType => React.useContext(TodosContext); diff --git a/src/index.tsx b/src/index.tsx index 7de19e0c70..3da113de25 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 './TodosContext'; createRoot(document.getElementById('root') as HTMLDivElement) - .render(); + .render( + + + , + ); diff --git a/src/styles/todo.scss b/src/styles/todo.scss index fc99714ef2..0699047769 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -8,7 +8,7 @@ font-size: 24px; line-height: 1.4em; border-bottom: 1px solid #ededed; - + &:last-child { border-bottom: 0; } @@ -30,7 +30,7 @@ &__title { padding: 12px 15px; - + word-break: break-all; transition: color 0.4s; } @@ -56,7 +56,7 @@ border: 0; background: none; cursor: pointer; - + transform: translateY(-2px); opacity: 0; transition: color 0.2s ease-out; @@ -65,7 +65,7 @@ color: #af5b5e; } } - + &:hover &__remove { opacity: 1; } @@ -73,15 +73,16 @@ &__title-field { width: 100%; padding: 11px 14px; - + font-size: inherit; line-height: inherit; font-family: inherit; font-weight: inherit; color: inherit; - + border: 1px solid #999; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + outline: none; &::placeholder { font-style: italic; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index 836166156b..d1d4a75f5a 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -70,6 +70,7 @@ border: none; background: rgba(0, 0, 0, 0.01); box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); + outline: none; &::placeholder { font-style: italic; @@ -105,6 +106,7 @@ } &__clear-completed { + width: 92px; margin: 0; padding: 0; border: 0; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..945af08377 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,8 @@ +export enum ErrorMessage { + DEFAULT = '', + LOAD_ERROR = 'Unable to load todos', + TITLE_ERROR = 'Title cannot be empty', + ADD_ERROR = 'Unable to add a todo', + DELETE_ERROR = 'Unable to delete a todo', + UPDATE_ERROR = 'Unable to update a todo', +} diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 0000000000..6315e8e9ec --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + ALL = 'All', + ACTIVE = 'Active', + COMPLETED = 'Completed', +} 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/TodosContext.ts b/src/types/TodosContext.ts new file mode 100644 index 0000000000..ac07136eef --- /dev/null +++ b/src/types/TodosContext.ts @@ -0,0 +1,17 @@ +import { ErrorMessage } from './ErrorMessage'; +import { Status } from './Status'; +import { Todo } from './Todo'; + +export type TodosContextType = { + todos: Todo[]; + setTodos: (value: Todo[] | { (prev: Todo[]) : Todo[] }) => void; + visibleTodos: Todo[]; + isChecked: boolean; + setIsChecked: (value: boolean) => void; + filter: Status; + setFilter: (value: Status) => void; + errorMessage: string; + setErrorMessage: (value: ErrorMessage) => void; + isProcessing: number[]; + setIsProcessing: (value: [] | { (prev: number[]) : number[] }) => void; +}; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..42421feae0 --- /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(); + } + + 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 f9a60b4ce036bf686e1975ce15b37181023e902e Mon Sep 17 00:00:00 2001 From: Kristina Bekher Date: Thu, 17 Aug 2023 20:51:52 +0200 Subject: [PATCH 2/9] add check update func --- src/App.tsx | 2 +- src/components/TodoItem/TodoItem.tsx | 48 +++++++++++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 33188a2130..018518cdce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,7 +103,7 @@ export const App: React.FC = () => { .then(todo => { setTodos(currentTodos => { const newTodos = [...currentTodos]; - const index = newTodos.findIndex(post => post.id === updatedTodo.id); + const index = newTodos.findIndex(item => item.id === updatedTodo.id); newTodos.splice(index, 1, todo); diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index 0fbea42709..cc609bc267 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -6,7 +6,7 @@ import classNames from 'classnames'; import { Todo } from '../../types/Todo'; import { useTodo } from '../../hooks/useTodo'; import { ErrorMessage } from '../../types/ErrorMessage'; -import { deleteTodo } from '../../api/todos'; +import { deleteTodo, updateTodo } from '../../api/todos'; type Props = { todo: Todo; @@ -34,20 +34,39 @@ export const TodoItem: React.FC = ({ todo, loading }) => { } }, [focus]); - const checkTodo = (todoId: number): void => { - const updatedTodos = todos.map(item => ( - item.id === todoId - ? { ...item, completed: !item.completed } - : item)); + const updateSelectedTodo = (updatedTodo: Todo): void => { + setIsProcessing(currentIds => [...currentIds, updatedTodo.id]); + + updateTodo(updatedTodo) + .then(newTodo => { + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = newTodos.findIndex(item => item.id === updatedTodo.id); + + newTodos.splice(index, 1, newTodo); - setTodos(updatedTodos); - setIsChecked(updatedTodos.every(item => item.completed)); + return newTodos; + }); + }) + .catch((error) => { + setErrorMessage(ErrorMessage.UPDATE_ERROR); + throw error; + }) + .finally(() => { + setIsProcessing([]); + setIsChecked(todos.every(item => item.completed)); + }); }; - const handleDoubleClick = useCallback(() => { - setIsEditing(true); - setFocus(true); - }, [isEditing]); + const checkTodo = (todoId: number): void => { + const todoToUpdate = todos.find(item => item.id === todoId); + + if (todoToUpdate) { + updateSelectedTodo({ + ...todoToUpdate, completed: !todoToUpdate.completed, + }); + } + }; const deleteSelectedTodo = (todoId: number): void => { setIsProcessing(currentIds => [...currentIds, todoId]); @@ -64,6 +83,11 @@ export const TodoItem: React.FC = ({ todo, loading }) => { .finally(() => setIsProcessing([])); }; + const handleDoubleClick = useCallback(() => { + setIsEditing(true); + setFocus(true); + }, [isEditing]); + const handleBlur = () => { const newTitle = title.trim(); From a74050308d18d89cc2ec4a746f64f2d4c73abe54 Mon Sep 17 00:00:00 2001 From: Kristina Bekher Date: Thu, 17 Aug 2023 21:09:40 +0200 Subject: [PATCH 3/9] add update todo function --- README.md | 2 +- src/App.tsx | 8 +++--- src/components/TodoItem/TodoItem.tsx | 42 +++++++++++++++++----------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index c7bfa3dd36..f9dddc6eeb 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://kbekher.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 018518cdce..2b6d6dd370 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -146,6 +146,10 @@ export const App: React.FC = () => { }); }; + const clearCompletedTodos = () => { + todos.forEach(todo => todo.completed && deleteSelectedTodo(todo.id)); + }; + const activeTodosCounter = useMemo(() => { return todos.filter(todo => !todo.completed).length; }, [todos]); @@ -154,10 +158,6 @@ export const App: React.FC = () => { return todos.filter(todo => todo.completed).length; }, [todos]); - const clearCompletedTodos = () => { - todos.forEach(todo => todo.completed && deleteSelectedTodo(todo.id)); - }; - const handleDeleteErrorMessage = () => { setErrorMessage(ErrorMessage.DEFAULT); }; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index cc609bc267..fb9d5eb6b4 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -58,16 +58,6 @@ export const TodoItem: React.FC = ({ todo, loading }) => { }); }; - const checkTodo = (todoId: number): void => { - const todoToUpdate = todos.find(item => item.id === todoId); - - if (todoToUpdate) { - updateSelectedTodo({ - ...todoToUpdate, completed: !todoToUpdate.completed, - }); - } - }; - const deleteSelectedTodo = (todoId: number): void => { setIsProcessing(currentIds => [...currentIds, todoId]); @@ -83,6 +73,16 @@ export const TodoItem: React.FC = ({ todo, loading }) => { .finally(() => setIsProcessing([])); }; + const checkTodo = (todoId: number): void => { + const todoToUpdate = todos.find(item => item.id === todoId); + + if (todoToUpdate) { + updateSelectedTodo({ + ...todoToUpdate, completed: !todoToUpdate.completed, + }); + } + }; + const handleDoubleClick = useCallback(() => { setIsEditing(true); setFocus(true); @@ -97,13 +97,21 @@ export const TodoItem: React.FC = ({ todo, loading }) => { return; } - setTodos(todos.map(item => ( - item.id === todo.id - ? { - ...todo, - title: newTitle, - } - : item))); + if (newTitle === todo.title) { + setTitle(todo.title); + setIsEditing(false); + setFocus(false); + + return; + } + + const todoToUpdate = todos.find(item => item.id === todo.id); + + if (todoToUpdate) { + updateSelectedTodo({ + ...todoToUpdate, title: newTitle, + }); + } setTitle(newTitle); setIsEditing(false); From 020594f11d9428da0cdb34f3a3bfe4885365e78f Mon Sep 17 00:00:00 2001 From: Kristina Bekher Date: Thu, 17 Aug 2023 21:17:14 +0200 Subject: [PATCH 4/9] update solution --- src/App.tsx | 8 ++++---- src/components/TodoItem/TodoItem.tsx | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2b6d6dd370..018518cdce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -146,10 +146,6 @@ export const App: React.FC = () => { }); }; - const clearCompletedTodos = () => { - todos.forEach(todo => todo.completed && deleteSelectedTodo(todo.id)); - }; - const activeTodosCounter = useMemo(() => { return todos.filter(todo => !todo.completed).length; }, [todos]); @@ -158,6 +154,10 @@ export const App: React.FC = () => { return todos.filter(todo => todo.completed).length; }, [todos]); + const clearCompletedTodos = () => { + todos.forEach(todo => todo.completed && deleteSelectedTodo(todo.id)); + }; + const handleDeleteErrorMessage = () => { setErrorMessage(ErrorMessage.DEFAULT); }; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index fb9d5eb6b4..43794f9d31 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -58,6 +58,16 @@ export const TodoItem: React.FC = ({ todo, loading }) => { }); }; + const checkTodo = (todoId: number): void => { + const todoToUpdate = todos.find(item => item.id === todoId); + + if (todoToUpdate) { + updateSelectedTodo({ + ...todoToUpdate, completed: !todoToUpdate.completed, + }); + } + }; + const deleteSelectedTodo = (todoId: number): void => { setIsProcessing(currentIds => [...currentIds, todoId]); @@ -73,16 +83,6 @@ export const TodoItem: React.FC = ({ todo, loading }) => { .finally(() => setIsProcessing([])); }; - const checkTodo = (todoId: number): void => { - const todoToUpdate = todos.find(item => item.id === todoId); - - if (todoToUpdate) { - updateSelectedTodo({ - ...todoToUpdate, completed: !todoToUpdate.completed, - }); - } - }; - const handleDoubleClick = useCallback(() => { setIsEditing(true); setFocus(true); From f63c691734f56544e63d0d5ed8f855a8129b29ff Mon Sep 17 00:00:00 2001 From: Kristina Bekher Date: Fri, 18 Aug 2023 19:26:26 +0200 Subject: [PATCH 5/9] update solution --- src/components/TodoItem/TodoItem.tsx | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index 43794f9d31..c7a27f67dd 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -58,14 +58,13 @@ export const TodoItem: React.FC = ({ todo, loading }) => { }); }; - const checkTodo = (todoId: number): void => { - const todoToUpdate = todos.find(item => item.id === todoId); + const checkTodo = (selectedTodo: Todo): void => { + const updatedTodo = { + ...selectedTodo, + completed: !selectedTodo.completed, + }; - if (todoToUpdate) { - updateSelectedTodo({ - ...todoToUpdate, completed: !todoToUpdate.completed, - }); - } + updateSelectedTodo(updatedTodo); }; const deleteSelectedTodo = (todoId: number): void => { @@ -105,13 +104,12 @@ export const TodoItem: React.FC = ({ todo, loading }) => { return; } - const todoToUpdate = todos.find(item => item.id === todo.id); + const updatedTodo = { + ...todo, + title: newTitle, + }; - if (todoToUpdate) { - updateSelectedTodo({ - ...todoToUpdate, title: newTitle, - }); - } + updateSelectedTodo(updatedTodo); setTitle(newTitle); setIsEditing(false); @@ -141,7 +139,7 @@ export const TodoItem: React.FC = ({ todo, loading }) => { type="checkbox" className="todo__status" checked={todo.completed} - onChange={() => checkTodo(todo.id as number)} + onChange={() => checkTodo(todo)} /> From f6346c7ff4644d9481e676a0cb8cc170aeedcef8 Mon Sep 17 00:00:00 2001 From: Kristina Bekher Date: Fri, 18 Aug 2023 19:42:12 +0200 Subject: [PATCH 6/9] update --- src/api/todos.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/api/todos.ts b/src/api/todos.ts index 329b2be021..89623cedd7 100644 --- a/src/api/todos.ts +++ b/src/api/todos.ts @@ -9,11 +9,9 @@ export const addTodo = ({ userId, title, completed }: Omit) => { return client.post('/todos', { title, userId, completed }); }; -export const updateTodo = ({ - id, - title, - completed, -}: Todo) => { +export const updateTodo = ( + { id, title, completed }: Todo, +) => { return client.patch(`/todos/${id}`, { title, completed }); }; From fb10acdef4457c0b3a3398517a4d36eec5275812 Mon Sep 17 00:00:00 2001 From: Kristina Bekher Date: Fri, 18 Aug 2023 19:46:10 +0200 Subject: [PATCH 7/9] update link --- src/utils/fetchClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts index 42421feae0..0e763b3b39 100644 --- a/src/utils/fetchClient.ts +++ b/src/utils/fetchClient.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -const BASE_URL = 'https://mate.academy/students-api'; +const BASE_URL = 'https://mate.academy/students-api/'; // returns a promise resolved after a given delay function wait(delay: number) { From e4c2e33a1fb243011e2b70a1d6f1ba6e11629df0 Mon Sep 17 00:00:00 2001 From: Kristina Bekher Date: Fri, 18 Aug 2023 19:50:01 +0200 Subject: [PATCH 8/9] update --- src/utils/fetchClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts index 0e763b3b39..42421feae0 100644 --- a/src/utils/fetchClient.ts +++ b/src/utils/fetchClient.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -const BASE_URL = 'https://mate.academy/students-api/'; +const BASE_URL = 'https://mate.academy/students-api'; // returns a promise resolved after a given delay function wait(delay: number) { From 9f6dc84fd825aca8bdb37e1e575932cac9a56f92 Mon Sep 17 00:00:00 2001 From: Kristina Bekher Date: Thu, 14 Sep 2023 18:51:24 +0200 Subject: [PATCH 9/9] update README file --- README.md | 66 +++++++++++++++++++------------------------------------ 1 file changed, 22 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index f9dddc6eeb..eb1469cb6a 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,22 @@ -# React Todo App with API (complete) - -It is the third part of the React Todo App with API. - -Take your code implemented for [Add and Delete](https://github.com/mate-academy/react_todo-app-add-and-delete) -and implement the ability to toggle and rename todos. - -> Here is [the working example](https://mate-academy.github.io/react_todo-app-with-api/) - -## Toggling a todo status - -Toggle the `completed` status on `TodoStatus` change: - -- covered the todo with a loader overlay while wating 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 oppsite 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 - -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 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. - -## Instructions - -- 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://kbekher.github.io/react_todo-app-with-api/) and add it to the PR description. +# React Todo App with API +React Todo App is built on React and uses RapidAPI. It has a intuitive and friendly user interface that helps you manage tasks efficiently. + +# Features +- Intuitive Task Creation: Easily add and organize tasks with a clean and user-friendly interface. +- Real-time Updates: Instantly see changes and updates without refreshing the page. +- Task Completion: Mark tasks as complete and track your progress. +- Task Editing: Modify task details on the fly, adapting to your evolving needs. +- Filtering: Quickly find tasks with filtering options. +- Data Persistence: Your tasks are saved and available when you return to the app. + +# Technologies & Tools Used +- React +- Typescript +- HTML +- SCSS +- JavaScript +- VS Code +- Rapid API + +# Demo +A live demo of the React Products Catalog App is available at [DEMO](https://kbekher.github.io/react_todo-app-with-api/) .