diff --git a/README.md b/README.md index 47a1add059..18cbad548d 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://cassiaqueiroz.github.io/react_todo-app-with-api/) and add it to the PR description. 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..5abc58b4ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,384 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; import { UserWarning } from './UserWarning'; +import { + addTodo, + deleteTodo, + getTodos, + updateTodo, + USER_ID, +} from './api/todos'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +type FilterType = 'all' | 'active' | 'completed'; export const App: React.FC = () => { + const inputRef = useRef(null); + const [title, setTitle] = useState(''); + const [todos, setTodos] = useState([]); + const [filter, setFilter] = useState('all'); + const [errorMessage, setErrorMessage] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [deletingIds, setDeletingIds] = useState([]); + const [updatingIds, setUpdatingIds] = useState([]); + const [editingId, setEditingId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + setErrorMessage('Title should not be empty'); + + return; + } + + setTempTodo({ + id: 0, + userId: USER_ID, + title: title.trim(), + completed: false, + }); + setIsSubmitting(true); + + addTodo(title.trim()) + .then(newTodo => { + setTodos(prev => [...prev, newTodo]); + setTitle(''); + }) + .catch(() => setErrorMessage('Unable to add a todo')) + .finally(() => { + setIsSubmitting(false); + setTempTodo(null); + }); + }; + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => setErrorMessage('Unable to load todos')) + .finally(() => inputRef.current?.focus()); + }, []); + + useEffect(() => { + if (!isSubmitting) { + inputRef.current?.focus(); + } + }, [isSubmitting]); + + useEffect(() => { + if (!errorMessage) { + return; + } + + const timer = setTimeout(() => setErrorMessage(''), 3000); + + return () => clearTimeout(timer); + }, [errorMessage]); + + const filteredTodos = todos.filter(todo => { + if (filter === 'active') { + return !todo.completed; + } + + if (filter === 'completed') { + return todo.completed; + } + + return true; + }); + + const activeCount = todos.filter(todo => !todo.completed).length; + + const handleDelete = (id: number) => { + setDeletingIds(prev => [...prev, id]); + + deleteTodo(id) + .then(() => setTodos(prev => prev.filter(todo => todo.id !== id))) + .catch(() => setErrorMessage('Unable to delete a todo')) + .finally(() => { + setDeletingIds(prev => prev.filter(deletingId => deletingId !== id)); + inputRef.current?.focus(); + }); + }; + + const handleClearCompleted = () => { + const completedTodos = todos.filter(todo => todo.completed); + + Promise.all(completedTodos.map(todo => handleDelete(todo.id))); + }; + + const handleToggle = (todo: Todo) => { + setUpdatingIds(prev => [...prev, todo.id]); + + updateTodo(todo.id, { completed: !todo.completed }) + .then((updated: Todo) => + setTodos(prev => + prev.map(item => (item.id === updated.id ? updated : item)), + ), + ) + .catch(() => setErrorMessage('Unable to update a todo')) + .finally(() => setUpdatingIds(prev => prev.filter(id => id !== todo.id))); + }; + + const handleToggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + const todosToUpdate = todos.filter( + todo => todo.completed !== !allCompleted, + ); + + Promise.all(todosToUpdate.map(todo => handleToggle(todo))); + }; + + const handleRename = (todo: Todo) => { + const trimmed = editingTitle.trim(); + + if (trimmed === todo.title) { + setEditingId(null); + + return; + } + + if (!trimmed) { + handleDelete(todo.id); + + return; + } + + setUpdatingIds(prev => [...prev, todo.id]); + + updateTodo(todo.id, { title: trimmed }) + .then((updated: Todo) => { + setTodos(prev => prev.map(t => (t.id === updated.id ? updated : t))); + setEditingId(null); + }) + .catch(() => setErrorMessage('Unable to update a todo')) + .finally(() => setUpdatingIds(prev => prev.filter(id => id !== todo.id))); + }; + + const editInputRef = useRef(null); + + useEffect(() => { + if (editingId !== null) { + editInputRef.current?.focus(); + } + }, [editingId]); + 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 && ( + <> +
+ {filteredTodos.map(todo => ( +
+ + + {editingId === todo.id ? ( +
{ + event.preventDefault(); + handleRename(todo); + }} + > + setEditingTitle(e.target.value)} + onBlur={() => handleRename(todo)} + onKeyUp={event => { + if (event.key === 'Escape') { + setEditingId(null); + setEditingTitle(''); + } + }} + /> +
+ ) : ( + <> + { + setEditingId(todo.id); + setEditingTitle(todo.title); + }} + > + {todo.title} + + + + + )} + +
+ {/* eslint-disable-next-line max-len */} +
+
+
+
+ ))} +
+ + + + )} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + + +
+
+
+
+
+ )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..5b81c6f387 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,25 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4309; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here +export const addTodo = (title: string): Promise => { + return client.post('/todos', { + userId: USER_ID, + title, + completed: false, + }); +}; + +export const deleteTodo = (id: number): Promise => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (id: number, data: Partial): Promise => { + return client.patch(`/todos/${id}`, data); +}; 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..5be775084e --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +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: any = null, +): Promise { + const options: RequestInit = { method }; + + if (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'), +}; diff --git a/tsconfig.json b/tsconfig.json index cfb168bb26..c592c63ab1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,11 @@ "src" ], "compilerOptions": { + "jsx": "react-jsx", "sourceMap": false, - "types": ["node", "cypress"] + "types": [ + "node", + "cypress" + ] } -} +} \ No newline at end of file