From e46fee47b397ad88fe127e52353574b6f5c38db3 Mon Sep 17 00:00:00 2001 From: Anton Date: Thu, 18 Jun 2026 19:29:42 +0200 Subject: [PATCH 1/6] 1st commit to share --- package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 210 ++++++++++++++++-- src/api/todos.ts | 18 ++ src/components/NewTodoForm/NewTodoForm.tsx | 79 +++++++ src/components/NewTodoForm/index.ts | 1 + src/components/TodoItem/TodoItem.scss | 1 + src/components/TodoItem/TodoItem.tsx | 47 ++++ src/components/TodoItem/index.ts | 1 + src/components/TodoList/TodoList.tsx | 23 ++ src/components/TodoList/index.ts | 1 + src/components/TodoRoadMap.js | 0 .../ToggleAllButton/ToggleAllButton.tsx | 0 src/components/ToggleAllButton/index.js | 0 src/types/ErrorType.ts | 8 + src/types/FilterType.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 ++++ 18 files changed, 435 insertions(+), 22 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/NewTodoForm/NewTodoForm.tsx create mode 100644 src/components/NewTodoForm/index.ts create mode 100644 src/components/TodoItem/TodoItem.scss create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/components/TodoRoadMap.js create mode 100644 src/components/ToggleAllButton/ToggleAllButton.tsx create mode 100644 src/components/ToggleAllButton/index.js create mode 100644 src/types/ErrorType.ts create mode 100644 src/types/FilterType.ts 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..0dbc74a8e1 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": "^2.1.3", "@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": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "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..6d0f20adcc 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": "^2.1.3", "@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..10311279bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,202 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import * as clientMethods from './api/todos'; +import type { Todo } from './types/Todo'; +import { NewTodoForm } from './components/NewTodoForm'; +import { TodoList } from './components/TodoList'; +import { ErrorType } from './types/ErrorType'; +import { FilterType } from './types/FilterType'; +import { TodoItem } from './components/TodoItem'; +import classNames from 'classnames'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(ErrorType.None); + const [selectedFilterLink, setSelectedFilterLink] = useState(FilterType.All); + const [tempTodo, setTempTodo] = useState(null); + const [deletingIds, setDeletingIds] = useState([]); + const inputFocusRef = useRef(null); + + const completedTodos = todos.filter(todo => todo.completed); + const activeTodos = todos.filter(todo => !todo.completed); + + const filterLinks = [ + { + label: 'All', + value: FilterType.All, + href: '#/', + dataCy: 'FilterLinkAll', + }, + { + label: 'Active', + value: FilterType.Active, + href: '#/active', + dataCy: 'FilterLinkActive', + }, + { + label: 'Completed', + value: FilterType.Completed, + href: '#/completed', + dataCy: 'FilterLinkCompleted', + }, + ]; + + const handleAddTodo = (newTodo: Todo): void => { + setTodos([...todos, newTodo]); + }; + + const handleDelete = async (deleteId: number) => { + setDeletingIds(current => [...current, deleteId]); + + try { + await clientMethods.deleteTodo(deleteId); + setTodos(current => current.filter(todo => todo.id !== deleteId)); + } catch { + setError(ErrorType.Delete); + } finally { + setDeletingIds(current => current.filter(id => id !== deleteId)); + inputFocusRef.current?.focus(); + } + }; + + const handleClearCompleted = () => { + Promise.all(completedTodos.map(todo => handleDelete(todo.id))); + }; + + useEffect(() => { + const loadTodos = async () => { + try { + const loadedTodos = await clientMethods.getTodos(); + + setTodos(loadedTodos); + } catch (err) { + setError(ErrorType.Load); + } finally { + setIsLoading(false); + } + }; + + loadTodos(); + }, []); + + useEffect(() => { + if (!error) { + return; + } + + const timer = setTimeout(() => setError(ErrorType.None), 3000); + + return () => clearTimeout(timer); + }, [error]); + + const allTodosCompleted = + todos.length > 0 && todos.every(todo => todo.completed); + let filteredTodos = todos; + + switch (selectedFilterLink) { + case FilterType.Active: + filteredTodos = todos.filter(todo => !todo.completed); + break; + case FilterType.Completed: + filteredTodos = todos.filter(todo => todo.completed); + break; + default: + filteredTodos = todos; + break; + } + + if (!clientMethods.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 && ( +
+ +
+ {!isLoading && ( + <> + + {tempTodo && } + + )} +
+ + {todos.length > 0 && ( + + )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..0e0b7c5ed4 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4133; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (todoData: Omit): Promise => { + return client.post('/todos', todoData); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +// Add more methods here diff --git a/src/components/NewTodoForm/NewTodoForm.tsx b/src/components/NewTodoForm/NewTodoForm.tsx new file mode 100644 index 0000000000..50fcbe433e --- /dev/null +++ b/src/components/NewTodoForm/NewTodoForm.tsx @@ -0,0 +1,79 @@ +import React, { useState, useEffect } from 'react'; +import type { Todo } from '../../types/Todo'; +import { ErrorType } from '../../types/ErrorType'; +import * as clientMethods from '../../api/todos'; + +type Props = { + onAdd: (value: Todo) => void; + onError: (message: ErrorType) => void; + setTempTodo: (tempTodo: Todo | null) => void; + inputFocusRef: React.RefObject; +}; + +export const NewTodoForm: React.FC = ({ + onAdd, + onError, + setTempTodo, + inputFocusRef, +}) => { + const [title, setTitle] = useState(''); + const [isSubmiting, setIsSubmiting] = useState(false); + + useEffect(() => { + if (!isSubmiting) { + inputFocusRef.current?.focus(); + } + }, [isSubmiting, inputFocusRef]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + // 1. Validate + if (!title.trim()) { + onError(ErrorType.EmptyTitle); + + return; + } + + try { + setIsSubmiting(true); + + setTempTodo({ + id: 0, + userId: clientMethods.USER_ID, + title: title.trim(), + completed: false, + }); + + const createdTodo = await clientMethods.addTodo({ + userId: clientMethods.USER_ID, + title: title.trim(), + completed: false, + }); + + onAdd(createdTodo); + onError(ErrorType.None); + setTitle(''); + } catch { + onError(ErrorType.Add); + } finally { + setTempTodo(null); + setIsSubmiting(false); + } + // 2. Clear previous error + }; + + return ( +
+ setTitle(event.target.value)} + disabled={isSubmiting} + ref={inputFocusRef} + /> +
+ ); +}; diff --git a/src/components/NewTodoForm/index.ts b/src/components/NewTodoForm/index.ts new file mode 100644 index 0000000000..e58e9e081e --- /dev/null +++ b/src/components/NewTodoForm/index.ts @@ -0,0 +1 @@ +export * from './NewTodoForm'; diff --git a/src/components/TodoItem/TodoItem.scss b/src/components/TodoItem/TodoItem.scss new file mode 100644 index 0000000000..2932342b00 --- /dev/null +++ b/src/components/TodoItem/TodoItem.scss @@ -0,0 +1 @@ +// TodoItem styles diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..cfb947be1c --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,47 @@ +/* eslint-disable */ + +import React from 'react'; +import type { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; + isLoading?: boolean; + onDelete?: (id : number) => void +} + +export const TodoItem: React.FC = ({ todo, isLoading, onDelete }) =>{ + + return ( +
+ + + + {todo.title} + + + + +
+
+
+
+
+ ) + + +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 0000000000..21f4abac39 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..b66e753875 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,23 @@ +/* eslint-disable */ + +import { TodoItem } from '../TodoItem' +import type { Todo } from '../../types/Todo'; + +type Props = { + todos : (Todo[]); + onDelete : ( id: number) => void; + deletingIds : number[] +} + +export const TodoList: React.FC = ({ todos, onDelete, deletingIds }) => ( + <> + {todos.map(todo=> ( + + ))} + +); diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/components/TodoRoadMap.js b/src/components/TodoRoadMap.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/ToggleAllButton/ToggleAllButton.tsx b/src/components/ToggleAllButton/ToggleAllButton.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/ToggleAllButton/index.js b/src/components/ToggleAllButton/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/types/ErrorType.ts b/src/types/ErrorType.ts new file mode 100644 index 0000000000..9cd03ed844 --- /dev/null +++ b/src/types/ErrorType.ts @@ -0,0 +1,8 @@ +export enum ErrorType { + None = '', + Load = 'Unable to load todos', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', + EmptyTitle = 'Title should not be empty', +} diff --git a/src/types/FilterType.ts b/src/types/FilterType.ts new file mode 100644 index 0000000000..5b306c57d0 --- /dev/null +++ b/src/types/FilterType.ts @@ -0,0 +1,5 @@ +export enum FilterType { + 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 6082831eeb1c84d7b617514ea71a92164ad26b89 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 21 Jun 2026 18:34:41 +0200 Subject: [PATCH 2/6] 2nd commit --- src/App.tsx | 13 +++++++++---- src/api/todos.ts | 4 ++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 10311279bc..e6ba90c88d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,8 @@ export const App: React.FC = () => { const [error, setError] = useState(ErrorType.None); const [selectedFilterLink, setSelectedFilterLink] = useState(FilterType.All); const [tempTodo, setTempTodo] = useState(null); - const [deletingIds, setDeletingIds] = useState([]); + const [loadingIds, setLoadingIds] = useState([]); + const inputFocusRef = useRef(null); const completedTodos = todos.filter(todo => todo.completed); @@ -47,7 +48,7 @@ export const App: React.FC = () => { }; const handleDelete = async (deleteId: number) => { - setDeletingIds(current => [...current, deleteId]); + setLoadingIds(current => [...current, deleteId]); try { await clientMethods.deleteTodo(deleteId); @@ -55,7 +56,7 @@ export const App: React.FC = () => { } catch { setError(ErrorType.Delete); } finally { - setDeletingIds(current => current.filter(id => id !== deleteId)); + setLoadingIds(current => current.filter(id => id !== deleteId)); inputFocusRef.current?.focus(); } }; @@ -64,6 +65,10 @@ export const App: React.FC = () => { Promise.all(completedTodos.map(todo => handleDelete(todo.id))); }; + const handleUpdate = () => { + + } + useEffect(() => { const loadTodos = async () => { try { @@ -138,7 +143,7 @@ export const App: React.FC = () => { {tempTodo && } diff --git a/src/api/todos.ts b/src/api/todos.ts index 0e0b7c5ed4..9395a8fd00 100644 --- a/src/api/todos.ts +++ b/src/api/todos.ts @@ -15,4 +15,8 @@ export const deleteTodo = (id: number) => { return client.delete(`/todos/${id}`); }; +export const updateTodo = (id: number, todoData:Partial ) => { + return client.patch(`/todos/${id}`, todoData); +}; + // Add more methods here From 47f3455a37d770df719f4d13c1633257ae7c075e Mon Sep 17 00:00:00 2001 From: Anton Date: Sat, 4 Jul 2026 22:02:45 +0200 Subject: [PATCH 3/6] The approach has been changed --- src/App.tsx | 20 +++++++++++++++++--- src/api/todos.ts | 2 +- src/components/TodoItem/TodoItem.tsx | 12 +++++++++--- src/components/TodoList/TodoList.tsx | 8 +++++--- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e6ba90c88d..de15e4ebc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,9 +65,22 @@ export const App: React.FC = () => { Promise.all(completedTodos.map(todo => handleDelete(todo.id))); }; - const handleUpdate = () => { + const handleUpdate = async (id: number, todoData?: Partial | undefined) => { + setLoadingIds(current => [...current, id]) - } + try { + await clientMethods.updateTodo(id, todoData); + setTodos(current => + current.map(todo => + todo.id === id? { ...todo, ...todoData } : todo, + ), + ); + } catch { + setError(ErrorType.Update); + } finally { + setLoadingIds(current => current.filter(item => item !== id)); + } + }; useEffect(() => { const loadTodos = async () => { @@ -143,7 +156,8 @@ export const App: React.FC = () => { {tempTodo && } diff --git a/src/api/todos.ts b/src/api/todos.ts index 9395a8fd00..6cb35b42a7 100644 --- a/src/api/todos.ts +++ b/src/api/todos.ts @@ -15,7 +15,7 @@ export const deleteTodo = (id: number) => { return client.delete(`/todos/${id}`); }; -export const updateTodo = (id: number, todoData:Partial ) => { +export const updateTodo = (id: number, todoData: Partial | undefined) => { return client.patch(`/todos/${id}`, todoData); }; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index cfb947be1c..c5c5e602e8 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -2,14 +2,16 @@ import React from 'react'; import type { Todo } from '../../types/Todo'; +import { USER_ID } from '../../api/todos'; type Props = { todo: Todo; isLoading?: boolean; - onDelete?: (id : number) => void + onDelete?: (id : number) => void; + handleUpdate?: (id: number, todoData?: Partial) => void; } -export const TodoItem: React.FC = ({ todo, isLoading, onDelete }) =>{ +export const TodoItem: React.FC = ({ todo, isLoading, onDelete, handleUpdate }) =>{ return (
@@ -19,7 +21,11 @@ export const TodoItem: React.FC = ({ todo, isLoading, onDelete }) =>{ type="checkbox" className="todo__status" checked={todo.completed} - onChange={() => {}} + onChange={() => { + if ( todo.id ) { + handleUpdate(todo.id) + } + }} /> diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx index b66e753875..150c6e0a4a 100644 --- a/src/components/TodoList/TodoList.tsx +++ b/src/components/TodoList/TodoList.tsx @@ -6,17 +6,19 @@ import type { Todo } from '../../types/Todo'; type Props = { todos : (Todo[]); onDelete : ( id: number) => void; - deletingIds : number[] + loadingIds : number[] + handleUpdate? : ( id: number, todoData?: Partial) => void; } -export const TodoList: React.FC = ({ todos, onDelete, deletingIds }) => ( +export const TodoList: React.FC = ({ todos, onDelete, loadingIds, handleUpdate }) => ( <> {todos.map(todo=> ( ))} From cb14c9dfff584e45c79e0bebd94197edc7d62950 Mon Sep 17 00:00:00 2001 From: Anton Date: Sat, 4 Jul 2026 22:05:20 +0200 Subject: [PATCH 4/6] The approach has been changed 2nd try --- src/App.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index de15e4ebc8..7203752dd0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,15 +65,16 @@ export const App: React.FC = () => { Promise.all(completedTodos.map(todo => handleDelete(todo.id))); }; - const handleUpdate = async (id: number, todoData?: Partial | undefined) => { - setLoadingIds(current => [...current, id]) + const handleUpdate = async ( + id: number, + todoData?: Partial | undefined, + ) => { + setLoadingIds(current => [...current, id]); try { await clientMethods.updateTodo(id, todoData); setTodos(current => - current.map(todo => - todo.id === id? { ...todo, ...todoData } : todo, - ), + current.map(todo => (todo.id === id ? { ...todo, ...todoData } : todo)), ); } catch { setError(ErrorType.Update); From 6f79969ea40ecab7d8e32becf55e5696cf86b973 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 5 Jul 2026 17:42:43 +0200 Subject: [PATCH 5/6] Last part to do --- src/App.tsx | 19 +++-- src/components/TodoItem/TodoItem.tsx | 117 ++++++++++++++++++++------- src/components/TodoList/TodoList.tsx | 6 +- 3 files changed, 105 insertions(+), 37 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7203752dd0..9e3b02037f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ export const App: React.FC = () => { const [selectedFilterLink, setSelectedFilterLink] = useState(FilterType.All); const [tempTodo, setTempTodo] = useState(null); const [loadingIds, setLoadingIds] = useState([]); + const [toggledTodosIds, setToggledIds] = useState([]); const inputFocusRef = useRef(null); @@ -53,8 +54,9 @@ export const App: React.FC = () => { try { await clientMethods.deleteTodo(deleteId); setTodos(current => current.filter(todo => todo.id !== deleteId)); - } catch { + } catch (error) { setError(ErrorType.Delete); + throw error; } finally { setLoadingIds(current => current.filter(id => id !== deleteId)); inputFocusRef.current?.focus(); @@ -65,10 +67,7 @@ export const App: React.FC = () => { Promise.all(completedTodos.map(todo => handleDelete(todo.id))); }; - const handleUpdate = async ( - id: number, - todoData?: Partial | undefined, - ) => { + const handleUpdate = async (id: number, todoData: Partial) => { setLoadingIds(current => [...current, id]); try { @@ -76,13 +75,20 @@ export const App: React.FC = () => { setTodos(current => current.map(todo => (todo.id === id ? { ...todo, ...todoData } : todo)), ); - } catch { + } catch (error) { setError(ErrorType.Update); + throw error; } finally { setLoadingIds(current => current.filter(item => item !== id)); } }; + const handleToggling = async () => { + setToggledIds(current => + current.filter(current => current.completed === true), + ); + }; + useEffect(() => { const loadTodos = async () => { try { @@ -140,6 +146,7 @@ export const App: React.FC = () => { type="button" className={`todoapp__toggle-all ${allTodosCompleted ? 'active' : ''}`} data-cy="ToggleAllButton" + onClick={} /> )} diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index c5c5e602e8..8bd79d3ace 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -1,17 +1,54 @@ -/* eslint-disable */ - -import React from 'react'; +import React, { useState } from 'react'; import type { Todo } from '../../types/Todo'; -import { USER_ID } from '../../api/todos'; type Props = { todo: Todo; isLoading?: boolean; - onDelete?: (id : number) => void; - handleUpdate?: (id: number, todoData?: Partial) => void; -} + onDelete?: (id: number) => Promise; + handleUpdate?: (id: number, todoData: Partial) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + onDelete, + handleUpdate, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(todo.title); + + const save = async () => { + const trimmed = inputValue.trim(); + + if (trimmed === todo.title) { + setIsEditing(false); + + return; + } -export const TodoItem: React.FC = ({ todo, isLoading, onDelete, handleUpdate }) =>{ + try { + if (trimmed === '') { + await onDelete?.(todo.id); + } else { + await handleUpdate?.(todo.id, { title: trimmed }); + setIsEditing(false); + } + } catch { + // On failure keep the form open so the user can retry. + } + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + save(); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setInputValue(todo.title); + setIsEditing(false); + } + }; return (
@@ -21,33 +58,57 @@ export const TodoItem: React.FC = ({ todo, isLoading, onDelete, handleUpd type="checkbox" className="todo__status" checked={todo.completed} - onChange={() => { - if ( todo.id ) { - handleUpdate(todo.id) - } - }} + onChange={() => + handleUpdate?.(todo.id, { completed: !todo.completed }) + } /> - - {todo.title} - + {isEditing ? ( +
+ setInputValue(event.target.value)} + onBlur={save} + onKeyUp={handleKeyUp} + autoFocus + /> +
+ ) : ( + <> + { + setIsEditing(true); + setInputValue(todo.title); + }} + > + {todo.title} + - + + + )} -
+
- ) - - + ); }; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx index 150c6e0a4a..3feb7c445f 100644 --- a/src/components/TodoList/TodoList.tsx +++ b/src/components/TodoList/TodoList.tsx @@ -5,9 +5,9 @@ import type { Todo } from '../../types/Todo'; type Props = { todos : (Todo[]); - onDelete : ( id: number) => void; - loadingIds : number[] - handleUpdate? : ( id: number, todoData?: Partial) => void; + onDelete : ( id: number) => Promise; + loadingIds : number[]; + handleUpdate? : ( id: number, todoData: Partial) => Promise; } export const TodoList: React.FC = ({ todos, onDelete, loadingIds, handleUpdate }) => ( From 14fa7f806e5952a95162370ef844533547114319 Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 6 Jul 2026 17:44:41 +0200 Subject: [PATCH 6/6] all tests passed --- README.md | 2 +- src/App.tsx | 32 +++++++++++++++++----------- src/components/TodoItem/TodoItem.tsx | 1 + src/components/TodoList/TodoList.tsx | 21 +++++++++++------- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 47a1add059..c91c47d1bd 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://antonnimachuk.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 9e3b02037f..5a940b477e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,12 +16,13 @@ export const App: React.FC = () => { const [selectedFilterLink, setSelectedFilterLink] = useState(FilterType.All); const [tempTodo, setTempTodo] = useState(null); const [loadingIds, setLoadingIds] = useState([]); - const [toggledTodosIds, setToggledIds] = useState([]); const inputFocusRef = useRef(null); const completedTodos = todos.filter(todo => todo.completed); const activeTodos = todos.filter(todo => !todo.completed); + const allTodosCompleted = + todos.length > 0 && todos.every(todo => todo.completed); const filterLinks = [ { @@ -54,9 +55,9 @@ export const App: React.FC = () => { try { await clientMethods.deleteTodo(deleteId); setTodos(current => current.filter(todo => todo.id !== deleteId)); - } catch (error) { + } catch (err) { setError(ErrorType.Delete); - throw error; + throw err; } finally { setLoadingIds(current => current.filter(id => id !== deleteId)); inputFocusRef.current?.focus(); @@ -64,7 +65,9 @@ export const App: React.FC = () => { }; const handleClearCompleted = () => { - Promise.all(completedTodos.map(todo => handleDelete(todo.id))); + Promise.all(completedTodos.map(todo => handleDelete(todo.id))).catch( + () => {}, + ); }; const handleUpdate = async (id: number, todoData: Partial) => { @@ -75,18 +78,23 @@ export const App: React.FC = () => { setTodos(current => current.map(todo => (todo.id === id ? { ...todo, ...todoData } : todo)), ); - } catch (error) { + } catch (err) { setError(ErrorType.Update); - throw error; + throw err; } finally { setLoadingIds(current => current.filter(item => item !== id)); } }; - const handleToggling = async () => { - setToggledIds(current => - current.filter(current => current.completed === true), - ); + const handleToggleAll = () => { + const targetStatus = !allTodosCompleted; + const todosToUpdate = todos.filter(todo => todo.completed !== targetStatus); + + Promise.all( + todosToUpdate.map(todo => + handleUpdate(todo.id, { completed: targetStatus }), + ), + ).catch(() => {}); }; useEffect(() => { @@ -115,8 +123,6 @@ export const App: React.FC = () => { return () => clearTimeout(timer); }, [error]); - const allTodosCompleted = - todos.length > 0 && todos.every(todo => todo.completed); let filteredTodos = todos; switch (selectedFilterLink) { @@ -146,7 +152,7 @@ export const App: React.FC = () => { type="button" className={`todoapp__toggle-all ${allTodosCompleted ? 'active' : ''}`} data-cy="ToggleAllButton" - onClick={} + onClick={handleToggleAll} /> )} diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index 8bd79d3ace..5f9c469e8f 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -52,6 +52,7 @@ export const TodoItem: React.FC = ({ return (
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}