From 7eb9dd655b5c9ea2d67501e9e229f111f7e93943 Mon Sep 17 00:00:00 2001 From: Goat Date: Mon, 6 Jul 2026 13:14:53 +0300 Subject: [PATCH 1/2] Solution --- src/App.tsx | 444 +++++++++++++++++++++++++++++++++++++-- src/api/todos.ts | 24 +++ src/styles.zip | Bin 0 -> 8653 bytes src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 ++++ 5 files changed, 506 insertions(+), 14 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/styles.zip 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..47efb658ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,442 @@ -/* 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, { useEffect, useMemo, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { + USER_ID, + createTodo, + deleteTodo, + getTodos, + updateTodo, +} from './api/todos'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +type Filter = 'all' | 'active' | 'completed'; + +type TempTodo = Omit & { id: number }; + +const TEMP_TODO_ID = 0; + +const getFilterFromHash = (): Filter => { + switch (window.location.hash) { + case '#/active': + return 'active'; + + case '#/completed': + return 'completed'; + + default: + return 'all'; + } +}; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState(getFilterFromHash); + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [editingTodoId, setEditingTodoId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + + const newTodoField = useRef(null); + const errorTimerId = useRef(null); + const editingCanceled = useRef(false); + + const hideError = React.useCallback(() => { + setErrorMessage(''); + + if (errorTimerId.current) { + window.clearTimeout(errorTimerId.current); + errorTimerId.current = null; + } + }, []); + + const showError = React.useCallback( + (message: string) => { + hideError(); + setErrorMessage(message); + + errorTimerId.current = window.setTimeout(() => { + setErrorMessage(''); + errorTimerId.current = null; + }, 3000); + }, + [hideError], + ); + + const markTodoAsLoading = (todoId: number) => { + setLoadingTodoIds(currentIds => [...currentIds, todoId]); + }; + + const unmarkTodoAsLoading = (todoId: number) => { + setLoadingTodoIds(currentIds => currentIds.filter(id => id !== todoId)); + }; + + useEffect(() => { + const handleHashChange = () => setFilter(getFilterFromHash()); + + window.addEventListener('hashchange', handleHashChange); + + return () => { + window.removeEventListener('hashchange', handleHashChange); + }; + }, []); + + useEffect(() => { + hideError(); + + getTodos() + .then(setTodos) + .catch(() => showError('Unable to load todos')); + + return () => { + if (errorTimerId.current) { + window.clearTimeout(errorTimerId.current); + } + }; + }, [hideError, showError]); + + useEffect(() => { + newTodoField.current?.focus(); + }, [todos.length, tempTodo, errorMessage]); + + const visibleTodos = useMemo(() => { + return todos.filter(todo => { + switch (filter) { + case 'active': + return !todo.completed; + + case 'completed': + return todo.completed; + + default: + return true; + } + }); + }, [todos, filter]); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const completedTodosCount = todos.length - activeTodosCount; + const allTodosCompleted = todos.length > 0 && activeTodosCount === 0; + const isAdding = Boolean(tempTodo); + + const handleAddTodo = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = newTodoTitle.trim(); + + if (!trimmedTitle) { + showError('Title should not be empty'); + + return; + } + + hideError(); + setTempTodo({ + id: TEMP_TODO_ID, + userId: USER_ID, + title: trimmedTitle, + completed: false, + }); + + createTodo(trimmedTitle) + .then(todo => { + setTodos(currentTodos => [...currentTodos, todo]); + setNewTodoTitle(''); + }) + .catch(() => showError('Unable to add a todo')) + .finally(() => setTempTodo(null)); + }; + + const handleDeleteTodo = (todoId: number) => { + hideError(); + markTodoAsLoading(todoId); + + return deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(error => { + showError('Unable to delete a todo'); + throw error; + }) + .finally(() => unmarkTodoAsLoading(todoId)); + }; + + const handleUpdateTodo = (todoId: number, data: Partial) => { + hideError(); + markTodoAsLoading(todoId); + + return updateTodo(todoId, data) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(todo => (todo.id === todoId ? updatedTodo : todo)), + ); + }) + .catch(error => { + showError('Unable to update a todo'); + throw error; + }) + .finally(() => unmarkTodoAsLoading(todoId)); + }; + + const handleToggleTodo = (todo: Todo) => { + handleUpdateTodo(todo.id, { completed: !todo.completed }).catch(() => {}); + }; + + const handleToggleAll = () => { + const newCompletedStatus = !allTodosCompleted; + + todos + .filter(todo => todo.completed !== newCompletedStatus) + .forEach(todo => { + handleUpdateTodo(todo.id, { completed: newCompletedStatus }).catch( + () => {}, + ); + }); + }; + + const startEditing = (todo: Todo | TempTodo) => { + if (todo.id === TEMP_TODO_ID) { + return; + } + + editingCanceled.current = false; + setEditingTodoId(todo.id); + setEditingTitle(todo.title); + }; + + const cancelEditing = () => { + editingCanceled.current = true; + setEditingTodoId(null); + setEditingTitle(''); + }; + + const submitTodoTitle = (todo: Todo) => { + if (editingCanceled.current) { + editingCanceled.current = false; + + return; + } + + const trimmedTitle = editingTitle.trim(); + + if (trimmedTitle === todo.title) { + cancelEditing(); + + return; + } + + if (!trimmedTitle) { + handleDeleteTodo(todo.id) + .then(cancelEditing) + .catch(() => {}); + + return; + } + + handleUpdateTodo(todo.id, { title: trimmedTitle }) + .then(cancelEditing) + .catch(() => {}); + }; + + const handleClearCompleted = () => { + todos + .filter(todo => todo.completed) + .forEach(todo => { + handleDeleteTodo(todo.id).catch(() => {}); + }); + }; + + const renderTodo = (todo: Todo | TempTodo) => { + const isTemp = todo.id === TEMP_TODO_ID; + const isLoading = isTemp || loadingTodoIds.includes(todo.id); + + return ( +
+ + + {editingTodoId === todo.id && !isTemp ? ( +
{ + event.preventDefault(); + submitTodoTitle(todo); + }} + > + setEditingTitle(event.target.value)} + onBlur={() => submitTodoTitle(todo)} + onKeyUp={event => { + if (event.key === 'Escape') { + cancelEditing(); + } + }} + /> +
+ ) : ( + <> + startEditing(todo)} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ); + }; + 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 && ( +
+ + {(todos.length > 0 || tempTodo) && ( +
+ {visibleTodos.map(renderTodo)} + {tempTodo && renderTodo(tempTodo)} +
+ )} + + {todos.length > 0 && ( + + )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..cffb2c5a21 --- /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 = 4351; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (title: string) => { + return client.post('/todos', { + userId: USER_ID, + title, + 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/styles.zip b/src/styles.zip new file mode 100644 index 0000000000000000000000000000000000000000..b7cf2406dcaa0113a3402b263a198500d081b853 GIT binary patch literal 8653 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 { + 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 191d0ce8d6fac9ef8d27a937cabebd581fec2d54 Mon Sep 17 00:00:00 2001 From: Goat Date: Mon, 6 Jul 2026 15:23:47 +0300 Subject: [PATCH 2/2] Solution --- src/App.tsx | 211 +++++---------------------- src/components/ErrorNotification.tsx | 27 ++++ src/components/Footer.tsx | 58 ++++++++ src/components/Header.tsx | 48 ++++++ src/components/TodoItem.tsx | 101 +++++++++++++ src/components/TodoList.tsx | 62 ++++++++ src/styles.zip | Bin 8653 -> 0 bytes src/types/Filter.ts | 1 + src/types/TempTodo.ts | 3 + 9 files changed, 337 insertions(+), 174 deletions(-) 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 delete mode 100644 src/styles.zip create mode 100644 src/types/Filter.ts create mode 100644 src/types/TempTodo.ts diff --git a/src/App.tsx b/src/App.tsx index 47efb658ec..332aa14ba6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,8 @@ -/* eslint-disable jsx-a11y/label-has-associated-control */ -/* eslint-disable jsx-a11y/control-has-associated-label */ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ErrorNotification } from './components/ErrorNotification'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; import { UserWarning } from './UserWarning'; import { USER_ID, @@ -9,12 +11,10 @@ import { getTodos, updateTodo, } from './api/todos'; +import { Filter } from './types/Filter'; +import { TempTodo } from './types/TempTodo'; import { Todo } from './types/Todo'; -type Filter = 'all' | 'active' | 'completed'; - -type TempTodo = Omit & { id: number }; - const TEMP_TODO_ID = 0; const getFilterFromHash = (): Filter => { @@ -252,84 +252,6 @@ export const App: React.FC = () => { }); }; - const renderTodo = (todo: Todo | TempTodo) => { - const isTemp = todo.id === TEMP_TODO_ID; - const isLoading = isTemp || loadingTodoIds.includes(todo.id); - - return ( -
- - - {editingTodoId === todo.id && !isTemp ? ( -
{ - event.preventDefault(); - submitTodoTitle(todo); - }} - > - setEditingTitle(event.target.value)} - onBlur={() => submitTodoTitle(todo)} - onKeyUp={event => { - if (event.key === 'Escape') { - cancelEditing(); - } - }} - /> -
- ) : ( - <> - startEditing(todo)} - > - {todo.title} - - - - - )} - -
-
-
-
-
- ); - }; - if (!USER_ID) { return ; } @@ -339,104 +261,45 @@ export const App: React.FC = () => {

todos

-
- {todos.length > 0 && ( -
+
{(todos.length > 0 || tempTodo) && ( -
- {visibleTodos.map(renderTodo)} - {tempTodo && renderTodo(tempTodo)} -
+ handleDeleteTodo(todoId).catch(() => {})} + onStartEditing={startEditing} + onEditingTitleChange={setEditingTitle} + onCancelEditing={cancelEditing} + onSubmitTodoTitle={submitTodoTitle} + /> )} {todos.length > 0 && ( - +
)}
-
-
+
); }; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..31bff110c0 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,27 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; + +type Props = { + errorMessage: string; + onHideError: () => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + onHideError, +}) => ( +
+
+); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..27d9ea4369 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Filter } from '../types/Filter'; + +type Props = { + activeTodosCount: number; + completedTodosCount: number; + filter: Filter; + onClearCompleted: () => void; +}; + +export const Footer: React.FC = ({ + activeTodosCount, + completedTodosCount, + filter, + onClearCompleted, +}) => ( + +); diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..83e7535593 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,48 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; + +type Props = { + todosCount: number; + allTodosCompleted: boolean; + newTodoTitle: string; + isAdding: boolean; + newTodoField: React.RefObject; + onAddTodo: (event: React.FormEvent) => void; + onNewTodoTitleChange: (title: string) => void; + onToggleAll: () => void; +}; + +export const Header: React.FC = ({ + todosCount, + allTodosCompleted, + newTodoTitle, + isAdding, + newTodoField, + onAddTodo, + onNewTodoTitleChange, + onToggleAll, +}) => ( +
+ {todosCount > 0 && ( +
+); diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..0da5374dcc --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,101 @@ +/* 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 { TempTodo } from '../types/TempTodo'; + +type Props = { + todo: Todo | TempTodo; + isTemp: boolean; + isLoading: boolean; + isEditing: boolean; + editingTitle: string; + onToggle: (todo: Todo) => void; + onDelete: (todoId: number) => void; + onStartEditing: (todo: Todo | TempTodo) => void; + onEditingTitleChange: (title: string) => void; + onCancelEditing: () => void; + onSubmitTitle: (todo: Todo) => void; +}; + +export const TodoItem: React.FC = ({ + todo, + isTemp, + isLoading, + isEditing, + editingTitle, + onToggle, + onDelete, + onStartEditing, + onEditingTitleChange, + onCancelEditing, + onSubmitTitle, +}) => ( +
+ + + {isEditing && !isTemp ? ( +
{ + event.preventDefault(); + onSubmitTitle(todo as Todo); + }} + > + onEditingTitleChange(event.target.value)} + onBlur={() => onSubmitTitle(todo as Todo)} + onKeyUp={event => { + if (event.key === 'Escape') { + onCancelEditing(); + } + }} + /> +
+ ) : ( + <> + onStartEditing(todo)} + > + {todo.title} + + + + + )} + +
+
+
+
+
+); diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..3b8c55b53c --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TempTodo } from '../types/TempTodo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + tempTodo: TempTodo | null; + tempTodoId: number; + loadingTodoIds: number[]; + editingTodoId: number | null; + editingTitle: string; + onToggleTodo: (todo: Todo) => void; + onDeleteTodo: (todoId: number) => void; + onStartEditing: (todo: Todo | TempTodo) => void; + onEditingTitleChange: (title: string) => void; + onCancelEditing: () => void; + onSubmitTodoTitle: (todo: Todo) => void; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + tempTodoId, + loadingTodoIds, + editingTodoId, + editingTitle, + onToggleTodo, + onDeleteTodo, + onStartEditing, + onEditingTitleChange, + onCancelEditing, + onSubmitTodoTitle, +}) => { + const todosToRender = tempTodo ? [...todos, tempTodo] : todos; + + return ( +
+ {todosToRender.map(todo => { + const isTemp = todo.id === tempTodoId; + const isLoading = isTemp || loadingTodoIds.includes(todo.id); + + return ( + + ); + })} +
+ ); +}; diff --git a/src/styles.zip b/src/styles.zip deleted file mode 100644 index b7cf2406dcaa0113a3402b263a198500d081b853..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8653 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 & { id: number };