diff --git a/README.md b/README.md index 47a1add059..1eac7f7ad9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ and implement the ability to toggle and rename todos. ## Toggling a todo status Toggle the `completed` status on `TodoStatus` change: + - Install Prettier Extention and use this [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. - covered the todo with a loader overlay while waiting for API response; - the status should be changed on success; @@ -38,6 +39,7 @@ Implement the ability to edit a todo title on double click: - or the deletion error message if we tried to delete the todo. ## If you want to enable tests + - open `cypress/integration/page.spec.js` - replace `describe.skip` with `describe` for the root `describe` @@ -47,4 +49,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://naviailpach.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..34b851d3cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,240 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import * as todoService from './api/todos'; +import { Filter } from './utils/Filter'; +import { Todo } from './types/todo'; +import { ErrorMessage } from './utils/ErrorMessage'; +import { ErrorNotification } from './components/ErrorNotification'; +import { TodoFooter } from './components/TodoFooter'; +import { TodoList } from './components/TodoList'; +import { TodoHeader } from './components/TodoHeader'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(ErrorMessage.Empty); + const [filter, setFilter] = useState(Filter.All); + const [query, setQuery] = useState(''); + const [tempTodo, setTempTodo] = useState(null); + + const [isLoading, setIsLoading] = useState(false); + const [loadingIds, setLoadingIds] = useState([]); + + useEffect(() => { + todoService + .getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage(ErrorMessage.Load); + }); + }, []); + + useEffect(() => { + if (!errorMessage) { + return; + } + + const timer = setTimeout(() => { + setErrorMessage(ErrorMessage.Empty); + }, 3000); + + return () => clearTimeout(timer); + }, [errorMessage]); + + const filteredTodos = todos.filter(todo => { + switch (filter) { + case 'active': + return !todo.completed; + + case 'completed': + return todo.completed; + + default: + return true; + } + }); + + const addTodo = ({ title, completed, userId }: Omit) => { + setTempTodo({ id: 0, title, completed, userId }); + setIsLoading(true); + + todoService + .createTodo({ title, completed, userId }) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + setQuery(''); + }) + .catch(() => { + setErrorMessage(ErrorMessage.Add); + }) + .finally(() => { + setTempTodo(null); + setIsLoading(false); + }); + }; + + const inputRef = useRef(null); + + useEffect(() => { + if (!isLoading) { + inputRef.current?.focus(); + } + }, [isLoading]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!query.trim()) { + setErrorMessage(ErrorMessage.TitleEmpty); + + return; + } + + addTodo({ + title: query.trim(), + completed: false, + userId: todoService.USER_ID, + }); + }; + + const deleteTodo = (todoId: number, onSuccess?: () => void) => { + setLoadingIds(current => [...current, todoId]); + + todoService + .deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + + onSuccess?.(); + }) + .catch(() => { + setErrorMessage(ErrorMessage.Delete); + }) + .finally(() => { + setLoadingIds(current => current.filter(id => id !== todoId)); + if (!onSuccess) { + inputRef.current?.focus(); + } + }); + }; + + const handleFilterChange = ( + event: React.MouseEvent, + value: Filter, + ) => { + event.preventDefault(); + setFilter(value); + }; + + const clearCompleted = () => { + const completedTodos = todos.filter(todo => todo.completed); + + setLoadingIds(current => [ + ...current, + ...completedTodos.map(todo => todo.id), + ]); + + Promise.allSettled( + completedTodos.map(todo => todoService.deleteTodo(todo.id)), + ).then(results => { + const failedInds = results.some(result => result.status === 'rejected'); + + if (failedInds) { + setErrorMessage(ErrorMessage.Delete); + } + + const successfulIds = results + .map((result, index) => + result.status === 'fulfilled' ? completedTodos[index].id : -1, + ) + .filter(id => id !== -1); + + setTodos(current => + current.filter(todo => !successfulIds.includes(todo.id)), + ); + + inputRef.current?.focus(); + }); + }; + + const updateTodo = (updatedTodo: Todo, onSuccess?: () => void) => { + setLoadingIds(current => [...current, updatedTodo.id]); + + todoService + .updateTodo(updatedTodo) + .then(todo => { + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = newTodos.findIndex(t => t.id === updatedTodo.id); + + newTodos.splice(index, 1, todo); + + return newTodos; + }); + + onSuccess?.(); + }) + .catch(() => { + setErrorMessage(ErrorMessage.Update); + }) + .finally(() => { + setLoadingIds(current => current.filter(id => id !== updatedTodo.id)); + }); + }; + + const toggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + const todosToToggle = todos.filter(todo => todo.completed === allCompleted); + + todosToToggle.forEach(todo => { + updateTodo({ ...todo, completed: !allCompleted }); + }); + }; + + if (!todoService.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 && ( + + )} +
+ + setErrorMessage(ErrorMessage.Empty)} + /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..d60761bda9 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,20 @@ +import { Todo } from '../types/todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4193; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const createTodo = ({ title, completed, userId }: Omit) => { + return client.post('/todos', { title, completed, userId }); +}; + +export const updateTodo = ({ id, title, completed, userId }: Todo) => { + return client.patch(`/todos/${id}`, { title, completed, userId }); +}; diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..17dc21e3ac --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,31 @@ +import classNames from 'classnames'; + +import React from 'react'; + +type Props = { + errorMessage: string; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + onClose, +}) => ( +
+
+); diff --git a/src/components/ErrorNotification/index.ts b/src/components/ErrorNotification/index.ts new file mode 100644 index 0000000000..612d424f31 --- /dev/null +++ b/src/components/ErrorNotification/index.ts @@ -0,0 +1 @@ +export { ErrorNotification } from './ErrorNotification'; diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 0000000000..c6b3b280cc --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,74 @@ +import classNames from 'classnames'; + +import React from 'react'; +import { Todo } from '../../types/todo'; +import { Filter } from '../../utils/Filter'; + +type Props = { + todos: Todo[]; + filter: Filter; + onFilterChange: ( + event: React.MouseEvent, + value: Filter, + ) => void; + clearCompleted: () => void; +}; + +export const TodoFooter: React.FC = ({ + todos, + filter, + onFilterChange, + clearCompleted, +}) => { + const filterLinks = [ + { label: 'All', value: Filter.All, href: '#/', dataCy: 'FilterLinkAll' }, + + { + label: 'Active', + value: Filter.Active, + href: '#/active', + dataCy: 'FilterLinkActive', + }, + + { + label: 'Completed', + value: Filter.Completed, + href: '#/completed', + dataCy: 'FilterLinkCompleted', + }, + ]; + + return ( +
+ + {todos.filter(t => !t.completed).length} items left + + + + + +
+ ); +}; diff --git a/src/components/TodoFooter/index.ts b/src/components/TodoFooter/index.ts new file mode 100644 index 0000000000..c05cfe1962 --- /dev/null +++ b/src/components/TodoFooter/index.ts @@ -0,0 +1 @@ +export { TodoFooter } from './TodoFooter'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 0000000000..03b065c47c --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,52 @@ +import classNames from 'classnames'; + +import React from 'react'; +import { Todo } from '../../types/todo'; + +type Props = { + todos: Todo[]; + query: string; + isLoading: boolean; + inputRef: React.RefObject; + onQueryChange: (value: string) => void; + onSubmit: (event: React.FormEvent) => void; + toggleAll: () => void; +}; + +export const TodoHeader: React.FC = ({ + todos, + query, + isLoading, + inputRef, + onQueryChange, + onSubmit, + toggleAll, +}) => ( +
+ {todos.length > 0 && ( +
+); diff --git a/src/components/TodoHeader/index.ts b/src/components/TodoHeader/index.ts new file mode 100644 index 0000000000..9a6486ad6f --- /dev/null +++ b/src/components/TodoHeader/index.ts @@ -0,0 +1 @@ +export { TodoHeader } from './TodoHeader'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..c99a162200 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,134 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; + +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../../types/todo'; + +type Props = { + todo: Todo; + isLoading?: boolean; + onDelete?: (value: number, onSuccess?: () => void) => void; + updateTodo?: (updatedTodo: Todo, onSucces?: () => void) => void; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading = false, + onDelete, + updateTodo, +}) => { + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + + const handleEdit = (event?: React.FormEvent) => { + event?.preventDefault(); + + if (editingId === null) { + return; + } + + if (!editTitle.trim()) { + onDelete?.(todo.id, () => setEditingId(null)); + + return; + } + + if (editTitle.trim() === todo.title) { + setEditingId(null); + + return; + } + + updateTodo?.({ ...todo, title: editTitle.trim() }, () => { + setEditingId(null); + }); + }; + + const editInputRef = useRef(null); + + useEffect(() => { + if (editingId === todo.id) { + editInputRef.current?.focus(); + } + }, [editingId, todo.id]); + + const doubleClick = () => { + setEditingId(todo.id); + setEditTitle(todo.title); + }; + + return ( +
+ + + {editingId === todo.id ? ( +
+ { + setEditTitle(event.target.value); + }} + onBlur={() => handleEdit()} + onKeyUp={event => { + if (event.key === 'Escape') { + setEditTitle(todo.title); + setEditingId(null); + } + }} + /> +
+ ) : ( + + {todo.title} + + )} + + {editingId !== todo.id && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 0000000000..e8da085412 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export { TodoItem } from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..9268aaf1c5 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Todo } from '../../types/todo'; +import { TodoItem } from '../TodoItem'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + loadingIds: number[]; + onDelete: (value: number) => void; + updateTodo: (updateTodo: Todo) => void; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + loadingIds, + onDelete, + updateTodo, +}) => ( +
+ {todos.map(todo => ( + + ))} + {tempTodo && } +
+); diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 0000000000..d0c1712ba4 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export { TodoList } from './TodoList'; 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/ErrorMessage.ts b/src/utils/ErrorMessage.ts new file mode 100644 index 0000000000..de544ad3cb --- /dev/null +++ b/src/utils/ErrorMessage.ts @@ -0,0 +1,8 @@ +export enum ErrorMessage { + TitleEmpty = 'Title should not be empty', + Load = 'Unable to load todos', + Add = 'Unable to add a todo', + Delete = 'Unable to delete a todo', + Update = 'Unable to update a todo', + Empty = '', +} diff --git a/src/utils/Filter.ts b/src/utils/Filter.ts new file mode 100644 index 0000000000..174408fd69 --- /dev/null +++ b/src/utils/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..33b2e0bd2a --- /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'), +};