diff --git a/README.md b/README.md index 47a1add059..d4a678db11 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://eg3844540-svg.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..01238faba4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,572 @@ -/* eslint-disable max-len */ +/* eslint-disable padding-line-between-statements */ +/* eslint-disable @typescript-eslint/indent */ +/* eslint-disable prettier/prettier */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; + +import React, { + FormEvent, + KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; + import { UserWarning } from './UserWarning'; +import { + USER_ID, + createTodo, + deleteTodo, + getTodos, + updateTodo, +} from './api/todos'; +import { Todo } from './types/Todo'; -const USER_ID = 0; +type FilterStatus = 'All' | 'Active' | 'Completed'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [filter, setFilter] = useState('All'); + + const [title, setTitle] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + const [isAdding, setIsAdding] = useState(false); + + const [processingTodoIds, setProcessingTodoIds] = useState([]); + + const [editingTodoId, setEditingTodoId] = useState(null); + const [editingTitle, setEditingTitle] = useState(''); + + const newTodoField = useRef(null); + const editField = useRef(null); + + useEffect(() => { + setLoading(true); + setError(''); + + getTodos() + .then(setTodos) + .catch(() => { + setError('Unable to load todos'); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + useEffect(() => { + if (!error) { + return; + } + + const timer = setTimeout(() => { + setError(''); + }, 3000); + + return () => clearTimeout(timer); + }, [error]); + + useEffect(() => { + if (editingTodoId !== null) { + editField.current?.focus(); + } + }, [editingTodoId]); + + const activeTodos = todos.filter(todo => !todo.completed); + + const visibleTodos = useMemo(() => { + switch (filter) { + case 'Active': + return todos.filter(todo => !todo.completed); + + case 'Completed': + return todos.filter(todo => todo.completed); + + default: + return todos; + } + }, [todos, filter]); + + const startProcessing = (todoId: number) => { + setProcessingTodoIds(currentIds => + currentIds.includes(todoId) + ? currentIds + : [...currentIds, todoId], + ); + }; + + const stopProcessing = (todoId: number) => { + setProcessingTodoIds(currentIds => + currentIds.filter(id => id !== todoId), + ); + }; + + const focusNewTodoField = () => { + setTimeout(() => { + newTodoField.current?.focus(); + }, 0); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + setError('Title should not be empty'); + return; + } + + const newTodo: Todo = { + id: 0, + userId: USER_ID, + title: trimmedTitle, + completed: false, + }; + + setError(''); + setIsAdding(true); + setTempTodo(newTodo); + + createTodo({ + userId: USER_ID, + title: trimmedTitle, + completed: false, + }) + .then(createdTodo => { + setTodos(currentTodos => [...currentTodos, createdTodo]); + setTitle(''); + }) + .catch(() => { + setError('Unable to add a todo'); + }) + .finally(() => { + setIsAdding(false); + setTempTodo(null); + focusNewTodoField(); + }); + }; + + const handleDelete = (todoId: number) => { + setError(''); + startProcessing(todoId); + + return deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setError('Unable to delete a todo'); + throw new Error('Unable to delete a todo'); + }) + .finally(() => { + stopProcessing(todoId); + focusNewTodoField(); + }); + }; + + const handleClearCompleted = () => { + const completedTodos = todos.filter(todo => todo.completed); + + Promise.allSettled( + completedTodos.map(todo => handleDelete(todo.id)), + ).then(results => { + const hasError = results.some( + result => result.status === 'rejected', + ); + + if (hasError) { + setError('Unable to delete a todo'); + } + }); + }; + + const handleToggleTodo = (todo: Todo) => { + setError(''); + startProcessing(todo.id); + + return updateTodo(todo.id, { + completed: !todo.completed, + }) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(currentTodo => + currentTodo.id === todo.id + ? updatedTodo + : currentTodo, + ), + ); + }) + .catch(() => { + setError('Unable to update a todo'); + throw new Error('Unable to update a todo'); + }) + .finally(() => { + stopProcessing(todo.id); + focusNewTodoField(); + }); + }; + + const handleToggleAll = () => { + const shouldComplete = !todos.every(todo => todo.completed); + + const todosToUpdate = todos.filter( + todo => todo.completed !== shouldComplete, + ); + + setError(''); + + todosToUpdate.forEach(todo => { + startProcessing(todo.id); + }); + + Promise.allSettled( + todosToUpdate.map(todo => + updateTodo(todo.id, { + completed: shouldComplete, + }) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(currentTodo => + currentTodo.id === updatedTodo.id + ? updatedTodo + : currentTodo, + ), + ); + }) + .finally(() => { + stopProcessing(todo.id); + }), + ), + ).then(results => { + const hasError = results.some( + result => result.status === 'rejected', + ); + + if (hasError) { + setError('Unable to update a todo'); + } + + focusNewTodoField(); + }); + }; + + const handleStartEditing = (todo: Todo) => { + setEditingTodoId(todo.id); + setEditingTitle(todo.title); + }; + + const cancelEditing = () => { + setEditingTodoId(null); + setEditingTitle(''); + }; + + const saveEditing = (todo: Todo) => { + const trimmedTitle = editingTitle.trim(); + + if (trimmedTitle === todo.title) { + cancelEditing(); + focusNewTodoField(); + + return; + } + + if (!trimmedTitle) { + handleDelete(todo.id) + .then(() => { + cancelEditing(); + }) + .catch(() => { + setError('Unable to delete a todo'); + }) + .finally(() => { + focusNewTodoField(); + }); + + return; + } + + setError(''); + startProcessing(todo.id); + + updateTodo(todo.id, { + title: trimmedTitle, + }) + .then(updatedTodo => { + setTodos(currentTodos => + currentTodos.map(currentTodo => + currentTodo.id === todo.id + ? updatedTodo + : currentTodo, + ), + ); + + cancelEditing(); + }) + .catch(() => { + setError('Unable to update a todo'); + }) + .finally(() => { + stopProcessing(todo.id); + focusNewTodoField(); + }); + }; + + const handleEditKeyUp = ( + event: KeyboardEvent, + ) => { + if (event.key === 'Escape') { + cancelEditing(); + focusNewTodoField(); + } + }; + 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 && ( +
+ +
+ {visibleTodos.map(todo => ( +
+ + + {editingTodoId === todo.id ? ( +
{ + event.preventDefault(); + saveEditing(todo); + }} + > + + setEditingTitle(event.target.value) + } + onBlur={() => saveEditing(todo)} + onKeyUp= {handleEditKeyUp} + + /> +
+ ) : ( + <> + + handleStartEditing(todo) + } + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ))} + + {tempTodo && ( +
+ + + + {tempTodo.title} + + + + +
+
+
+
+
+ )} +
+ + {todos.length > 0 && ( + + )} +
+ +
+
+
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..99b5e4d252 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,23 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4352; + +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 = ( + todoId: number, + data: Partial>, +) => { + return client.patch(`/todos/${todoId}`, 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..708ac4c17b --- /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(); + } + + 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'), +};