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..3082465680 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,241 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import classNames from 'classnames'; + +import { Todo } from './types/Todo'; +import { ErrorMessage } from './types/ErrorMessage'; +import { getTodos, addTodo, deleteTodo, updateTodo } from './api/todos'; +import { TodoList } from './components/TodoList'; +import { TodoFilter, FilterValue } from './components/TodoFilter'; +import { ErrorNotification } from './components/ErrorNotification'; import { UserWarning } from './UserWarning'; -const USER_ID = 0; +const USER_ID = 2425; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState('all'); + const [errorMessage, setErrorMessage] = useState(null); + const [newTitle, setNewTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [isAdding, setIsAdding] = useState(false); + + const inputRef = useRef(null); + + const activeUserId = (() => { + try { + const user = localStorage.getItem('user'); + + return user ? JSON.parse(user).id : USER_ID; + } catch { + return USER_ID; + } + })(); + + useEffect(() => { + if (!activeUserId) { + return; + } + + getTodos(activeUserId) + .then(setTodos) + .catch(() => { + setErrorMessage(ErrorMessage.Load); + }); + }, [activeUserId]); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [todos, tempTodo]); + + if (!activeUserId) { return ; } + const handleAddTodo = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + setErrorMessage(ErrorMessage.EmptyTitle); + + return; + } + + const optimisticTodo: Todo = { + id: 0, + userId: activeUserId, + title: trimmedTitle, + completed: false, + }; + + setTempTodo(optimisticTodo); + setIsAdding(true); + setErrorMessage(null); + + addTodo({ userId: activeUserId, title: trimmedTitle, completed: false }) + .then(newTodo => { + setTodos(prev => [...prev, newTodo]); + setNewTitle(''); + }) + .catch(() => { + setErrorMessage(ErrorMessage.Add); + }) + .finally(() => { + setTempTodo(null); + setIsAdding(false); + inputRef.current?.focus(); + }); + }; + + const handleDeleteTodo = (todoId: number): Promise => { + setLoadingTodoIds(prev => [...prev, todoId]); + setErrorMessage(null); + + return deleteTodo(todoId) + .then(() => { + setTodos(prev => prev.filter(todo => todo.id !== todoId)); + }) + .catch(err => { + setErrorMessage(ErrorMessage.Delete); + throw err; + }) + .finally(() => { + setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); + inputRef.current?.focus(); + }); + }; + + const handleUpdateTodo = ( + todoId: number, + data: Partial, + ): Promise => { + setLoadingTodoIds(prev => [...prev, todoId]); + setErrorMessage(null); + + return updateTodo(todoId, data) + .then(updatedTodo => { + setTodos(prev => + prev.map(todo => (todo.id === todoId ? updatedTodo : todo)), + ); + }) + .catch(err => { + setErrorMessage(ErrorMessage.Update); + throw err; + }) + .finally(() => { + setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); + inputRef.current?.focus(); + }); + }; + + const handleToggleAll = () => { + const allCompleted = todos.every(t => t.completed); + const targetCompleted = !allCompleted; + const todosToUpdate = todos.filter(t => t.completed !== targetCompleted); + + todosToUpdate.forEach(todo => { + handleUpdateTodo(todo.id, { completed: targetCompleted }).catch(() => {}); + }); + }; + + const handleClearCompleted = () => { + const completedTodos = todos.filter(t => t.completed); + + completedTodos.forEach(todo => { + handleDeleteTodo(todo.id).catch(() => {}); + }); + }; + + const visibleTodos = todos.filter(todo => { + if (filter === 'active') { + return !todo.completed; + } + + if (filter === 'completed') { + return todo.completed; + } + + return true; + }); + + const allCompleted = todos.length > 0 && todos.every(t => t.completed); + const activeTodosCount = todos.filter(t => !t.completed).length; + const completedTodosCount = todos.filter(t => t.completed).length; + const activeCountText = `${activeTodosCount} ${ + activeTodosCount === 1 ? 'item' : 'items' + } left`; + 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) && ( + <> + + + {todos.length > 0 && ( +
+ + {activeCountText} + + + + + +
+ )} + + )} +
+ + setErrorMessage(null)} + /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..4f252f733e --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const addTodo = ({ userId, title, completed }: Omit) => { + return client.post('/todos', { userId, title, completed }); +}; + +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..db84006954 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,46 @@ +import React, { useEffect } from 'react'; +import classNames from 'classnames'; + +import { ErrorMessage } from '../types/ErrorMessage'; + +interface Props { + message: ErrorMessage | null; + onClose: () => void; +} + +export const ErrorNotification: React.FC = ({ message, onClose }) => { + useEffect(() => { + if (!message) { + return; + } + + const timer = setTimeout(() => { + onClose(); + }, 3000); + + return () => { + clearTimeout(timer); + }; + }, [message, onClose]); + + return ( +
+
+ ); +}; diff --git a/src/components/TodoFilter.tsx b/src/components/TodoFilter.tsx new file mode 100644 index 0000000000..1ce2eb3c43 --- /dev/null +++ b/src/components/TodoFilter.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import classNames from 'classnames'; + +type FilterValue = 'all' | 'active' | 'completed'; + +interface Props { + filter: FilterValue; + onChange: (filter: FilterValue) => void; +} + +export const TodoFilter: React.FC = ({ filter, onChange }) => { + return ( + + ); +}; + +export type { FilterValue }; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..104785df13 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,144 @@ +import React, { useState, useRef, useEffect } from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../types/Todo'; + +interface Props { + todo: Todo; + isLoading: boolean; + onDelete: (todoId: number) => Promise; + onUpdate: (todoId: 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 editFieldRef = useRef(null); + + useEffect(() => { + if (isEditing && editFieldRef.current) { + editFieldRef.current.focus(); + } + }, [isEditing]); + + const handleStatusChange = () => { + onUpdate(todo.id, { completed: !todo.completed }); + }; + + const handleSave = () => { + if (!isEditing || isLoading) { + return; + } + + const trimmedTitle = editTitle.trim(); + + if (trimmedTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (!trimmedTitle) { + onDelete(todo.id) + .then(() => { + setIsEditing(false); + }) + .catch(() => { + if (editFieldRef.current) { + editFieldRef.current.focus(); + } + }); + + return; + } + + onUpdate(todo.id, { title: trimmedTitle }) + .then(() => { + setIsEditing(false); + }) + .catch(() => { + if (editFieldRef.current) { + editFieldRef.current.focus(); + } + }); + }; + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleSave(); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditing(false); + setEditTitle(todo.title); + } + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {isEditing ? ( +
+ setEditTitle(e.target.value)} + onBlur={handleSave} + onKeyUp={handleKeyUp} + ref={editFieldRef} + disabled={isLoading} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..a22bfd8ab2 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +interface Props { + todos: Todo[]; + tempTodo: Todo | null; + loadingTodoIds: number[]; + onDelete: (todoId: number) => Promise; + onUpdate: (todoId: number, data: Partial) => Promise; +} + +export const TodoList: React.FC = ({ + todos, + tempTodo, + loadingTodoIds, + onDelete, + onUpdate, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && ( + Promise.resolve()} + onUpdate={() => Promise.resolve()} + /> + )} +
+ ); +}; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..4d4347ca85 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + Load = 'Unable to load todos', + EmptyTitle = '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/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..3ffe70eeba --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,41 @@ +const BASE_URL = 'https://mate.academy/students-api'; + +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: unknown = null, +): Promise { + const options: RequestInit = { method }; + + if (data) { + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + return wait(300) + .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: unknown) => request(url, 'POST', data), + patch: (url: string, data: unknown) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +};