From 12ed50c4c79ca635371c1b9e014c871bac9b5615 Mon Sep 17 00:00:00 2001 From: Anastasia Date: Tue, 24 Feb 2026 16:58:58 +0100 Subject: [PATCH 1/2] task --- src/App.tsx | 227 +++++++++++++++++++++++++-- src/api/todos.ts | 24 +++ src/components/ErrorNotification.tsx | 26 +++ src/components/Footer.tsx | 71 +++++++++ src/components/Header.tsx | 55 +++++++ src/components/TodoItem.tsx | 143 +++++++++++++++++ src/components/TodoList.tsx | 45 ++++++ src/types/ErrorMessage.ts | 7 + src/types/FilterStatus.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 45 ++++++ 11 files changed, 640 insertions(+), 14 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification.tsx create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/types/ErrorMessage.ts create mode 100644 src/types/FilterStatus.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..94f5b565dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,225 @@ /* 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 * as todoService from './api/todos'; +import { Todo } from './types/Todo'; +import { FilterStatus } from './types/FilterStatus'; +import { ErrorMessage } from './types/ErrorMessage'; -const USER_ID = 0; +import { ErrorNotification } from './components/ErrorNotification'; + +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [filter, setFilter] = useState(FilterStatus.All); + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [loadingIds, setLoadingIds] = useState([]); + + const todoFieldRef = React.useRef(null); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + + const showError = (message: string) => { + setErrorMessage(message); + setTimeout(() => setErrorMessage(''), 3000); + }; + + useEffect(() => { + const loadTodos = async () => { + setErrorMessage(''); + + try { + const data = await todoService.getTodos(); + + setTodos(data); + } catch { + showError(ErrorMessage.Load); + } + }; + + loadTodos(); + todoFieldRef.current?.focus(); + }, []); + + useEffect(() => { + if (!isLoading) { + todoFieldRef.current?.focus(); + } + }, [isLoading, todos.length]); + + const visibleTodos = todos.filter(todo => { + if (filter === FilterStatus.Active) { + return !todo.completed; + } + + if (filter === FilterStatus.Completed) { + return todo.completed; + } + + return true; + }); + + if (!todoService.USER_ID) { return ; } + const handleFormSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + setErrorMessage(''); + + const trimmedTodoTitle = newTodoTitle.trim(); + + if (!trimmedTodoTitle) { + showError(ErrorMessage.Title); + + return; + } + + setIsLoading(true); + + const newTempTodo = { + id: 0, + userId: todoService.USER_ID, + title: trimmedTodoTitle, + completed: false, + }; + + setTempTodo(newTempTodo); + + try { + const newTodo = await todoService.createTodo(trimmedTodoTitle); + + setTodos(prev => [...prev, newTodo]); + setNewTodoTitle(''); + } catch { + showError(ErrorMessage.Add); + } finally { + setIsLoading(false); + setTempTodo(null); + } + }; + + const onDeleteTodo = async (todoId: number) => { + setErrorMessage(''); + + setLoadingIds(prev => [...prev, todoId]); + + try { + await todoService.deleteTodo(todoId); + setTodos(prev => prev.filter(todo => todo.id !== todoId)); + } catch { + showError('Unable to delete a todo'); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todoId)); + } + }; + + const onToggleTodo = async (todo: Todo) => { + setErrorMessage(''); + setLoadingIds(prev => [...prev, todo.id]); + + try { + const updatedTodo = await todoService.updateTodo(todo.id, { + completed: !todo.completed, + }); + + setTodos(prev => prev.map(t => (t.id === todo.id ? updatedTodo : t))); + } catch { + showError('Unable to update a todo'); + } finally { + setLoadingIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + const onUpdateTodo = async (todoToUpdate: Todo, newTitle: string) => { + setErrorMessage(''); + setLoadingIds(prev => [...prev, todoToUpdate.id]); + + try { + const updatedTodo = await todoService.updateTodo(todoToUpdate.id, { + title: newTitle, + }); + + setTodos(prev => + prev.map(t => (t.id === todoToUpdate.id ? updatedTodo : t)), + ); + } catch (error) { + showError(ErrorMessage.Update); + throw error; + } finally { + setLoadingIds(prev => prev.filter(id => id !== todoToUpdate.id)); + } + }; + + const clearCompleted = () => { + const completedTodos = todos.filter(t => t.completed); + + completedTodos.forEach(todo => { + onDeleteTodo(todo.id); + }); + }; + + const onToggleAll = () => { + const areAllCompleted = todos.every(todo => todo.completed); + const todosToUpdate = todos.filter( + todo => todo.completed === areAllCompleted, + ); + + todosToUpdate.forEach(todo => onToggleTodo(todo)); + }; + return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
0 && todos.every(todo => todo.completed) + } + isLoading={isLoading} + onToggleAll={onToggleAll} + hasTodos={todos.length > 0} + /> + + {(todos.length > 0 || tempTodo) && ( + <> + +
todo.completed)} + onClearCompleted={clearCompleted} + /> + + )} +
+ + setErrorMessage('')} + /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..e982fa4541 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,24 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4002; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (title: string) => { + return client.post('/todos', { + title, + userId: USER_ID, + completed: false, + }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, data: Partial) => { + return client.patch(`/todos/${todoId}`, data); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..1a5a9279b3 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import cn from 'classnames'; + +type Props = { + message: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ message, onClose }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..f5222a0f24 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import cn from 'classnames'; +import { FilterStatus } from '../types/FilterStatus'; + +type Props = { + activeTodosCount: number; + filter: FilterStatus; + setFilter: (status: FilterStatus) => void; + hasCompleted: boolean; + onClearCompleted: () => void; +}; + +const filterLinks = [ + { status: FilterStatus.All, label: 'All', href: '#/', cy: 'FilterLinkAll' }, + + { + status: FilterStatus.Active, + label: 'Active', + href: '#/active', + cy: 'FilterLinkActive', + }, + { + status: FilterStatus.Completed, + label: 'Completed', + href: '#/completed', + cy: 'FilterLinkCompleted', + }, +]; + +export const Footer: React.FC = ({ + activeTodosCount, + filter, + setFilter, + hasCompleted, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..d13e889bc2 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,55 @@ +import React, { forwardRef } from 'react'; +import cn from 'classnames'; + +type Props = { + title: string; + onTitleChange: (value: string) => void; + onSubmit: (event: React.FormEvent) => void; + isToggleAllActive: boolean; + isLoading: boolean; + onToggleAll: () => void; + hasTodos: boolean; +}; + +export const Header = forwardRef( + ( + { + title, + onTitleChange, + onSubmit, + isToggleAllActive, + isLoading, + onToggleAll, + hasTodos, + }, + ref, + ) => ( +
+ {hasTodos && ( +
+ ), +); + +Header.displayName = 'Header'; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..c15c66c734 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,143 @@ +/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useState, useEffect, useRef } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + isLoading: boolean; + onDeleteTodo: (todoId: number) => void; + onToggleTodo: (todo: Todo) => void; + onUpdateTodo: (todo: Todo, newTitle: string) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + onDeleteTodo, + onToggleTodo, + onUpdateTodo, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(todo.title); + + const editFieldRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editFieldRef.current?.focus(); + } + }, [isEditing]); + + useEffect(() => { + setTitle(todo.title); + }, [todo.title]); + + const handleEdit = () => { + setIsEditing(true); + }; + + const handleCancel = () => { + setIsEditing(false); + setTitle(todo.title); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + handleCancel(); + } + + if (event.key === 'Enter') { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + handleSubmit(event); + } + }; + + const handleSubmit = async (event?: React.BaseSyntheticEvent) => { + event?.preventDefault(); + + if (isLoading) { + return; + } + + const trimmedTitle = title.trim(); + + if (trimmedTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (!trimmedTitle) { + onDeleteTodo(todo.id); + + return; + } + + try { + await onUpdateTodo(todo, trimmedTitle); + setIsEditing(false); + } catch (error) {} + }; + + return ( +
+ + + {isEditing ? ( +
+ setTitle(event.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSubmit} + autoFocus + disabled={isLoading} + /> +
+ ) : ( + <> + + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..71453907f5 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,45 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + loadingIds: number[]; + tempTodo: Todo | null; + onDeleteTodo: (todoId: number) => void; + onToggleTodo: (todo: Todo) => void; + onUpdateTodo: (todo: Todo, newTitle: string) => Promise; +}; + +export const TodoList: React.FC = ({ + todos, + loadingIds, + tempTodo, + onDeleteTodo, + onToggleTodo, + onUpdateTodo, +}) => ( +
+ {todos.map(todo => ( + + ))} + {tempTodo && ( + + )} +
+); diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..91de57ea7f --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + Load = 'Unable to load todos', + Title = 'Title should not be empty', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', +} diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts new file mode 100644 index 0000000000..13d1a2739c --- /dev/null +++ b/src/types/FilterStatus.ts @@ -0,0 +1,5 @@ +export enum FilterStatus { + 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..5620945028 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,45 @@ +/* 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', + }; + } + + 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 c34ca94127efe196a3c0d3d2da754b554e6fe65d Mon Sep 17 00:00:00 2001 From: Anastasia Date: Tue, 24 Feb 2026 19:23:45 +0100 Subject: [PATCH 2/2] changed App and ErrorNotification --- src/App.tsx | 26 +++++++++++++------------- src/components/ErrorNotification.tsx | 3 ++- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 94f5b565dd..87ab2f0014 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ import { Footer } from './components/Footer'; export const App: React.FC = () => { const [todos, setTodos] = useState([]); - const [errorMessage, setErrorMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); const [filter, setFilter] = useState(FilterStatus.All); const [newTodoTitle, setNewTodoTitle] = useState(''); const [tempTodo, setTempTodo] = useState(null); @@ -27,14 +27,14 @@ export const App: React.FC = () => { const activeTodosCount = todos.filter(todo => !todo.completed).length; - const showError = (message: string) => { + const showError = (message: ErrorMessage) => { setErrorMessage(message); - setTimeout(() => setErrorMessage(''), 3000); + setTimeout(() => setErrorMessage(null), 3000); }; useEffect(() => { const loadTodos = async () => { - setErrorMessage(''); + setErrorMessage(null); try { const data = await todoService.getTodos(); @@ -74,7 +74,7 @@ export const App: React.FC = () => { const handleFormSubmit = async (event: React.FormEvent) => { event.preventDefault(); - setErrorMessage(''); + setErrorMessage(null); const trimmedTodoTitle = newTodoTitle.trim(); @@ -109,7 +109,7 @@ export const App: React.FC = () => { }; const onDeleteTodo = async (todoId: number) => { - setErrorMessage(''); + setErrorMessage(null); setLoadingIds(prev => [...prev, todoId]); @@ -117,14 +117,14 @@ export const App: React.FC = () => { await todoService.deleteTodo(todoId); setTodos(prev => prev.filter(todo => todo.id !== todoId)); } catch { - showError('Unable to delete a todo'); + showError(ErrorMessage.Delete); } finally { setLoadingIds(prev => prev.filter(id => id !== todoId)); } }; const onToggleTodo = async (todo: Todo) => { - setErrorMessage(''); + setErrorMessage(null); setLoadingIds(prev => [...prev, todo.id]); try { @@ -134,14 +134,14 @@ export const App: React.FC = () => { setTodos(prev => prev.map(t => (t.id === todo.id ? updatedTodo : t))); } catch { - showError('Unable to update a todo'); + showError(ErrorMessage.Update); } finally { setLoadingIds(prev => prev.filter(id => id !== todo.id)); } }; const onUpdateTodo = async (todoToUpdate: Todo, newTitle: string) => { - setErrorMessage(''); + setErrorMessage(null); setLoadingIds(prev => [...prev, todoToUpdate.id]); try { @@ -192,10 +192,10 @@ export const App: React.FC = () => { } isLoading={isLoading} onToggleAll={onToggleAll} - hasTodos={todos.length > 0} + hasTodos={!!todos.length} /> - {(todos.length > 0 || tempTodo) && ( + {(!!todos.length || tempTodo) && ( <> { setErrorMessage('')} + onClose={() => setErrorMessage(null)} />
); diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx index 1a5a9279b3..79a7fcc0ae 100644 --- a/src/components/ErrorNotification.tsx +++ b/src/components/ErrorNotification.tsx @@ -1,8 +1,9 @@ import React from 'react'; import cn from 'classnames'; +import { ErrorMessage } from '../types/ErrorMessage'; type Props = { - message: string; + message: ErrorMessage | null; onClose: () => void; };