From 804f07c7eea2116dae3f92369e0be90d60d118f0 Mon Sep 17 00:00:00 2001 From: Kateryna Date: Tue, 10 Dec 2024 15:10:45 +0100 Subject: [PATCH] add react todo-app-with-api --- README.md | 2 +- src/App.tsx | 147 ++++++++++++++++-- src/api/todos.ts | 21 +++ .../ErrorNotification/ErrorNotification.tsx | 35 +++++ src/components/Footer/Footer.tsx | 68 ++++++++ src/components/TodoInput/TodoInput.tsx | 102 ++++++++++++ src/components/TodoItem/TodoItem.tsx | 31 ++++ src/components/TodoList/TodoList.tsx | 138 ++++++++++++++++ src/types/Filter.ts | 1 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 ++++++ 11 files changed, 583 insertions(+), 14 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification/ErrorNotification.tsx create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/TodoInput/TodoInput.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/types/Filter.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..91435a7d0d 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://moskkat40.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..14ac0b8736 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,147 @@ /* 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, { useEffect, useMemo, useState } from 'react'; import { UserWarning } from './UserWarning'; +import { USER_ID } from './api/todos'; +import * as servisesTodos from './api/todos'; +import { Todo } from './types/Todo'; +import { TodoInput } from './components/TodoInput/TodoInput'; +import { TodoList } from './components/TodoList/TodoList'; +import { ErrorNotification } from './components/ErrorNotification/ErrorNotification'; +import { Footer } from './components/Footer/Footer'; +import { Filter } from './types/Filter'; -const USER_ID = 0; +export enum FilterParam { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [filter, setFilter] = useState(FilterParam.All); + const [tempTodo, setTempTodo] = useState(null); + const [loadingIds, setLoadingIds] = useState([]); + + useEffect(() => { + servisesTodos + .getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage('Unable to load todos'); + }); + }, []); + + useEffect(() => { + setTimeout(() => { + setErrorMessage(''); + }, 3000); + }, [errorMessage]); + + const filteredTodos = useMemo(() => { + if (filter === FilterParam.Active) { + return todos.filter(todo => !todo.completed); + } + + if (filter === FilterParam.Completed) { + return todos.filter(todo => todo.completed); + } + + return todos; + }, [filter, todos]); + + const handleDeleteTodo = (todoId: number) => { + setLoadingIds(current => [...current, todoId]); + + return servisesTodos + .deleteTodo(todoId) + .then(() => { + setTodos(currentTodo => currentTodo.filter(todo => todo.id !== todoId)); + }) + .catch(error => { + setErrorMessage('Unable to delete a todo'); + throw error; + }) + .finally(() => { + setLoadingIds(current => current.filter(id => id !== todoId)); + }); + }; + + const handleUpdateTodo = ( + todoId: number, + newTitle: string, + completed: boolean, + ) => { + setLoadingIds(current => [...current, todoId]); + const todoToUpdate = todos.find(todo => todo.id === todoId); + + if (!todoToUpdate) { + return; + } + + const updatedTodo = { + ...todoToUpdate, + title: newTitle.trim(), + completed: completed, + }; + + return servisesTodos + .updateTodo(todoId, updatedTodo) + .then(() => + setTodos(currentTodo => + currentTodo.map(todo => (todo.id === todoId ? updatedTodo : todo)), + ), + ) + .catch(error => { + setErrorMessage('Unable to update a todo'); + throw error; + }) + .finally(() => { + setLoadingIds(current => current.filter(id => id !== todoId)); + }); + }; + 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 && ( +
+ )} +
+ +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..862b89d197 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,21 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 1913; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const postTodos = ({ title, userId, completed }: Omit) => { + return client.post(`/todos/`, { title, userId, completed }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todoId: number, todo: Todo) => { + return client.patch(`/todos/${todoId}`, todo); +}; +// Add more methods here diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..8c015be489 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,35 @@ +import classNames from 'classnames'; +import React from 'react'; + +type Props = { + errorMessage: string; + setErrorMessage: (a: string) => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + setErrorMessage, +}) => { + const handleCloseNotification = () => { + setErrorMessage(''); + }; + + return ( +
+
+ ); +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..f68a89d634 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,68 @@ +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; +import { Filter } from '../../types/Filter'; +import { FilterParam } from '../../App'; + +type Props = { + filter: string; + setFilter: (a: Filter) => void; + todos: Todo[]; + handleDeleteTodo: (a: number) => void; +}; + +export const Footer: React.FC = ({ + filter, + setFilter, + todos, + handleDeleteTodo, +}) => { + const handleFilter = (event: React.MouseEvent) => { + const filterValue = event.currentTarget.textContent as Filter; + + setFilter(filterValue); + }; + + const handleClearComplete = () => { + const completedTodos = todos.filter(todo => todo.completed); + const deletePromises = completedTodos.map(todo => + handleDeleteTodo(todo.id), + ); + + Promise.allSettled(deletePromises); + }; + + const amountActiveTodos = todos.filter(todo => !todo.completed).length; + + return ( +
+ + {amountActiveTodos} items left + + + + +
+ ); +}; diff --git a/src/components/TodoInput/TodoInput.tsx b/src/components/TodoInput/TodoInput.tsx new file mode 100644 index 0000000000..8e47cecb31 --- /dev/null +++ b/src/components/TodoInput/TodoInput.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from 'react'; +import * as servisesTodos from '../../api/todos'; +import { Todo } from '../../types/Todo'; +import classNames from 'classnames'; + +const userId = servisesTodos.USER_ID; + +type Props = { + setErrorMessage: (a: string) => void; + todos: Todo[]; + setTodos: (a: Todo[]) => void; + setTempTodo: (a: Todo[] | null) => void; + tempTodo: Todo | null; + handleUpdateTodo: (a: number, b: string, c: boolean) => void; + loadingIds: number[]; +}; + +export const TodoInput: React.FC = ({ + setErrorMessage, + todos, + setTodos, + setTempTodo, + tempTodo, + handleUpdateTodo, + loadingIds, +}) => { + const [title, setTitle] = useState(''); + const inputRef = useRef(null); + const AllCompletedTodo = todos.every(todo => todo.completed); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [todos, tempTodo]); + + const handleInputValue = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const handleAddTodo = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + setTempTodo({ id: 0, title: title.trim(), userId, completed: false }); + if (title.trim().length > 0) { + servisesTodos + .postTodos({ title: title.trim(), userId, completed: false }) + .then(newTodo => { + setTodos([...todos, newTodo]); + setTitle(''); + setTempTodo(null); + }) + .catch(() => { + setTempTodo(null); + setErrorMessage('Unable to add a todo'); + }); + } else { + setTempTodo(null); + setErrorMessage('Title should not be empty'); + } + } + }; + + const handleChangleAllStatus = () => { + const newCompletedStatus = !AllCompletedTodo; + const todosToUpdate = todos.filter( + todo => todo.completed !== newCompletedStatus, + ); + + todosToUpdate.map(todo => + handleUpdateTodo(todo.id, todo.title, newCompletedStatus), + ); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..6e60f05cca --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,31 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { Todo } from '../../types/Todo'; + +type Props = { + tempTodo: Todo | null; +}; + +export const TodoItem: React.FC = ({ tempTodo }) => { + return ( +
+ + + {tempTodo?.title} + + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..55350060e0 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,138 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { Todo } from '../../types/Todo'; +import classNames from 'classnames'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { useState } from 'react'; + +type Props = { + filteredTodos: Todo[]; + tempTodo: Todo | null; + handleDeleteTodo: (a: number) => Promise; + handleUpdateTodo: (a: number, b: string, c: boolean) => Promise; + loadingIds: number[]; +}; + +export const TodoList: React.FC = ({ + filteredTodos, + tempTodo, + handleDeleteTodo, + loadingIds, + handleUpdateTodo, +}) => { + const [isEditing, setIsEditing] = useState(null); + const [newTitle, setNewTitle] = useState(''); + + + const handleRenaimTodo = (id: number, title: string) => { + setIsEditing(id); + setNewTitle(title); + }; + + const handleNewTitle = (event: React.ChangeEvent) => { + setNewTitle(event.target.value); + }; + + const handleKeyDown = ( + event: React.KeyboardEvent, + id: number, + completed: boolean, + ) => { + if (event.key === 'Enter') { + setIsEditing(null); + if (newTitle.length === 0) { + handleDeleteTodo(id).catch(() => setIsEditing(id)); + } else { + handleUpdateTodo(id, newTitle, completed).catch(() => setIsEditing(id)); + } + } + + if (event.key === 'Escape') { + setIsEditing(null); + } + }; + + const handleBlur = (id: number, completed: boolean) => { + setIsEditing(null); + if (newTitle.length === 0) { + handleDeleteTodo(id).catch(() => setIsEditing(id)); + } else { + handleUpdateTodo(id, newTitle, completed).catch(() => setIsEditing(id)); + } + }; + + return ( +
+ {filteredTodos.map(({ title, id, completed }) => ( + <> +
+ + + {isEditing === id ? ( + handleKeyDown(event, id, completed)} + onChange={handleNewTitle} + onBlur={() => handleBlur(id, completed)} + autoFocus + /> + ) : ( + <> + handleRenaimTodo(id, title)} + > + {title} + + + + )} + +
+
+
+
+
+ + ))} + {tempTodo && } +
+ ); +}; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..868e3cba1e --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1 @@ +export type Filter = 'All' | 'Completed' | 'Active'; 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'), +};