diff --git a/README.md b/README.md index 47a1add059..a07114eecf 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://yana-karpovych.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..18f036af9e 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", @@ -38,7 +38,7 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.34.4", "eslint-plugin-react-hooks": "^4.6.2", - "gh-pages": "^6.1.1", + "gh-pages": "^6.3.0", "mochawesome": "^7.1.3", "mochawesome-merge": "^4.3.0", "mochawesome-report-generator": "^6.2.0", @@ -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", @@ -2660,15 +2661,6 @@ "node": ">=8" } }, - "node_modules/array-uniq": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", - "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", @@ -5434,18 +5426,19 @@ } }, "node_modules/gh-pages": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.1.1.tgz", - "integrity": "sha512-upnohfjBwN5hBP9w2dPE7HO5JJTHzSGMV1JrLrHvNuqmjoYHg6TBrCcnEoorjG/e0ejbuvnwyKMdTyM40PEByw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.3.0.tgz", + "integrity": "sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==", "dev": true, + "license": "MIT", "dependencies": { "async": "^3.2.4", - "commander": "^11.0.0", + "commander": "^13.0.0", "email-addresses": "^5.0.0", "filenamify": "^4.3.0", "find-cache-dir": "^3.3.1", "fs-extra": "^11.1.1", - "globby": "^6.1.0" + "globby": "^11.1.0" }, "bin": { "gh-pages": "bin/gh-pages.js", @@ -5455,35 +5448,14 @@ "node": ">=10" } }, - "node_modules/gh-pages/node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "dev": true, - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/gh-pages/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/gh-pages/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/gh-pages/node_modules/fs-extra": { @@ -5500,55 +5472,6 @@ "node": ">=14.14" } }, - "node_modules/gh-pages/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gh-pages/node_modules/globby": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", - "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", - "dev": true, - "dependencies": { - "array-union": "^1.0.1", - "glob": "^7.0.3", - "object-assign": "^4.0.1", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/gh-pages/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -8330,27 +8253,6 @@ "node": ">=0.10.0" } }, - "node_modules/pinkie": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinkie-promise": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", - "dev": true, - "dependencies": { - "pinkie": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", diff --git a/package.json b/package.json index b6062525ab..e8272c4051 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", @@ -34,7 +34,7 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.34.4", "eslint-plugin-react-hooks": "^4.6.2", - "gh-pages": "^6.1.1", + "gh-pages": "^6.3.0", "mochawesome": "^7.1.3", "mochawesome-merge": "^4.3.0", "mochawesome-report-generator": "^6.2.0", diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..f29bdf96cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,304 @@ -/* 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 React, { useCallback, useEffect, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { + USER_ID, + getTodoError, + todosService, + TodosServiceError, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { TodoItem } from './components/TodoItem'; +import { ErrorNotification } from './components/ErrorNotification'; +import { useErrorMessage } from './hooks/useErrorMessage'; +import { StatusFilter, TodoStatus } from './components/StatusFilter'; +import cn from 'classnames'; +// import { use } from 'chai'; +import { AddTodoForm, AddTodoFormData } from './components/AddTodoForm'; +import { TodoCreate } from './types/TodoCreate'; +import { TodoUpdate } from './types/Todo.Update'; +// import { has } from 'cypress/types/lodash'; -const USER_ID = 0; +function getFilteredTodos(todos: Todo[], { status }: { status: TodoStatus }) { + let filteredTodos = todos; + + if (status !== TodoStatus.All) { + filteredTodos = filteredTodos.filter(todo => { + switch (status) { + case TodoStatus.Completed: + return todo.completed; + case TodoStatus.Active: + return !todo.completed; + default: + throw new Error('Missing case in getFilteredTodos status filter'); + } + }); + } + + return filteredTodos; +} export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [statusFilter, setStatusFilter] = useState(TodoStatus.All); + const [isCreatingTodo, setIsCreatingTodo] = useState(false); + const newTodoTitleRef = useRef(null); + + const { error, setError, resetErrorMessage } = useErrorMessage(); + + const showTodosAndFooter = todos.length > 0; + const showToggleAllButton = todos.length > 0; + + const filteredTodos = getFilteredTodos(todos, { status: statusFilter }); + + const completedTodos = todos.filter(todo => todo.completed); + const activeTodos = todos.filter(todo => !todo.completed); + const activeTodosCount = activeTodos.length; + + const allCompleted = + todos.length > 0 && completedTodos.length === todos.length; + const hasCompleted = completedTodos.length > 0; + + const isTodoLoading = useCallback( + (todoId: number) => loadingTodoIds.includes(todoId), + [loadingTodoIds], + ); + + const handleAddToLoading = useCallback((todoId: number) => { + setLoadingTodoIds(current => [...current, todoId]); + }, []); + + const handleRemoveFromLoading = useCallback((todoId: number) => { + setLoadingTodoIds(current => current.filter(id => id !== todoId)); + }, []); + + const handleDeleteTodo = useCallback( + (todoId: number) => { + handleAddToLoading(todoId); + todosService + .delete(todoId) + .then(() => { + setTodos(current => current.filter(todo => todo.id !== todoId)); + }) + .catch(() => { + setError(getTodoError(TodosServiceError.UnableToDeleteATodo)); + }) + .finally(() => { + handleRemoveFromLoading(todoId); + newTodoTitleRef.current?.focus(); + }); + }, + [newTodoTitleRef, handleAddToLoading, handleRemoveFromLoading, setError], + ); + + const handleClearComplete = useCallback(() => { + completedTodos.forEach(todo => { + handleDeleteTodo(todo.id); + }); + }, [completedTodos, handleDeleteTodo]); + + const handleCreateTodo = useCallback( + ( + values: AddTodoFormData, + { + onSuccess, + onError, + }: { + onSuccess?: (createTodoDto: Todo) => void; + onError?: () => void; + } = {}, + ) => { + if (newTodoTitleRef.current) { + newTodoTitleRef.current.focus(); + } + + const createTodoDto: TodoCreate = { + title: values.title, + userId: USER_ID, + completed: false, + }; + + setTempTodo({ + id: 0, + ...createTodoDto, + }); + + setIsCreatingTodo(true); + + todosService + .create(createTodoDto) + .then(createdTodo => { + setTempTodo(null); + setTodos(current => [...current, createdTodo]); + + onSuccess?.(createdTodo); + }) + .catch(() => { + setTempTodo(null); + setError(getTodoError(TodosServiceError.UnableToAddATodo)); + + onError?.(); + }) + .finally(() => { + setIsCreatingTodo(false); + + if (newTodoTitleRef.current) { + newTodoTitleRef.current.disabled = false; + } + + newTodoTitleRef.current?.focus(); + }); + }, + [newTodoTitleRef, setError], + ); + + const handleUpdateTodo = useCallback( + ( + todoId: number, + data: TodoUpdate, + { + onSuccess, + onError, + }: { onSuccess?: (updatedTodo: Todo) => void; onError?: () => void } = {}, + ) => { + handleAddToLoading(todoId); + + todosService + .update(todoId, data) + .then(updatedTodo => { + setTodos(current => + current.map(todo => { + return todo.id === updatedTodo.id ? updatedTodo : todo; + }), + ); + + onSuccess?.(updatedTodo); + }) + .catch(() => { + setError(getTodoError(TodosServiceError.UnableToUpdateATodo)); + + onError?.(); + }) + .finally(() => { + handleRemoveFromLoading(todoId); + }); + }, + [handleAddToLoading, handleRemoveFromLoading, setError], + ); + + const handleBulkToggleStatus = useCallback(() => { + if (activeTodos.length !== 0) { + activeTodos.forEach(({ id, ...todo }) => { + handleUpdateTodo(id, { + title: todo.title, + userId: todo.userId, + completed: !todo.completed, + }); + }); + + return; + } + + todos.forEach(({ id, ...todo }) => { + handleUpdateTodo(id, { + title: todo.title, + userId: todo.userId, + completed: !todo.completed, + }); + }); + }, [todos, activeTodos, handleUpdateTodo]); + + useEffect(() => { + resetErrorMessage(); + setLoading(true); + + todosService + .list() + .then((todosFromServer: Todo[]) => setTodos(todosFromServer)) + .catch(() => { + setError(getTodoError(TodosServiceError.UnableToLoadTodos)); + }) + .finally(() => setLoading(false)); + }, [resetErrorMessage, setError]); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ {showToggleAllButton && ( +
+ + {showTodosAndFooter && ( + <> +
+ {filteredTodos.map(todo => ( + + ))} + {tempTodo && ( + undefined} /> + )} +
+ + {todos.length > 0 && ( +
+ + {activeTodosCount} items left + + + + + +
+ )} + + )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..23a379fd6f --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,35 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; +import { TodoCreate } from '../types/TodoCreate'; +import { TodoUpdate } from '../types/Todo.Update'; + +export const USER_ID = 4231; + +export const todosService = { + list: () => client.get(`/todos?userId=${USER_ID}`), + create: (data: TodoCreate) => client.post(`/todos`, data), + delete: (todoId: number) => client.delete(`/todos/${todoId}`), + update: (todoId: number, data: TodoUpdate) => + client.patch(`/todos/${todoId}`, data), +}; + +export enum TodosServiceError { + UnableToLoadTodos = 'todos_service_unable_to_load_todos', + TitleShouldNotBeEmpty = 'todos_service_title_should_not_be_empty', + UnableToAddATodo = 'todos_service_unable_to_add_a_todo', + UnableToDeleteATodo = 'todos_service_unable_to_delete_a_todo', + UnableToUpdateATodo = 'todos_service_unable_to_update_a_todo', +} + +export const TODOS_ERROR_MESSAGES: Record = { + [TodosServiceError.UnableToLoadTodos]: 'Unable to load todos', + [TodosServiceError.TitleShouldNotBeEmpty]: 'Title should not be empty', + [TodosServiceError.UnableToAddATodo]: 'Unable to add a todo', + [TodosServiceError.UnableToDeleteATodo]: 'Unable to delete a todo', + [TodosServiceError.UnableToUpdateATodo]: 'Unable to update a todo', +}; + +export function getTodoError(errorKey: TodosServiceError): string { + return TODOS_ERROR_MESSAGES[errorKey]; +} +// Add more methods here diff --git a/src/components/AddTodoForm.tsx b/src/components/AddTodoForm.tsx new file mode 100644 index 0000000000..e62f5f5cb1 --- /dev/null +++ b/src/components/AddTodoForm.tsx @@ -0,0 +1,66 @@ +import { FormEventHandler, forwardRef, useCallback, useState } from 'react'; +import { getTodoError, TodosServiceError } from '../api/todos'; +import { Todo } from '../types/Todo'; + +export type AddTodoFormData = { + title: string; +}; + +type AddTodoFormProps = { + onSubmit: ( + value: AddTodoFormData, + callbacks?: { + onSuccess?: (createTodoDto: Todo) => void; + onError?: () => void; + }, + ) => void; + onError: (notification: string) => void; + loading: boolean; +}; + +export const AddTodoForm = forwardRef( + ({ onSubmit, onError, loading }, ref) => { + const [newTodoTitle, setNewTodoTitle] = useState(''); + + const clearTodoTitle = useCallback(() => setNewTodoTitle(''), []); + + const handleSubmit: FormEventHandler = event => { + event.preventDefault(); + + const preparedNewTodoTitle = newTodoTitle.trim(); + + if (preparedNewTodoTitle === '') { + onError(getTodoError(TodosServiceError.TitleShouldNotBeEmpty)); + + return; + } + + onSubmit( + { title: preparedNewTodoTitle }, + { + onSuccess: () => { + clearTodoTitle(); + }, + }, + ); + }; + + return ( +
+ setNewTodoTitle(event.target.value.trimStart())} + /> +
+ ); + }, +); + +AddTodoForm.displayName = 'AddTodoForm'; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..1f8a306a7e --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,31 @@ +import cn from 'classnames'; + +type ErrorNotificationProps = { + notification: string; + onClose: () => void; +}; + +export function ErrorNotification({ + notification, + onClose, +}: ErrorNotificationProps) { + const hideNotification = notification === ''; + + return ( +
+
+ ); +} diff --git a/src/components/RenameTodoForm.tsx b/src/components/RenameTodoForm.tsx new file mode 100644 index 0000000000..40db91da7b --- /dev/null +++ b/src/components/RenameTodoForm.tsx @@ -0,0 +1,63 @@ +import { FormEventHandler, KeyboardEventHandler, useState } from 'react'; + +type RenameTodoFormProps = { + defaultValue: string; + onSubmit: (value: string) => void; + onClose: () => void; + onDelete: () => void; +}; + +export function RenameTodoForm({ + defaultValue, + onSubmit, + onClose, + onDelete, +}: RenameTodoFormProps) { + const [newTitle, setNewTitle] = useState(defaultValue); + + const handleProcessNewTitle = () => { + const preparedNewTitle = newTitle.trim(); + + if (preparedNewTitle === defaultValue) { + onClose(); + + return; + } + + if (preparedNewTitle === '') { + onDelete(); + + return; + } + + onSubmit(preparedNewTitle); + }; + + const handleSubmit: FormEventHandler = event => { + event.preventDefault(); + + handleProcessNewTitle(); + }; + + const handleKeyUp: KeyboardEventHandler = event => { + if (event.key === 'Escape') { + onClose(); + } + }; + + return ( +
+ setNewTitle(event.target.value)} + autoFocus + onBlur={handleProcessNewTitle} + onKeyUp={handleKeyUp} + /> +
+ ); +} diff --git a/src/components/StatusFilter.tsx b/src/components/StatusFilter.tsx new file mode 100644 index 0000000000..ad3e0e0cb1 --- /dev/null +++ b/src/components/StatusFilter.tsx @@ -0,0 +1,51 @@ +import cn from 'classnames'; + +export enum TodoStatus { + All = 'all', + Active = 'active', + Completed = 'completed', +} + +type StatusFilterProps = { + value: TodoStatus; + onValueChange: (value: TodoStatus) => void; +}; + +export function StatusFilter({ value, onValueChange }: StatusFilterProps) { + return ( + + ); +} diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..0bf55273fe --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,120 @@ +import { Todo } from '../types/Todo'; +import cn from 'classnames'; +import { TodoUpdate } from '../types/Todo.Update'; +import { useCallback, useState } from 'react'; +import { RenameTodoForm } from './RenameTodoForm'; + +type TodoProps = { + todo: Todo; + loading: boolean; + onDelete?: (todoId: number) => void; + onUpdate?: ( + todoId: number, + data: TodoUpdate, + callbacks?: { + onSuccess?: (updatedTodo: Todo) => void; + onError?: () => void; + }, + ) => void; +}; + +export function TodoItem({ + todo, + loading = false, + onDelete = () => undefined, + onUpdate = () => undefined, +}: TodoProps) { + const [renaming, setRenaming] = useState(false); + + const handleToggleStatus = useCallback( + (completed: boolean) => { + const { id, ...updatedTodo }: Todo = { + ...todo, + completed, + }; + + onUpdate(id, { + title: updatedTodo.title, + completed: updatedTodo.completed, + userId: updatedTodo.userId, + }); + }, + [todo, onUpdate], + ); + + const handleRenameTodo = useCallback( + (title: string) => { + const { id, ...updatedTodo }: Todo = { + ...todo, + title, + }; + + onUpdate( + id, + { + title: updatedTodo.title, + completed: updatedTodo.completed, + userId: updatedTodo.userId, + }, + { + onSuccess: () => { + setRenaming(false); + }, + }, + ); + }, + [todo, onUpdate], + ); + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {renaming ? ( + setRenaming(false)} + onDelete={() => onDelete(todo.id)} + /> + ) : ( + <> + setRenaming(true)} + > + {todo.title} + + + + + )} + +
+
+
+
+
+ ); +} diff --git a/src/hooks/useErrorMessage.ts b/src/hooks/useErrorMessage.ts new file mode 100644 index 0000000000..2d88e8f411 --- /dev/null +++ b/src/hooks/useErrorMessage.ts @@ -0,0 +1,25 @@ +import { useState, useCallback, useEffect } from 'react'; + +export function useErrorMessage() { + const [error, setError] = useState(''); + + const handleResetErrorMessage = useCallback(() => { + setError(''); + }, []); + + useEffect(() => { + const timeoutID = setTimeout(() => { + handleResetErrorMessage(); + }, 3000); + + return () => { + clearTimeout(timeoutID); + }; + }, [error, handleResetErrorMessage]); + + return { + error, + setError, + resetErrorMessage: handleResetErrorMessage, + }; +} diff --git a/src/types/Todo.Update.ts b/src/types/Todo.Update.ts new file mode 100644 index 0000000000..8a8670d5aa --- /dev/null +++ b/src/types/Todo.Update.ts @@ -0,0 +1,5 @@ +export type TodoUpdate = { + userId: number; + title: string; + completed: boolean; +}; 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/TodoCreate.ts b/src/types/TodoCreate.ts new file mode 100644 index 0000000000..5a6321c696 --- /dev/null +++ b/src/types/TodoCreate.ts @@ -0,0 +1,5 @@ +export type TodoCreate = { + title: string; + userId: number; + completed: boolean; +}; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..a48e5e76bf --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,48 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; + +// https://mate.academy/students-api/todos?userId=4231 + +// 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'), +}; diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b..9372525dda 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,5 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + base: '/react_todo-app-with-api/', })