From 81db3ef33c57def4797806c33301b0526a43bec1 Mon Sep 17 00:00:00 2001 From: OkMoroz Date: Mon, 16 Dec 2024 19:19:34 +0200 Subject: [PATCH 1/5] added start codes --- README.md | 4 +- src/App.tsx | 156 ++++++++++++++++++++++++++++++++---- src/api/todos.ts | 16 ++++ src/components/Errors.tsx | 39 +++++++++ src/components/Footer.tsx | 61 ++++++++++++++ src/components/Header.tsx | 84 +++++++++++++++++++ src/components/TodoItem.tsx | 58 ++++++++++++++ src/components/TodoList.tsx | 34 ++++++++ src/types/Errors.ts | 7 ++ src/types/Status.ts | 5 ++ src/types/Todo.ts | 6 ++ src/utils/fetchClient.ts | 42 ++++++++++ 12 files changed, 496 insertions(+), 16 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Errors.tsx create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/types/Errors.ts create mode 100644 src/types/Status.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index d3c3756ab9..c4ad68b56f 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://OkMoroz.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..28ac77af56 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,152 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { UserWarning } from './UserWarning'; +import { addTodo, deleteTodo, getTodos, USER_ID } from './api/todos'; -const USER_ID = 0; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { Errors } from './components/Errors'; + +import { Todo } from './types/Todo'; +import { Status } from './types/Status'; +import { ErrorMessage } from './types/Errors'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [filteredStatus, setFilteredStatus] = useState(Status.All); + const [errorMessage, setErrorMessage] = useState(''); + const [tempoTodo, setTempoTodo] = useState(null); + const [title, setTitle] = useState(''); + const [deletedTodo, setDeletedTodo] = useState(NaN); + const [isDeleteCompleted, setIsDeleteCompleted] = useState(false); + + useEffect(() => { + const timeoutId = setTimeout(() => setErrorMessage(''), 3000); + + getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage(ErrorMessage.UnableToLoad); + clearTimeout(timeoutId); + }); + + return () => clearTimeout(timeoutId); + }, []); + + const handleAdd = useCallback((newTodo: Todo) => { + setTempoTodo(newTodo); + let todosLength = 0; + + addTodo(newTodo) + .then(todo => { + setTodos(currentTodos => [...currentTodos, todo]); + todosLength = 1; + }) + .catch(() => { + setErrorMessage(ErrorMessage.UnableToAdd); + }) + .finally(() => { + if (todosLength === 1) { + setTitle(''); + } + + setTempoTodo(null); + }); + }, []); + + const handleDeleteCompleted = () => { + setIsDeleteCompleted(true); + const completedTodos = todos.filter(todo => todo.completed); + + Promise.allSettled( + completedTodos.map(todo => deleteTodo(todo.id).then(() => todo)), + ) + .then(values => { + values.forEach(val => { + if (val.status === 'rejected') { + setErrorMessage(ErrorMessage.UnableToDelete); + } else { + setTodos(currentTodos => { + const todoId = val.value as Todo; + + return currentTodos.filter(todo => todo.id !== todoId.id); + }); + } + }); + }) + .finally(() => setIsDeleteCompleted(false)); + }; + + const handleDelete = (todoId: number) => { + setDeletedTodo(todoId); + + deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessage.UnableToDelete); + }) + .finally(() => setDeletedTodo(NaN)); + }; + + const filteredTodos = todos.filter(todo => { + if (filteredStatus === Status.Active) { + return !todo.completed; + } + + if (filteredStatus === Status.Completed) { + return todo.completed; + } + + return true; + }); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + 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 && ( +
+ )} +
+ + setErrorMessage('')} /> +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..3f39c3c633 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,16 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 2132; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (data: Omit) => { + return client.post('/todos', data); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; diff --git a/src/components/Errors.tsx b/src/components/Errors.tsx new file mode 100644 index 0000000000..a7342f7f5b --- /dev/null +++ b/src/components/Errors.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from 'react'; +import cn from 'classnames'; + +type Props = { + message: string; + clearError: () => void; +}; + +export const Errors: React.FC = props => { + const { message, clearError } = props; + + useEffect(() => { + const timeOut = setTimeout(clearError, 3000); + + return () => { + clearTimeout(timeOut); + }; + }, [message]); + + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..528b6f0b17 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import cn from 'classnames'; +import { Status } from '../types/Status'; +import { Todo } from '../types/Todo'; + +type Props = { + todos: Todo[]; + filteredStatus: Status; + onFilteredStatus: (stat: Status) => void; + todosCount: number; + isDeleteCompleted: boolean; + onDeleteCompleted: () => void; +}; + +export const Footer: React.FC = props => { + const { + todos, + filteredStatus, + onFilteredStatus, + todosCount, + isDeleteCompleted, + onDeleteCompleted, + } = props; + + const allStatus = Object.values(Status); + const isCompleted = todos.some(todo => todo.completed); + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..68347e8ae3 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useRef } from 'react'; +import { Todo } from '../types/Todo'; +import { USER_ID } from '../api/todos'; + +type Props = { + handleAdd: (newTodo: Todo) => void; + setErrorMessage: (message: string) => void; + todosLength: number; + title: string; + onChangeTitle: (value: string) => void; + isDeleteCompleted: boolean; + deletedTodo: number; + tempoTodo: Todo | null; +}; + +export const Header: React.FC = props => { + const { + title, + onChangeTitle, + handleAdd, + setErrorMessage, + todosLength, + isDeleteCompleted, + deletedTodo, + tempoTodo, + } = props; + + const inputRef = useRef(null); + + useEffect(() => { + if ( + inputRef.current && + tempoTodo === null && + Object.is(deletedTodo, NaN) && + isDeleteCompleted === false + ) { + inputRef.current.focus(); + } + }, [todosLength, tempoTodo, deletedTodo, isDeleteCompleted]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + setErrorMessage('Title should not be empty'); + + return; + } + + const newTodo = { + id: 0, + userId: USER_ID, + title: title.trim(), + completed: false, + }; + + handleAdd(newTodo); + }; + + return ( + <> +
+
+ + ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..639af48987 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,58 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { Todo } from '../types/Todo'; +import cn from 'classnames'; + +type Props = { + todo: Todo; + deletedTodo?: number; + isDeleteCompleted?: boolean; + isTempoTodo: boolean; + onDelete: (todoId: number) => void; +}; + +export const TodoItem: React.FC = props => { + const { todo, deletedTodo, isDeleteCompleted, isTempoTodo, onDelete } = props; + + return ( + <> +
+ + + + {todo.title} + + + + +
+
+
+
+
+ + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..9252b49d52 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,34 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { TodoItem } from '../components/TodoItem'; +import { Todo } from '../types/Todo'; + +type Props = { + todos: Todo[]; + tempoTodo: Todo | null; + deletedTodo: number; + isDeleteCompleted: boolean; + onDelete: (todoId: number) => void; +}; +export const TodoList: React.FC = props => { + const { todos, tempoTodo, deletedTodo, isDeleteCompleted, onDelete } = props; + + return ( +
+ {todos.map(todo => ( + + ))} + {tempoTodo && ( + + )} +
+ ); +}; diff --git a/src/types/Errors.ts b/src/types/Errors.ts new file mode 100644 index 0000000000..81eb357f0f --- /dev/null +++ b/src/types/Errors.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + UnableToLoad = 'Unable to load todos', + EmptyTitle = 'Title should not be empty', + UnableToAdd = 'Unable to add a todo', + UnableToDelete = 'Unable to delete a todo', + UnableToUpdate = 'Unable to update a todo', +} diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 0000000000..30ad355656 --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + Active = 'Active', + Completed = 'Completed', + All = 'All', +} 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'), +}; From 8bca719984a602b715aa18a4fbdcb4e57a38ba88 Mon Sep 17 00:00:00 2001 From: OkMoroz Date: Mon, 16 Dec 2024 23:25:27 +0200 Subject: [PATCH 2/5] added solutions --- src/App.tsx | 156 ++++++++++++++++++-------------- src/api/todos.ts | 4 + src/components/Footer.tsx | 44 +++++---- src/components/Header.tsx | 89 +++++++++++-------- src/components/TodoItem.tsx | 172 +++++++++++++++++++++++++++--------- src/components/TodoList.tsx | 31 ++++--- src/types/Filters.ts | 5 ++ src/types/Loading.ts | 3 + src/utils/filteredClent.ts | 18 ++++ src/utils/filteredTodos.ts | 18 ++++ src/utils/loadingObject.ts | 11 +++ 11 files changed, 371 insertions(+), 180 deletions(-) create mode 100644 src/types/Filters.ts create mode 100644 src/types/Loading.ts create mode 100644 src/utils/filteredClent.ts create mode 100644 src/utils/filteredTodos.ts create mode 100644 src/utils/loadingObject.ts diff --git a/src/App.tsx b/src/App.tsx index 28ac77af56..d0b6f545f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,26 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { UserWarning } from './UserWarning'; -import { addTodo, deleteTodo, getTodos, USER_ID } from './api/todos'; +import React, { useState, useEffect } from 'react'; +import { addTodo, deleteTodo, getTodos, updateTodo } from './api/todos'; +// import { todos } from './api/todos'; import { Header } from './components/Header'; import { TodoList } from './components/TodoList'; import { Footer } from './components/Footer'; import { Errors } from './components/Errors'; +import { loadingObject } from './utils/loadingObject'; +import { filteredTodos } from './utils/filteredTodos'; + import { Todo } from './types/Todo'; -import { Status } from './types/Status'; import { ErrorMessage } from './types/Errors'; +import { Loading } from './types/Loading'; +import { Filters } from './types/Filters'; export const App: React.FC = () => { const [todos, setTodos] = useState([]); - const [filteredStatus, setFilteredStatus] = useState(Status.All); const [errorMessage, setErrorMessage] = useState(''); - const [tempoTodo, setTempoTodo] = useState(null); - const [title, setTitle] = useState(''); - const [deletedTodo, setDeletedTodo] = useState(NaN); - const [isDeleteCompleted, setIsDeleteCompleted] = useState(false); + const [tempTodo, setTempTodo] = useState(null); + const [filter, setFilter] = useState(Filters.All); + const [loadingId, setLoadingId] = useState({}); useEffect(() => { const timeoutId = setTimeout(() => setErrorMessage(''), 3000); @@ -33,36 +35,84 @@ export const App: React.FC = () => { return () => clearTimeout(timeoutId); }, []); - const handleAdd = useCallback((newTodo: Todo) => { - setTempoTodo(newTodo); - let todosLength = 0; + const handleAdd = (newTodo: Todo): Promise => { + setTempTodo(newTodo); + + return addTodo(newTodo).then(newTodoRes => { + setTodos(currentTodos => [...currentTodos, newTodoRes]); + }); + }; + + const updateCompleted = ( + updatedTodo: Todo, + key: keyof Todo, + value: boolean | string, + ) => { + return updateTodo({ ...updatedTodo, [key]: value }) + .then((updatedTodoFromServer: Todo) => { + setTodos(currentTodos => { + return currentTodos.map(todo => + todo.id === updatedTodo.id ? updatedTodoFromServer : todo, + ); + }); - addTodo(newTodo) - .then(todo => { - setTodos(currentTodos => [...currentTodos, todo]); - todosLength = 1; + return false; }) .catch(() => { - setErrorMessage(ErrorMessage.UnableToAdd); - }) - .finally(() => { - if (todosLength === 1) { - setTitle(''); - } + setErrorMessage(ErrorMessage.UnableToUpdate); - setTempoTodo(null); + return true; }); - }, []); + }; + + const handleToggleAll = () => { + const activeTodos = todos.filter(todo => !todo.completed); + const activeTodosIds = loadingObject(activeTodos); + + if (activeTodos.length) { + setLoadingId(activeTodosIds); + + Promise.all( + activeTodos.map(todo => updateTodo({ ...todo, completed: true })), + ) + .then(() => + setTodos(currentTodos => { + return currentTodos.map(todo => { + if (Object.hasOwn(activeTodosIds, todo.id)) { + return { ...todo, completed: true }; + } else { + return todo; + } + }); + }), + ) + .catch(() => setErrorMessage(ErrorMessage.UnableToUpdate)) + .finally(() => setLoadingId({})); + + return; + } + + setLoadingId(loadingObject(todos)); + Promise.all(todos.map(todo => updateTodo({ ...todo, completed: false }))) + .then(() => + setTodos(prevTodos => { + return prevTodos.map(todo => ({ ...todo, completed: false })); + }), + ) + .catch(() => setErrorMessage(ErrorMessage.UnableToUpdate)) + .finally(() => setLoadingId({})); + }; const handleDeleteCompleted = () => { - setIsDeleteCompleted(true); const completedTodos = todos.filter(todo => todo.completed); + setLoadingId(loadingObject(completedTodos)); + Promise.allSettled( completedTodos.map(todo => deleteTodo(todo.id).then(() => todo)), ) .then(values => { - values.forEach(val => { + values.map(val => { if (val.status === 'rejected') { setErrorMessage(ErrorMessage.UnableToDelete); } else { @@ -74,13 +124,11 @@ export const App: React.FC = () => { } }); }) - .finally(() => setIsDeleteCompleted(false)); + .finally(() => setLoadingId({})); }; - const handleDelete = (todoId: number) => { - setDeletedTodo(todoId); - - deleteTodo(todoId) + const handleDelete = (todoId: number): Promise => { + return deleteTodo(todoId) .then(() => { setTodos(currentTodos => currentTodos.filter(todo => todo.id !== todoId), @@ -89,58 +137,36 @@ export const App: React.FC = () => { .catch(() => { setErrorMessage(ErrorMessage.UnableToDelete); }) - .finally(() => setDeletedTodo(NaN)); + .finally(() => setTempTodo(null)); }; - const filteredTodos = todos.filter(todo => { - if (filteredStatus === Status.Active) { - return !todo.completed; - } - - if (filteredStatus === Status.Completed) { - return todo.completed; - } - - return true; - }); - - const activeTodosCount = todos.filter(todo => !todo.completed).length; - - if (!USER_ID) { - return ; - } - return (

todos

{todos.length > 0 && (