From 4e87e892c3d14ab1747689755d4604bc35227a2d Mon Sep 17 00:00:00 2001 From: Viktoriia Date: Thu, 12 Dec 2024 18:11:32 +0200 Subject: [PATCH 1/5] draft 1 --- package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 283 +++++++++++++++++++++++++-- src/api/todos.ts | 22 +++ src/components/Error/Erros.tsx | 34 ++++ src/components/Error/index.tsx | 0 src/components/Footer/Fotter.tsx | 67 +++++++ src/components/Footer/index.tsx | 0 src/components/Header/Header.tsx | 60 ++++++ src/components/Header/index.tsx | 0 src/components/TempTodo/TempTodo.tsx | 34 ++++ src/components/TempTodo/index.tsx | 0 src/components/TodoItem/TodoItem.tsx | 102 ++++++++++ src/components/TodoItem/index.tsx | 0 src/components/TodoList/TodoList.tsx | 50 +++++ src/components/TodoList/index.tsx | 0 src/types/Status.tsx | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++ 19 files changed, 701 insertions(+), 19 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Error/Erros.tsx create mode 100644 src/components/Error/index.tsx create mode 100644 src/components/Footer/Fotter.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/TempTodo/TempTodo.tsx create mode 100644 src/components/TempTodo/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/Status.tsx create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/package-lock.json b/package-lock.json index 19701e8788..511279ae76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1183,10 +1183,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index b6062525ab..005692edf7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..bac26a765a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,281 @@ -/* 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, { useState, useEffect } from 'react'; + import { UserWarning } from './UserWarning'; +import { USER_ID } from './api/todos'; +import { Header } from './components/Header/Header'; +import { TodoList } from './components/TodoList/TodoList'; +import { TempTodo } from './components/TempTodo/TempTodo'; +import { Footer } from './components/Footer/Fotter'; +import { Error } from './components/Error/Erros'; +import { getTodos, addTodo, deleteTodo, updateTodo } from './api/todos'; + +import { Todo } from './types/Todo'; +import { TodoStatus } from './types/Status'; -const USER_ID = 0; +function filterTodos(todos: Todo[], status: TodoStatus) { + const todosCopy = [...todos]; + + switch (status) { + case TodoStatus.active: + return todosCopy.filter(todo => !todo.completed); + case TodoStatus.completed: + return todosCopy.filter(todo => todo.completed); + case TodoStatus.all: + return todosCopy; + } +} export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [status, setStatus] = useState(TodoStatus.all); + const [title, setTitle] = useState(''); + const [loading, setLoading] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const [processedIs, setProcessedIs] = useState([]); + const areAllTodosCompleted = todos.every(todo => todo.completed); + const noTodos = todos.length === 0; + const filteredTodos = filterTodos(todos, status); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => setErrorMessage('Unable to load todos')); + }, []); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const normalizeTitle = title.trim(); + + if (!normalizeTitle) { + setErrorMessage('Title should not be empty'); + + return; + } + + setLoading(true); + const newTodo = { + userId: 2042, + title: normalizeTitle, + completed: false, + }; + + setTempTodo({ + id: 0, + ...newTodo, + }); + + addTodo(newTodo) + .then(response => { + setTitle(''); + setTodos(existing => [...existing, response]); + }) + .catch(() => setErrorMessage('Unable to add a todo')) + .finally(() => { + setLoading(false); + setTempTodo(null); + }); + }; + + const handleDeleteOneTodo = (id: number) => { + setLoading(true); + setProcessedIs(existing => [...existing, id]); + deleteTodo(id) + .then(() => { + setTodos(existing => existing.filter(current => current.id !== id)); + }) + .catch(() => setErrorMessage('Unable to delete a todo')) + .finally(() => { + setLoading(false); + setProcessedIs([]); + }); + }; + + const handleDeleteCompletedTodos = () => { + todos.forEach(todo => { + if (todo.completed) { + setLoading(true); + setProcessedIs(existing => [...existing, todo.id]); + deleteTodo(todo.id) + .then(() => + setTodos(existing => + existing.filter(current => current.id !== todo.id), + ), + ) + .catch(() => setErrorMessage('Unable to delete a todo')) + .finally(() => { + setLoading(false); + setProcessedIs(existing => existing.filter(id => id !== todo.id)); + }); + } + }); + }; + + const handleStatusUpdate = (id: number) => { + setLoading(true); + setProcessedIs(existing => [...existing, id]); + + const changeItem = todos.find(todo => todo.id === id); + + if (changeItem) { + const toUpdate = { completed: !changeItem.completed }; + + updateTodo(id, toUpdate) + .then(() => { + setTodos(existing => + existing.map(el => + el.id === id ? { ...el, completed: toUpdate.completed } : el, + ), + ); + }) + .catch(() => setErrorMessage('Unable to update a todo')) + .finally(() => { + setLoading(false); + setProcessedIs([]); + }); + } + }; + + const handleTotalStatusUpdate = () => { + if (!areAllTodosCompleted) { + todos.forEach(todo => { + if (!todo.completed) { + setLoading(true); + setProcessedIs(existing => [...existing, todo.id]); + const toUpdate = { completed: true }; + + updateTodo(todo.id, toUpdate) + .then(() => { + setTodos(existing => + existing.map(el => + el.id === todo.id + ? { ...el, completed: toUpdate.completed } + : el, + ), + ); + }) + .catch(() => setErrorMessage('Unable to update a todo')) + .finally(() => { + setLoading(false); + setProcessedIs(existing => existing.filter(id => id !== todo.id)); + }); + } + }); + } + + if (areAllTodosCompleted) { + todos.forEach(todo => { + setLoading(true); + setProcessedIs(existing => [...existing, todo.id]); + const toUpdate = { completed: false }; + + updateTodo(todo.id, toUpdate) + .then(() => { + setTodos(existing => + existing.map(el => + el.id === todo.id + ? { ...el, completed: toUpdate.completed } + : el, + ), + ); + }) + .catch(() => setErrorMessage('Unable to update a todo')) + .finally(() => { + setLoading(false); + setProcessedIs(existing => existing.filter(id => id !== todo.id)); + }); + }); + } + }; + + const handleTitleUpdate = ( + e: React.FormEvent, + id: number, + newTitle: string, + setTitleBeingUpdated: React.Dispatch>, + ) => { + e.preventDefault(); + + const normalizeNewTitle = newTitle.trim(); + const isTitleChanged = normalizeNewTitle !== title; + + if (!normalizeNewTitle.length) { + handleDeleteOneTodo(id); + + return; + } + + if (!isTitleChanged) { + return; + } + + const changeItem = todos.find(todo => todo.id === id); + const toUpdate = { title: normalizeNewTitle }; + + if (changeItem) { + setLoading(true); + setProcessedIs(existing => [...existing, id]); + updateTodo(id, toUpdate) + .then(() => { + setTodos(existing => + existing.map(el => + el.id === id ? { ...el, title: toUpdate.title } : el, + ), + ); + }) + .catch(() => setErrorMessage('Unable to update a todo')) + .finally(() => { + setLoading(false); + setProcessedIs([]); + setTitleBeingUpdated(false); + }); + } + }; + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
setTitle(value)} + onTotalStatusUpdate={handleTotalStatusUpdate} + /> + + + + {tempTodo && } + + {!noTodos && ( +
setStatus(value)} + clearCompletedTodos={handleDeleteCompletedTodos} + /> + )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..58ce7e8dc5 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,22 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 2042; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (todoData: Omit) => { + return client.post('/todos', todoData); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, todoData: Partial) => { + return client.patch(`/todos/${todoId}`, todoData); +}; + +// Add more methods here diff --git a/src/components/Error/Erros.tsx b/src/components/Error/Erros.tsx new file mode 100644 index 0000000000..8d8065c912 --- /dev/null +++ b/src/components/Error/Erros.tsx @@ -0,0 +1,34 @@ +import React, { useEffect } from 'react'; +import classNames from 'classnames'; + +type Props = { + errorMessage: string; + hideError: (arg: string) => void; +}; + +export const Error: React.FC = ({ errorMessage, hideError }) => { + useEffect(() => { + setTimeout(() => hideError(''), 3000); + }); + + return ( +
+
+ ); +}; diff --git a/src/components/Error/index.tsx b/src/components/Error/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/Footer/Fotter.tsx b/src/components/Footer/Fotter.tsx new file mode 100644 index 0000000000..54de8b45a2 --- /dev/null +++ b/src/components/Footer/Fotter.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../../types/Todo'; +import { TodoStatus } from '../../types/Status'; + +type Props = { + todos: Todo[]; + status: TodoStatus; + onStatusChange: (arg: TodoStatus) => void; + clearCompletedTodos: () => void; +}; + +export const Footer: React.FC = ({ + todos, + status, + onStatusChange, + clearCompletedTodos, +}) => { + const activeTodos = todos.filter(todo => !todo.completed); + const isAnyCompleted = todos.some(todo => todo.completed); + const filterOptions = Object.values(TodoStatus); + + const capitalizeFirstLetter = (value: TodoStatus) => { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; + }; + + return ( +
+ + {`${activeTodos.length} 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..e69de29bb2 diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..8cfd6f1d50 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useRef, useEffect } from 'react'; +import classNames from 'classnames'; + +type Props = { + allTodosCompleted: boolean; + noTodos: boolean; + onSubmit: (e: React.FormEvent) => void; + loading: boolean; + title: string; + onTitleChange: (arg: string) => void; + onTotalStatusUpdate: () => void; +}; + +export const Header: React.FC = ({ + allTodosCompleted, + noTodos, + onSubmit, + loading, + title, + onTitleChange, + onTotalStatusUpdate, +}) => { + const inputRef = useRef(null); + + useEffect(() => { + if (!loading && inputRef.current) { + inputRef.current.focus(); + } + }, [loading]); + + return ( +
+ {!noTodos && ( +
+ ); +}; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/TempTodo/TempTodo.tsx b/src/components/TempTodo/TempTodo.tsx new file mode 100644 index 0000000000..53739df757 --- /dev/null +++ b/src/components/TempTodo/TempTodo.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TempTodo: React.FC = ({ todo }) => { + const { title } = todo; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + + {title} + + + + + {/* 'is-active' class puts this modal on top of the todo */} +
+
+
+
+
+ ); +}; diff --git a/src/components/TempTodo/index.tsx b/src/components/TempTodo/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..b904589bb5 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; + +type Props = { + id: number; + completed: boolean; + title: string; + isItemLoading: boolean; + onItemDelete: (id: number) => void; + onItemStatusUpdate: (id: number) => void; + onItemTitleUpdate: ( + arg1: React.FormEvent, + arg2: number, + arg3: string, + arg4: React.Dispatch>, + ) => void; + processedIs: number[]; +}; +export const TodoItem: React.FC = ({ + id, + completed, + title, + isItemLoading, + onItemStatusUpdate, + onItemDelete, + onItemTitleUpdate, + processedIs, +}) => { + const isLoading = isItemLoading && processedIs.includes(id); + const [isTitleBeingUpdated, setTitleBeingUpdated] = useState(false); + const [newTitle, setNewTitle] = useState(title); + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {!isTitleBeingUpdated ? ( + <> + setTitleBeingUpdated(true)} + > + {title} + + + {/* Remove button appears only on hover */} + + + ) : ( +
+ onItemTitleUpdate(event, id, newTitle, setTitleBeingUpdated) + } + onBlur={event => + onItemTitleUpdate(event, id, newTitle, setTitleBeingUpdated) + } + > + setNewTitle(event.target.value)} + disabled={isLoading} + autoFocus + /> +
+ )} + + {/* overlay will cover the todo while it is being deleted or updated */} +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.tsx b/src/components/TodoItem/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..24fc8cdfce --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,50 @@ +import React from 'react'; + +import { TodoItem } from '../TodoItem/TodoItem'; + +import { Todo } from '../../types/Todo'; + +type Props = { + filteredTodos: Todo[]; + onDelete: (id: number) => void; + onStatusUpdate: (id: number) => void; + onUpdatedTitleSubmit: ( + arg1: React.FormEvent, + arg2: number, + arg3: string, + arg4: React.Dispatch>, + ) => void; + loading: boolean; + processedIs: number[]; +}; + +export const TodoList: React.FC = ({ + filteredTodos, + onDelete, + onStatusUpdate, + onUpdatedTitleSubmit, + loading, + processedIs, +}) => { + return ( +
+ {filteredTodos.map(todo => { + const { id, completed, title } = todo; + + return ( + + ); + })} +
+ ); +}; diff --git a/src/components/TodoList/index.tsx b/src/components/TodoList/index.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/types/Status.tsx b/src/types/Status.tsx new file mode 100644 index 0000000000..5ab1d4bd7e --- /dev/null +++ b/src/types/Status.tsx @@ -0,0 +1,5 @@ +export enum TodoStatus { + 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/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 609574c5679cdb44bfb26f67b33f160e81a90469 Mon Sep 17 00:00:00 2001 From: Viktoriia Date: Thu, 12 Dec 2024 19:23:58 +0200 Subject: [PATCH 2/5] draft 2 --- src/App.tsx | 23 ++++--- src/components/TodoItem/TodoItem.tsx | 94 ++++++++++++++++------------ src/components/TodoList/TodoList.tsx | 18 +++--- 3 files changed, 82 insertions(+), 53 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bac26a765a..3fd24297bf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -192,23 +192,25 @@ export const App: React.FC = () => { }; const handleTitleUpdate = ( - e: React.FormEvent, id: number, newTitle: string, setTitleBeingUpdated: React.Dispatch>, + isTitleChanged: boolean, + e?: React.FormEvent, ) => { - e.preventDefault(); + e?.preventDefault(); const normalizeNewTitle = newTitle.trim(); - const isTitleChanged = normalizeNewTitle !== title; - if (!normalizeNewTitle.length) { + if (!normalizeNewTitle) { handleDeleteOneTodo(id); return; } if (!isTitleChanged) { + setTitleBeingUpdated(false); + return; } @@ -216,7 +218,6 @@ export const App: React.FC = () => { const toUpdate = { title: normalizeNewTitle }; if (changeItem) { - setLoading(true); setProcessedIs(existing => [...existing, id]); updateTodo(id, toUpdate) .then(() => { @@ -228,13 +229,21 @@ export const App: React.FC = () => { }) .catch(() => setErrorMessage('Unable to update a todo')) .finally(() => { - setLoading(false); setProcessedIs([]); setTitleBeingUpdated(false); }); } }; + const handleKeyUp = ( + event: React.KeyboardEvent, + setTitleBeingUpdated: React.Dispatch>, + ) => { + if (event.key === 'Escape') { + setTitleBeingUpdated(false); + } + }; + if (!USER_ID) { return ; } @@ -259,7 +268,7 @@ export const App: React.FC = () => { onDelete={handleDeleteOneTodo} onStatusUpdate={handleStatusUpdate} onUpdatedTitleSubmit={handleTitleUpdate} - loading={loading} + onKeyUp={handleKeyUp} processedIs={processedIs} /> diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index b904589bb5..edadbb5feb 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -1,18 +1,22 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import classNames from 'classnames'; type Props = { id: number; completed: boolean; title: string; - isItemLoading: boolean; onItemDelete: (id: number) => void; onItemStatusUpdate: (id: number) => void; onItemTitleUpdate: ( - arg1: React.FormEvent, - arg2: number, - arg3: string, - arg4: React.Dispatch>, + arg1: number, + arg2: string, + arg3: React.Dispatch>, + arg4: boolean, + arg5?: React.FormEvent, + ) => void; + onKeyUp: ( + arg1: React.KeyboardEvent, + arg2: React.Dispatch>, ) => void; processedIs: number[]; }; @@ -20,22 +24,26 @@ export const TodoItem: React.FC = ({ id, completed, title, - isItemLoading, onItemStatusUpdate, onItemDelete, onItemTitleUpdate, + onKeyUp, processedIs, }) => { - const isLoading = isItemLoading && processedIs.includes(id); const [isTitleBeingUpdated, setTitleBeingUpdated] = useState(false); + const prevTitle = title; const [newTitle, setNewTitle] = useState(title); + const isTitleChanged = prevTitle !== newTitle.trim(); + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [isTitleBeingUpdated]); return ( -
+
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - - {!isTitleBeingUpdated ? ( + {isTitleBeingUpdated ? ( +
+ onItemTitleUpdate( + id, + newTitle, + setTitleBeingUpdated, + isTitleChanged, + event, + ) + } + > + + onItemTitleUpdate( + id, + newTitle, + setTitleBeingUpdated, + isTitleChanged, + ) + } + onKeyUp={event => onKeyUp(event, setTitleBeingUpdated)} + onChange={event => setNewTitle(event.target.value)} + /> +
+ ) : ( <> = ({ > {title} - - {/* Remove button appears only on hover */} - ) : ( -
- onItemTitleUpdate(event, id, newTitle, setTitleBeingUpdated) - } - onBlur={event => - onItemTitleUpdate(event, id, newTitle, setTitleBeingUpdated) - } - > - setNewTitle(event.target.value)} - disabled={isLoading} - autoFocus - /> -
)} - - {/* overlay will cover the todo while it is being deleted or updated */}
diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx index 24fc8cdfce..48738f73be 100644 --- a/src/components/TodoList/TodoList.tsx +++ b/src/components/TodoList/TodoList.tsx @@ -9,12 +9,16 @@ type Props = { onDelete: (id: number) => void; onStatusUpdate: (id: number) => void; onUpdatedTitleSubmit: ( - arg1: React.FormEvent, - arg2: number, - arg3: string, - arg4: React.Dispatch>, + arg1: number, + arg2: string, + arg3: React.Dispatch>, + arg4: boolean, + arg5?: React.FormEvent, + ) => void; + onKeyUp: ( + arg1: React.KeyboardEvent, + arg2: React.Dispatch>, ) => void; - loading: boolean; processedIs: number[]; }; @@ -23,7 +27,7 @@ export const TodoList: React.FC = ({ onDelete, onStatusUpdate, onUpdatedTitleSubmit, - loading, + onKeyUp, processedIs, }) => { return ( @@ -40,7 +44,7 @@ export const TodoList: React.FC = ({ onItemDelete={onDelete} onItemStatusUpdate={onStatusUpdate} onItemTitleUpdate={onUpdatedTitleSubmit} - isItemLoading={loading} + onKeyUp={onKeyUp} processedIs={processedIs} /> ); From 5278154d02278891d6f31c6ad56d03820933d26a Mon Sep 17 00:00:00 2001 From: Viktoriia Date: Thu, 12 Dec 2024 22:05:43 +0200 Subject: [PATCH 3/5] final draft --- src/App.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3fd24297bf..72468e910d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,6 +72,7 @@ export const App: React.FC = () => { .then(response => { setTitle(''); setTodos(existing => [...existing, response]); + setLoading(false); }) .catch(() => setErrorMessage('Unable to add a todo')) .finally(() => { @@ -89,8 +90,8 @@ export const App: React.FC = () => { }) .catch(() => setErrorMessage('Unable to delete a todo')) .finally(() => { - setLoading(false); setProcessedIs([]); + setLoading(false); }); }; @@ -115,7 +116,6 @@ export const App: React.FC = () => { }; const handleStatusUpdate = (id: number) => { - setLoading(true); setProcessedIs(existing => [...existing, id]); const changeItem = todos.find(todo => todo.id === id); @@ -133,7 +133,6 @@ export const App: React.FC = () => { }) .catch(() => setErrorMessage('Unable to update a todo')) .finally(() => { - setLoading(false); setProcessedIs([]); }); } @@ -226,11 +225,11 @@ export const App: React.FC = () => { el.id === id ? { ...el, title: toUpdate.title } : el, ), ); + setTitleBeingUpdated(false); }) .catch(() => setErrorMessage('Unable to update a todo')) .finally(() => { setProcessedIs([]); - setTitleBeingUpdated(false); }); } }; From cc7c529c732ac313840d48c3fff137697f92686e Mon Sep 17 00:00:00 2001 From: Viktoriia Date: Thu, 12 Dec 2024 22:31:55 +0200 Subject: [PATCH 4/5] latest update --- src/components/TodoItem/TodoItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index edadbb5feb..2d1fd86bfe 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -37,10 +37,10 @@ export const TodoItem: React.FC = ({ const inputRef = useRef(null); useEffect(() => { - if (inputRef.current) { + if (isTitleBeingUpdated && inputRef.current) { inputRef.current.focus(); } - }, [isTitleBeingUpdated]); + }); return (
From 294cfc68f29601674f7dc6661089ba0354e00925 Mon Sep 17 00:00:00 2001 From: Viktoriia Date: Tue, 17 Dec 2024 20:34:21 +0200 Subject: [PATCH 5/5] refactored code --- README.md | 2 +- src/App.tsx | 247 ++------------------------- src/components/Error/Erros.tsx | 2 +- src/components/Footer/Fotter.tsx | 55 ++++-- src/components/Header/Header.tsx | 139 ++++++++++++--- src/components/Helpers/Helpers.tsx | 21 +++ src/components/TodoItem/TodoItem.tsx | 174 +++++++++++++------ src/components/TodoList/TodoList.tsx | 41 ++--- 8 files changed, 340 insertions(+), 341 deletions(-) create mode 100644 src/components/Helpers/Helpers.tsx diff --git a/README.md b/README.md index d3c3756ab9..b2f9d94938 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,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://VikaChereushenko.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 72468e910d..1d293f7397 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,33 +9,18 @@ import { TodoList } from './components/TodoList/TodoList'; import { TempTodo } from './components/TempTodo/TempTodo'; import { Footer } from './components/Footer/Fotter'; import { Error } from './components/Error/Erros'; -import { getTodos, addTodo, deleteTodo, updateTodo } from './api/todos'; +import { getTodos } from './api/todos'; +import { filterTodos } from './components/Helpers/Helpers'; import { Todo } from './types/Todo'; import { TodoStatus } from './types/Status'; -function filterTodos(todos: Todo[], status: TodoStatus) { - const todosCopy = [...todos]; - - switch (status) { - case TodoStatus.active: - return todosCopy.filter(todo => !todo.completed); - case TodoStatus.completed: - return todosCopy.filter(todo => todo.completed); - case TodoStatus.all: - return todosCopy; - } -} - export const App: React.FC = () => { const [todos, setTodos] = useState([]); const [errorMessage, setErrorMessage] = useState(''); const [status, setStatus] = useState(TodoStatus.all); - const [title, setTitle] = useState(''); - const [loading, setLoading] = useState(false); const [tempTodo, setTempTodo] = useState(null); - const [processedIs, setProcessedIs] = useState([]); - const areAllTodosCompleted = todos.every(todo => todo.completed); + const [processedIds, setprocessedIds] = useState([]); const noTodos = todos.length === 0; const filteredTodos = filterTodos(todos, status); @@ -45,204 +30,6 @@ export const App: React.FC = () => { .catch(() => setErrorMessage('Unable to load todos')); }, []); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - - const normalizeTitle = title.trim(); - - if (!normalizeTitle) { - setErrorMessage('Title should not be empty'); - - return; - } - - setLoading(true); - const newTodo = { - userId: 2042, - title: normalizeTitle, - completed: false, - }; - - setTempTodo({ - id: 0, - ...newTodo, - }); - - addTodo(newTodo) - .then(response => { - setTitle(''); - setTodos(existing => [...existing, response]); - setLoading(false); - }) - .catch(() => setErrorMessage('Unable to add a todo')) - .finally(() => { - setLoading(false); - setTempTodo(null); - }); - }; - - const handleDeleteOneTodo = (id: number) => { - setLoading(true); - setProcessedIs(existing => [...existing, id]); - deleteTodo(id) - .then(() => { - setTodos(existing => existing.filter(current => current.id !== id)); - }) - .catch(() => setErrorMessage('Unable to delete a todo')) - .finally(() => { - setProcessedIs([]); - setLoading(false); - }); - }; - - const handleDeleteCompletedTodos = () => { - todos.forEach(todo => { - if (todo.completed) { - setLoading(true); - setProcessedIs(existing => [...existing, todo.id]); - deleteTodo(todo.id) - .then(() => - setTodos(existing => - existing.filter(current => current.id !== todo.id), - ), - ) - .catch(() => setErrorMessage('Unable to delete a todo')) - .finally(() => { - setLoading(false); - setProcessedIs(existing => existing.filter(id => id !== todo.id)); - }); - } - }); - }; - - const handleStatusUpdate = (id: number) => { - setProcessedIs(existing => [...existing, id]); - - const changeItem = todos.find(todo => todo.id === id); - - if (changeItem) { - const toUpdate = { completed: !changeItem.completed }; - - updateTodo(id, toUpdate) - .then(() => { - setTodos(existing => - existing.map(el => - el.id === id ? { ...el, completed: toUpdate.completed } : el, - ), - ); - }) - .catch(() => setErrorMessage('Unable to update a todo')) - .finally(() => { - setProcessedIs([]); - }); - } - }; - - const handleTotalStatusUpdate = () => { - if (!areAllTodosCompleted) { - todos.forEach(todo => { - if (!todo.completed) { - setLoading(true); - setProcessedIs(existing => [...existing, todo.id]); - const toUpdate = { completed: true }; - - updateTodo(todo.id, toUpdate) - .then(() => { - setTodos(existing => - existing.map(el => - el.id === todo.id - ? { ...el, completed: toUpdate.completed } - : el, - ), - ); - }) - .catch(() => setErrorMessage('Unable to update a todo')) - .finally(() => { - setLoading(false); - setProcessedIs(existing => existing.filter(id => id !== todo.id)); - }); - } - }); - } - - if (areAllTodosCompleted) { - todos.forEach(todo => { - setLoading(true); - setProcessedIs(existing => [...existing, todo.id]); - const toUpdate = { completed: false }; - - updateTodo(todo.id, toUpdate) - .then(() => { - setTodos(existing => - existing.map(el => - el.id === todo.id - ? { ...el, completed: toUpdate.completed } - : el, - ), - ); - }) - .catch(() => setErrorMessage('Unable to update a todo')) - .finally(() => { - setLoading(false); - setProcessedIs(existing => existing.filter(id => id !== todo.id)); - }); - }); - } - }; - - const handleTitleUpdate = ( - id: number, - newTitle: string, - setTitleBeingUpdated: React.Dispatch>, - isTitleChanged: boolean, - e?: React.FormEvent, - ) => { - e?.preventDefault(); - - const normalizeNewTitle = newTitle.trim(); - - if (!normalizeNewTitle) { - handleDeleteOneTodo(id); - - return; - } - - if (!isTitleChanged) { - setTitleBeingUpdated(false); - - return; - } - - const changeItem = todos.find(todo => todo.id === id); - const toUpdate = { title: normalizeNewTitle }; - - if (changeItem) { - setProcessedIs(existing => [...existing, id]); - updateTodo(id, toUpdate) - .then(() => { - setTodos(existing => - existing.map(el => - el.id === id ? { ...el, title: toUpdate.title } : el, - ), - ); - setTitleBeingUpdated(false); - }) - .catch(() => setErrorMessage('Unable to update a todo')) - .finally(() => { - setProcessedIs([]); - }); - } - }; - - const handleKeyUp = ( - event: React.KeyboardEvent, - setTitleBeingUpdated: React.Dispatch>, - ) => { - if (event.key === 'Escape') { - setTitleBeingUpdated(false); - } - }; - if (!USER_ID) { return ; } @@ -253,32 +40,32 @@ export const App: React.FC = () => {
setTitle(value)} - onTotalStatusUpdate={handleTotalStatusUpdate} + todoList={todos} + onError={setErrorMessage} + updateTodoList={setTodos} + updateTempTodo={setTempTodo} + updateProcessedIds={setprocessedIds} /> {tempTodo && } {!noTodos && (
setStatus(value)} - clearCompletedTodos={handleDeleteCompletedTodos} + updateProcessedIds={setprocessedIds} + onError={setErrorMessage} /> )}
diff --git a/src/components/Error/Erros.tsx b/src/components/Error/Erros.tsx index 8d8065c912..d24c2a3860 100644 --- a/src/components/Error/Erros.tsx +++ b/src/components/Error/Erros.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; type Props = { errorMessage: string; - hideError: (arg: string) => void; + hideError: (errorMessage: string) => void; }; export const Error: React.FC = ({ errorMessage, hideError }) => { diff --git a/src/components/Footer/Fotter.tsx b/src/components/Footer/Fotter.tsx index 54de8b45a2..ffc1ececb6 100644 --- a/src/components/Footer/Fotter.tsx +++ b/src/components/Footer/Fotter.tsx @@ -1,29 +1,58 @@ -import React from 'react'; +import React, { useCallback } from 'react'; +import { useMemo } from 'react'; import classNames from 'classnames'; +import { deleteTodo } from '../../api/todos'; +import { capitalizeFirstLetter, filterOptions } from '../Helpers/Helpers'; + import { Todo } from '../../types/Todo'; import { TodoStatus } from '../../types/Status'; type Props = { - todos: Todo[]; + todoList: Todo[]; + updateTodolist: React.Dispatch>; status: TodoStatus; - onStatusChange: (arg: TodoStatus) => void; - clearCompletedTodos: () => void; + onStatusChange: (status: TodoStatus) => void; + updateProcessedIds: React.Dispatch>; + onError: React.Dispatch>; }; export const Footer: React.FC = ({ - todos, + todoList, + updateTodolist, status, onStatusChange, - clearCompletedTodos, + updateProcessedIds, + onError, }) => { - const activeTodos = todos.filter(todo => !todo.completed); - const isAnyCompleted = todos.some(todo => todo.completed); - const filterOptions = Object.values(TodoStatus); + const activeTodos = useMemo( + () => todoList.filter(todo => !todo.completed), + [todoList], + ); + const isAnyCompleted = useMemo( + () => todoList.some(todo => todo.completed), + [todoList], + ); - const capitalizeFirstLetter = (value: TodoStatus) => { - return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; - }; + const handleDeleteCompletedTodos = useCallback(() => { + todoList.forEach(todo => { + if (todo.completed) { + updateProcessedIds(existing => [...existing, todo.id]); + deleteTodo(todo.id) + .then(() => + updateTodolist(existing => + existing.filter(current => current.id !== todo.id), + ), + ) + .catch(() => onError('Unable to delete a todo')) + .finally(() => { + updateProcessedIds(existing => + existing.filter(id => id !== todo.id), + ); + }); + } + }); + }, [todoList]); return (