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..9cf9cc6c42 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,295 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import { flushSync } from 'react-dom'; import { UserWarning } from './UserWarning'; +import { ErrorNotification } from './components/ErrorNotification'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { getTodos, createTodo, deleteTodo, updateTodo } from './api/todos'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +type FilterType = 'all' | 'active' | 'completed'; -export const App: React.FC = () => { - if (!USER_ID) { - return ; +function getUserId(): number { + try { + const userData = localStorage.getItem('user'); + + return userData ? JSON.parse(userData).id || 0 : 0; + } catch { + return 0; } +} + +function getFilteredTodos(todos: Todo[], filter: FilterType): Todo[] { + switch (filter) { + case 'active': + return todos.filter(todo => !todo.completed); + case 'completed': + return todos.filter(todo => todo.completed); + default: + return todos; + } +} + +const TodoApp: React.FC<{ userId: number }> = ({ userId }) => { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [filter, setFilter] = useState('all'); + const [loadingIds, setLoadingIds] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [newTodoTitle, setNewTodoTitle] = useState(''); + const [isInputDisabled, setIsInputDisabled] = useState(false); + + const inputRef = useRef(null); + const errorTimerRef = useRef | null>(null); + + const showError = (message: string) => { + setErrorMessage(message); + + if (errorTimerRef.current) { + clearTimeout(errorTimerRef.current); + } + + errorTimerRef.current = setTimeout(() => { + setErrorMessage(''); + }, 3000); + }; + + const hideError = () => { + setErrorMessage(''); + + if (errorTimerRef.current) { + clearTimeout(errorTimerRef.current); + errorTimerRef.current = null; + } + }; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + getTodos(userId) + .then(setTodos) + .catch(() => showError('Unable to load todos')); + }, [userId]); + + const addLoadingId = (id: number) => setLoadingIds(prev => [...prev, id]); + + const removeLoadingId = (id: number) => + setLoadingIds(prev => prev.filter(existingId => existingId !== id)); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + hideError(); + + const trimmed = newTodoTitle.trim(); + + if (!trimmed) { + showError('Title should not be empty'); + inputRef.current?.focus(); + + return; + } + + const temp: Todo = { + id: 0, + userId, + title: trimmed, + completed: false, + }; + + flushSync(() => { + setIsInputDisabled(true); + setTempTodo(temp); + }); + + let createdTodo: Todo | null = null; + + try { + createdTodo = await createTodo(userId, trimmed); + } catch { + showError('Unable to add a todo'); + } finally { + setTimeout(() => { + flushSync(() => { + setTempTodo(null); + setIsInputDisabled(false); + if (createdTodo) { + setTodos(prev => [...prev, createdTodo!]); + setNewTodoTitle(''); + } + }); + inputRef.current?.focus(); + }, 0); + } + }; + + const handleDeleteTodo = async (id: number): Promise => { + flushSync(() => addLoadingId(id)); + + let success = false; + + try { + await deleteTodo(id); + success = true; + } catch { + showError('Unable to delete a todo'); + } finally { + setTimeout(() => { + flushSync(() => { + removeLoadingId(id); + if (success) { + setTodos(prev => prev.filter(todo => todo.id !== id)); + } + }); + if (success) { + inputRef.current?.focus(); + } + }, 0); + } + + if (!success) { + throw new Error('Unable to delete a todo'); + } + }; + + const handleUpdateTodo = async ( + id: number, + data: Partial, + ): Promise => { + flushSync(() => addLoadingId(id)); + + let updated: Todo | null = null; + + try { + updated = await updateTodo(id, data); + } catch { + showError('Unable to update a todo'); + } finally { + setTimeout(() => { + flushSync(() => { + removeLoadingId(id); + if (updated) { + setTodos(prev => prev.map(todo => (todo.id === id ? updated! : todo))); + } + }); + }, 0); + } + + if (!updated) { + throw new Error('Unable to update a todo'); + } + }; + + const handleToggleAll = async () => { + const allCompleted = todos.every(todo => todo.completed); + const newStatus = !allCompleted; + const todosToUpdate = todos.filter(todo => todo.completed !== newStatus); + const ids = todosToUpdate.map(todo => todo.id); + + setLoadingIds(prev => [...prev, ...ids]); + + let hasError = false; + + await Promise.all( + todosToUpdate.map(todo => + updateTodo(todo.id, { completed: newStatus }) + .then(updated => { + setTodos(prev => + prev.map(t => (t.id === updated.id ? updated : t)), + ); + }) + .catch(() => { + hasError = true; + }), + ), + ); + + if (hasError) { + showError('Unable to update a todo'); + } + + setLoadingIds(prev => prev.filter(id => !ids.includes(id))); + }; + + const handleClearCompleted = async () => { + const completedTodos = todos.filter(todo => todo.completed); + const ids = completedTodos.map(todo => todo.id); + + setLoadingIds(prev => [...prev, ...ids]); + + let hasError = false; + + await Promise.all( + completedTodos.map(todo => + deleteTodo(todo.id) + .then(() => { + setTodos(prev => prev.filter(t => t.id !== todo.id)); + }) + .catch(() => { + hasError = true; + }), + ), + ); + + if (hasError) { + showError('Unable to delete a todo'); + } + + setLoadingIds(prev => prev.filter(id => !ids.includes(id))); + inputRef.current?.focus(); + }; + + const filteredTodos = getFilteredTodos(todos, filter); + const hasTodos = todos.length > 0; return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {(hasTodos || tempTodo) && ( + + )} + + {hasTodos && ( +
+ )} +
+ + +
); }; + +export const App: React.FC = () => { + const userId = getUserId(); + + if (!userId) { + return ; + } + + return ; +}; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..d1648eb6e1 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export function getTodos(userId: number): Promise { + return client.get(`/todos?userId=${userId}`); +} + +export function createTodo(userId: number, title: string): Promise { + return client.post('/todos', { userId, title, completed: false }); +} + +export function deleteTodo(todoId: number): Promise { + return client.delete(`/todos/${todoId}`); +} + +export function updateTodo(todoId: number, data: Partial): Promise { + return client.patch(`/todos/${todoId}`, data); +} diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..ee8295f0b9 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + error: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ error, onClose }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..33f711662f --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type FilterType = 'all' | 'active' | 'completed'; + +type Props = { + todos: Todo[]; + filter: FilterType; + onFilterChange: (filter: FilterType) => void; + onClearCompleted: () => void; +}; + +export const Footer: React.FC = ({ + todos, + filter, + onFilterChange, + onClearCompleted, +}) => { + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const hasCompleted = todos.some(todo => todo.completed); + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..56d9249213 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todos: Todo[]; + newTodoTitle: string; + isInputDisabled: boolean; + onTitleChange: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + onToggleAll: () => void; + inputRef: React.RefObject; +}; + +export const Header: React.FC = ({ + todos, + newTodoTitle, + isInputDisabled, + onTitleChange, + onSubmit, + onToggleAll, + inputRef, +}) => { + const allCompleted = todos.length > 0 && todos.every(todo => todo.completed); + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..a6594fb795 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,143 @@ +import React, { useState, useRef, useEffect } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + isLoading: boolean; + onDelete: (id: number) => Promise; + onUpdate: (id: number, data: Partial) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + onDelete, + onUpdate, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(todo.title); + const editInputRef = useRef(null); + const submittingRef = useRef(false); + const escPressedRef = useRef(false); + + useEffect(() => { + if (isEditing) { + editInputRef.current?.focus(); + } + }, [isEditing]); + + const doSubmit = async () => { + if (submittingRef.current || escPressedRef.current) { + return; + } + + const trimmed = editTitle.trim(); + + if (trimmed === todo.title) { + setIsEditing(false); + + return; + } + + submittingRef.current = true; + + if (!trimmed) { + try { + await onDelete(todo.id); + } catch { + submittingRef.current = false; + } + + return; + } + + try { + await onUpdate(todo.id, { title: trimmed }); + setIsEditing(false); + submittingRef.current = false; + } catch { + submittingRef.current = false; + editInputRef.current?.focus(); + } + }; + + const handleDoubleClick = () => { + escPressedRef.current = false; + submittingRef.current = false; + setEditTitle(todo.title); + setIsEditing(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + doSubmit(); + } + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + escPressedRef.current = true; + setEditTitle(todo.title); + setIsEditing(false); + } + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {isEditing ? ( + setEditTitle(e.target.value)} + onBlur={doSubmit} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + /> + ) : ( + <> + + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..5a5aeb903b --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { TransitionGroup, CSSTransition } from 'react-transition-group'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + loadingIds: number[]; + onDelete: (id: number) => Promise; + onUpdate: (id: number, data: Partial) => Promise; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + loadingIds, + onDelete, + onUpdate, +}) => { + return ( +
+ + {todos.map(todo => ( + + + + ))} + + {tempTodo && ( + + + + )} + +
+ ); +}; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..d4b6e8c0c8 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,6 @@ +export type 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..253dff114f --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,35 @@ +const BASE_URL = 'https://mate.academy/students-api'; + +type RequestOptions = { + method?: string; + body?: string; +}; + +function request(url: string, options: RequestOptions = {}): Promise { + const headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + + return fetch(BASE_URL + url, { ...options, headers }).then(response => { + if (!response.ok) { + throw new Error(); + } + + return response.json(); + }); +} + +export const client = { + get: (url: string) => request(url), + post: (url: string, data: unknown) => + request(url, { + method: 'POST', + body: JSON.stringify(data), + }), + patch: (url: string, data: unknown) => + request(url, { + method: 'PATCH', + body: JSON.stringify(data), + }), + delete: (url: string) => request(url, { method: 'DELETE' }), +};