From 664333d647d34423ecd0947110b18a9eeb7e435c Mon Sep 17 00:00:00 2001 From: Sidach Ruslan Date: Tue, 21 Apr 2026 14:08:05 +0300 Subject: [PATCH] add solution --- README.md | 2 +- src/App.tsx | 406 +++++++++++++++++++++++++-- src/api/todos.ts | 21 ++ src/components/ErrorNotification.tsx | 29 ++ src/components/Footer.tsx | 59 ++++ src/components/Header.tsx | 59 ++++ src/components/TodoItem.tsx | 123 ++++++++ src/components/TodosList.tsx | 72 +++++ src/types/FilterStatus.ts | 5 + src/types/Todo.ts | 6 + src/types/UserId.ts | 1 + src/utils/fetchClient.ts | 46 +++ 12 files changed, 810 insertions(+), 19 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/TodosList.tsx create mode 100644 src/types/FilterStatus.ts create mode 100644 src/types/Todo.ts create mode 100644 src/types/UserId.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index 47a1add059..3ca786f4ea 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://RuslanV23.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 81e011f432..07ce43d6da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,396 @@ -/* 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 { UserWarning } from './UserWarning'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; -const USER_ID = 0; +import * as todoService from './api/todos'; +import { Todo } from './types/Todo'; +import { TodosList } from './components/TodosList'; +import { FilterStatus } from './types/FilterStatus'; +import { Footer } from './components/Footer'; +// eslint-disable-next-line max-len +import { ErrorNotification } from './components/ErrorNotification'; +import { Header } from './components/Header'; +import { USER_ID } from './types/UserId'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState(FilterStatus.All); + const [titleInput, setTitleInput] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [error, setError] = useState(''); + + const [editTodo, setEditTodo] = useState(null); + const [editTitleInput, setEditTitleInput] = useState(''); + + const [isProssesingId, setIsProssesingId] = useState([]); + + const [disabledHeaderInput, setDisabledHeaderInput] = useState(false); + + // #region focus + const headerInputRef = useRef(null); + + const headerFocus = useCallback(() => { + headerInputRef.current?.focus(); + }, []); + + useEffect(() => { + headerFocus(); + }, [headerFocus]); + + useEffect(() => { + if (!disabledHeaderInput) { + headerInputRef.current?.focus(); + } + }, [disabledHeaderInput]); + + const timerId = useRef(0); + + const handleSetError = useCallback((newError: string) => { + window.clearTimeout(timerId.current); + + timerId.current = window.setTimeout(() => { + setError(''); + }, 3000); + + setError(newError); + }, []); + // #endregion + + // #region useMemo variable + const filteredTodos: Todo[] = useMemo(() => { + if (filter === FilterStatus.All) { + return todos; + } + + return todos.filter(todo => + filter === FilterStatus.Active ? !todo.completed : todo.completed, + ); + }, [todos, filter]); + + const hasCompletedTodo: boolean = useMemo(() => { + return todos.some(todo => todo.completed); + }, [todos]); + + const hasTodo: boolean = useMemo(() => { + return todos.length !== 0; + }, [todos]); + + const isEveryCompletedTodo: boolean = useMemo(() => { + return todos.every(todo => todo.completed); + }, [todos]); + + const itemsLeft: number = useMemo(() => { + return todos.filter(todo => !todo.completed).length; + }, [todos]); + + // #endregion + + const setProssesingTodo = useCallback( + (todoIds: number[], isProsess: boolean) => { + if (isProsess) { + setIsProssesingId(prev => [...prev, ...todoIds]); + + return; + } + + setIsProssesingId(prev => prev.filter(id => !todoIds.includes(id))); + }, + [], + ); + + const deleteTodo = useCallback( + async (todoId: number) => { + try { + setProssesingTodo([todoId], true); + await todoService.deleteTodo(todoId); + setTodos(current => current.filter(todo => todo.id !== todoId)); + } catch { + handleSetError('Unable to delete a todo'); + throw new Error('Unable to delete a todo'); + } finally { + setProssesingTodo([todoId], false); + headerFocus(); + } + }, + [headerFocus, setProssesingTodo, handleSetError], + ); + + const updateTodo = useCallback( + async (todo: Partial & Pick) => { + const currentTodo = todos.find(todofind => todofind.id === todo.id); + + if (!currentTodo) { + handleSetError('Unable to update a todo'); + + return; + } + + const isDifferent = (Object.keys(todo) as (keyof Todo)[]).some( + todoKey => todo[todoKey] !== currentTodo[todoKey], + ); + + if (!isDifferent) { + return; + } + + return todoService.updateTodo(todo); + }, + [todos, handleSetError], + ); + + const handleToggleCompleleTodo = useCallback( + ({ id, completed }: Pick) => { + setProssesingTodo([id], true); + + updateTodo({ id, completed: !completed }) + .then(updatedTodo => { + if (!updatedTodo) { + return; + } + + setTodos(prevTodos => { + const newTodos = [...prevTodos]; + const index = newTodos.findIndex( + todo => todo.id === updatedTodo.id, + ); + + newTodos.splice(index, 1, updatedTodo); + + return newTodos; + }); + }) + .catch(() => { + handleSetError('Unable to update a todo'); + }) + .finally(() => { + setProssesingTodo([id], false); + }); + }, + [setProssesingTodo, updateTodo, handleSetError], + ); + + const handleEditSubmit = useCallback( + ({ title, id }: Todo) => { + if (title === editTitleInput) { + setEditTodo(null); + + return; + } + + const trimmedTitle = editTitleInput.trim(); + + if (trimmedTitle.length === 0) { + deleteTodo(id); + + return; + } + + setEditTitleInput(trimmedTitle); + setProssesingTodo([id], true); + + updateTodo({ title: trimmedTitle, id: id }) + .then(updatedTodo => { + if (!updatedTodo) { + return; + } + + setTodos(prev => { + const newTodos = [...prev]; + const index = newTodos.findIndex( + todo => todo.id === updatedTodo.id, + ); + + newTodos[index].title = trimmedTitle; + + return newTodos; + }); + setEditTodo(null); + }) + .catch(() => { + handleSetError('Unable to update a todo'); + }) + .finally(() => { + setProssesingTodo([id], false); + }); + }, + [deleteTodo, editTitleInput, setProssesingTodo, updateTodo, handleSetError], + ); + + const toggleAll = useCallback(async () => { + const needToggleTodos = todos.filter(todo => + isEveryCompletedTodo ? true : !todo.completed, + ); + + const ids = needToggleTodos.map(todo => todo.id); + + setProssesingTodo(ids, true); + + try { + const results = await Promise.allSettled( + needToggleTodos.map(todo => + updateTodo({ id: todo.id, completed: !isEveryCompletedTodo }), + ), + ); + + const fulfilledTodos = results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' && result.value !== undefined, + ) + .map(result => result.value); + + if (results.some(result => result.status === 'rejected')) { + handleSetError('Unable to update todos'); + } + + setTodos(prevTodos => + prevTodos.map(todo => { + const updatedTodo = fulfilledTodos.find(item => item.id === todo.id); + + return updatedTodo ?? todo; + }), + ); + } finally { + setProssesingTodo(ids, false); + } + }, [ + todos, + isEveryCompletedTodo, + updateTodo, + handleSetError, + setProssesingTodo, + ]); + + const clearCompletedTodos = useCallback(async () => { + try { + const idsComplited = todos + .filter(todo => todo.completed) + .map(todo => todo.id); + + setIsProssesingId((currentIsDeleting: number[]) => [ + ...currentIsDeleting, + ...idsComplited, + ]); + + const promiseDeletingTodos = await Promise.allSettled( + idsComplited.map(id => todoService.deleteTodo(id)), + ); + + const succeeded = promiseDeletingTodos + .map((r, i) => (r.status === 'fulfilled' ? idsComplited[i] : null)) + .filter(Boolean) as number[]; + + setTodos(prev => prev.filter(t => !succeeded.includes(t.id))); + + if (promiseDeletingTodos.some(r => r.status === 'rejected')) { + handleSetError('Unable to delete a todo'); + } + + setIsProssesingId((currentIsDeleting: number[]) => + currentIsDeleting.filter(id => !idsComplited.includes(id)), + ); + + headerFocus(); + } catch { + handleSetError('Unable to delete a todo'); + } + }, [todos, headerFocus, handleSetError]); + + const addTodo = useCallback( + async (title: string) => { + const trimmedTitle = title.trim(); + + if (trimmedTitle.length === 0) { + handleSetError('Title should not be empty'); + + return; + } + + setDisabledHeaderInput(true); + setTempTodo({ + title: trimmedTitle, + completed: false, + userId: USER_ID, + } as Todo); + + try { + const newTodo = await todoService.createTodo({ + title: trimmedTitle, + completed: false, + userId: USER_ID, + }); + + setTitleInput(''); + setTodos(currentTodos => [...currentTodos, newTodo]); + } catch { + handleSetError('Unable to add a todo'); + } finally { + setTempTodo(null); + setDisabledHeaderInput(false); + } + }, + [handleSetError], + ); + + useEffect(() => { + todoService + .getTodos() + .then(todo => { + window.clearTimeout(timerId.current); + setTodos(todo); + }) + .catch(() => { + handleSetError('Unable to load todos'); + }); + }, [handleSetError]); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {todos.length !== 0 && ( +
+ )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..480024ec9e --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,21 @@ +import { Todo } from '../types/Todo'; +import { USER_ID } from '../types/UserId'; +import { client } from '../utils/fetchClient'; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const createTodo = (todo: Omit) => { + return client.post(`/todos`, todo); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todo: Partial): Promise => { + return client.patch(`/todos/${todo.id}`, todo); +}; + +// Add more methods here diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..d87c8e8ca3 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames'; +import React from 'react'; + +type Props = { + error: string; + setError: (er: string) => void; +}; + +const ErrorNotificationComponent: React.FC = ({ error, setError }) => { + return ( +
+
+ ); +}; + +export const ErrorNotification = React.memo(ErrorNotificationComponent); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..3b12272d94 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { FilterStatus } from '../types/FilterStatus'; +import classNames from 'classnames'; + +type Props = { + filter: FilterStatus; + setFilter: (filter: FilterStatus) => void; + hasCompletedTodo: boolean; + itemsLeft: number; + onClearCompletedTodos: () => Promise; +}; + +const FooterComponent: React.FC = ({ + itemsLeft, + setFilter, + filter, + hasCompletedTodo, + onClearCompletedTodos, +}) => { + return ( + + ); +}; + +export const Footer = React.memo(FooterComponent); diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..7985f06c61 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,59 @@ +import classNames from 'classnames'; +import React from 'react'; + +type Props = { + hasTodo: boolean; + onToggleAll: () => void; + isEveryCompletedTodo: boolean; + headerInputRef: React.MutableRefObject; + title: string; + setTitle: (title: string) => void; + addTodo: (title: string) => Promise; + disabledInput: boolean; +}; + +const HeaderComponent: React.FC = ({ + hasTodo, + onToggleAll, + isEveryCompletedTodo, + headerInputRef, + title, + setTitle, + addTodo, + disabledInput, +}) => { + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + addTodo(title); + }; + + return ( +
+ {hasTodo && ( +
+ ); +}; + +export const Header = React.memo(HeaderComponent); diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..972460cbbc --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,123 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ + +import classNames from 'classnames'; +import React, { useEffect, useRef } from 'react'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + onDelete: (todoid: number) => Promise; + handleToggleCompleleTodo: (todo: Pick) => void; + isProssesing: boolean; + titleInput: string; + setTitleInput: (str: string) => void; + editTodo: Todo | null; + setEditTodo: (todo: Todo | null) => void; + onEditSubmit: (todo: Todo) => void; +}; + +const TodoItemComponent: React.FC = ({ + todo, + onDelete, + isProssesing, + handleToggleCompleleTodo, + titleInput, + setTitleInput, + editTodo, + setEditTodo, + onEditSubmit, +}) => { + const inputTitleRef = useRef(null); + + const isEditing = editTodo?.id === todo.id; + + const handleEditSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onEditSubmit(todo); + }; + + const handleEditOnBlur = () => { + onEditSubmit(todo); + }; + + useEffect(() => { + if (isEditing) { + inputTitleRef.current?.focus(); + } + }, [isEditing]); + + return ( +
+ + + {isEditing && !isProssesing ? ( +
{ + if (event.key === 'Escape') { + setEditTodo(null); + setTitleInput(todo.title); + } + }} + onSubmit={handleEditSubmit} + > + setTitleInput(event.target.value)} + onBlur={handleEditOnBlur} + /> +
+ ) : ( + <> + { + setTitleInput(todo.title); + setEditTodo(todo); + }} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ); +}; + +export const TodoItem = React.memo(TodoItemComponent); diff --git a/src/components/TodosList.tsx b/src/components/TodosList.tsx new file mode 100644 index 0000000000..71733d1102 --- /dev/null +++ b/src/components/TodosList.tsx @@ -0,0 +1,72 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ + +import React from 'react'; + +import { Todo as TypeTodo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: TypeTodo[]; + onDelete: (todoid: number) => Promise; + tempTodo: TypeTodo | null; + isProssesingId: number[]; + handleToggleCompleleTodo: (todo: Pick) => void; + editTitleInput: string; + setEditTitleInput: (str: string) => void; + editTodo: TypeTodo | null; + setEditTodo: (todo: TypeTodo | null) => void; + onEditSubmit: (todo: TypeTodo) => void; +}; + +const TodosListComponent: React.FC = ({ + todos, + onDelete, + tempTodo, + isProssesingId, + handleToggleCompleleTodo, + editTitleInput, + setEditTitleInput, + editTodo, + setEditTodo, + onEditSubmit, +}) => { + return ( +
+ {todos.map(todo => { + const isProssesing = isProssesingId.includes(todo.id); + + return ( + + ); + })} + + {tempTodo && ( + + )} +
+ ); +}; + +export const TodosList = React.memo(TodosListComponent); diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts new file mode 100644 index 0000000000..7ca17f289b --- /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/types/UserId.ts b/src/types/UserId.ts new file mode 100644 index 0000000000..e9af16120f --- /dev/null +++ b/src/types/UserId.ts @@ -0,0 +1 @@ +export const USER_ID = 4140; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..28c946e7f6 --- /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(`${response.status} ${response.statusText}`); + } + + 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'), +};