From 8ddf254a4f5a527e4f7cd1114f246e69558c0e8e Mon Sep 17 00:00:00 2001 From: AsyaDev <142207257+AsyaDev14@users.noreply.github.com> Date: Sun, 22 Dec 2024 23:42:22 +0200 Subject: [PATCH 1/2] final version --- package-lock.json | 8 +- package.json | 2 +- src/App.tsx | 175 ++++++++++++++++++++++++--- src/api/todos.ts | 20 +++ src/components/ErrorNotification.tsx | 46 +++++++ src/components/Footer.tsx | 55 +++++++++ src/components/Header.tsx | 77 ++++++++++++ src/components/TodoItem.tsx | 134 ++++++++++++++++++++ src/components/TodoList.tsx | 48 ++++++++ src/types/ErrorStatus.ts | 8 ++ src/types/Status.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++++ 13 files changed, 608 insertions(+), 22 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification.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/ErrorStatus.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/package-lock.json b/package-lock.json index 19701e8788..3d5a60e99e 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": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1183,9 +1183,9 @@ } }, "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": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, "dependencies": { "@octokit/rest": "^17.11.2", diff --git a/package.json b/package.json index b6062525ab..005692edf7 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": "^1.9.12", "@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..6fbafdebfa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,167 @@ /* 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 { UserWarning } from './UserWarning'; -const USER_ID = 0; +import React, { useEffect, useState, useMemo } from 'react'; +import { Todo } from './types/Todo'; +import { + USER_ID, + addTodo, + deleteTodo, + getTodos, + updateTodo, +} from './api/todos'; +import { ErrorStatus } from './types/ErrorStatus'; +import { Status } from './types/Status'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { ErrorNotification } from './components/ErrorNotification'; +import { TodoList } from './components/TodoList'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todoList, setTodoList] = useState([]); + const [error, setError] = useState(ErrorStatus.Empty); + const [filter, setFilter] = useState(Status.All); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + + useEffect(() => { + getTodos() + .then(setTodoList) + .catch(() => { + setError(ErrorStatus.UnableToLoad); + }); + }, []); + + const todosActiveNum = useMemo( + () => todoList.filter(todo => !todo.completed).length, + [todoList], + ); + + const todosCompletedNum = useMemo( + () => todoList.filter(todo => todo.completed).length, + [todoList], + ); + + const allTodosCompleted = useMemo( + () => todoList.length === todosCompletedNum, + [todoList], + ); + + const handleAddTodo = async (todoTitle: string) => { + setTempTodo({ id: 0, title: todoTitle, completed: false, userId: USER_ID }); + try { + const newTodo = await addTodo({ title: todoTitle, completed: false }); + + setTodoList(prev => [...prev, newTodo]); + } catch (err) { + setError(ErrorStatus.UnableToAdd); + throw err; + } finally { + setTempTodo(null); + } + }; + + const handleRemoveTodo = async (todoId: number) => { + setLoadingTodoIds(prev => [...prev, todoId]); + try { + await deleteTodo(todoId); + setTodoList(prev => prev.filter(todo => todo.id !== todoId)); + } catch (err) { + setError(ErrorStatus.UnableToDelete); + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); + } + }; + + const handleClearCompleted = async () => { + const completedTodos = todoList.filter(todo => todo.completed); + + completedTodos.forEach(todo => { + handleRemoveTodo(todo.id); + }); + }; + + const handleUpdatedTodo = async (todoToUpdate: Todo): Promise => { + setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); + try { + const updatedTodo = await updateTodo(todoToUpdate); + + setTodoList(prev => + prev.map(todoElem => + todoElem.id === updatedTodo.id ? todoToUpdate : todoElem, + ), + ); + // eslint-disable-next-line @typescript-eslint/no-shadow + } catch (error) { + setError(ErrorStatus.UnableToUpdate); + throw error; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoToUpdate.id)); + } + }; + + const handleToggleAll = async () => { + if (todosActiveNum > 0) { + const activeTodos = todoList.filter(todo => !todo.completed); + + activeTodos.forEach(todo => { + handleUpdatedTodo({ ...todo, completed: true }); + }); + } else { + todoList.forEach(todo => { + handleUpdatedTodo({ ...todo, completed: false }); + }); + } + }; + + const filteredTodoList = (): Todo[] => { + switch (filter) { + case Status.Active: + return todoList.filter(todo => !todo.completed); + case Status.Completed: + return todoList.filter(todo => todo.completed); + default: + return todoList; + } + }; return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {(!!todoList.length || tempTodo) && ( + <> + + +
todo.completed)} + /> + + )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..32a48928db --- /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 = 2138; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const addTodo = (newTodo: Omit) => { + return client.post(`/todos`, { ...newTodo, userId: USER_ID }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const updateTodo = (todo: Todo) => { + return client.patch(`/todos/${todo.id}`, todo); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..73b616e459 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,46 @@ +import React, { useEffect } from 'react'; +import { ErrorStatus } from '../types/ErrorStatus'; +import cn from 'classnames'; + +type Props = { + error: string; + setError: React.Dispatch>; +}; + +export const ErrorNotification: React.FC = props => { + const { error, setError } = props; + + useEffect(() => { + if (error === ErrorStatus.Empty) { + return; + } + + const timerId = setTimeout(() => { + setError(ErrorStatus.Empty); + }, 3000); + + return () => { + clearInterval(timerId); + }; + }, [error, setError]); + + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..1d61513a86 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Status } from '../types/Status'; +import cn from 'classnames'; + +interface Props { + filterStatus: Status; + setFilter: (value: Status) => void; + todosCounter: number; + onClearCompleted: () => Promise; + showClearCompletedButton: boolean; +} + +export const Footer: React.FC = props => { + const { + setFilter, + filterStatus, + todosCounter, + onClearCompleted, + showClearCompletedButton, + } = props; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..2e9af15e95 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,77 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { ErrorStatus } from '../types/ErrorStatus'; +import { Todo } from '../types/Todo'; +import cn from 'classnames'; + +type Props = { + onAddTodo: (value: string) => Promise; + setErrorMessage: React.Dispatch>; + todoLength: number; + todoList: Todo[]; + onToggleAll: () => Promise; + allTodosCompleted: boolean; +}; + +export const Header: React.FC = props => { + const { + onAddTodo, + setErrorMessage, + todoLength, + onToggleAll, + allTodosCompleted, + } = props; + + const [inputValue, setInputValue] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const inputRef = useRef(null); + + useEffect(() => { + inputRef?.current?.focus(); + }, [inputValue, isLoading, todoLength]); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!inputValue.trim()) { + setErrorMessage(ErrorStatus.EmptyTitle); + + return; + } + + try { + setIsLoading(true); + await onAddTodo(inputValue.trim()); + setInputValue(''); + } catch (error) { + } finally { + setIsLoading(false); + inputRef?.current?.focus(); + } + }; + + return ( +
+ {todoLength !== 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..50885d9433 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/indent */ +/* eslint-disable jsx-a11y/label-has-associated-control */ + +import React, { useRef, useState } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + isLoading?: boolean; + onRemoveTodo: (todoId: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; + isInEditMode?: boolean; + setEditedTodoId: React.Dispatch>; +}; + +export const TodoItem: React.FC = props => { + const { + todo, + isLoading, + onRemoveTodo, + onUpdateTodo, + isInEditMode, + setEditedTodoId, + } = props; + + const [titleValue, setTitleValue] = useState(todo.title); + + const inputRef = useRef(null); + + const onCheckTodo = () => { + const todoToUpdate = { ...todo, completed: !todo.completed }; + + onUpdateTodo(todoToUpdate); + }; + + const onDoubleClick = () => { + setEditedTodoId(todo.id); + }; + + const handleBlur = async ( + event: + | React.FocusEvent + | React.FormEvent, + ) => { + event.preventDefault(); + + const normalizeTitle = titleValue.trim(); + + if (todo.title === normalizeTitle) { + setEditedTodoId(null); + + return; + } + + try { + if (normalizeTitle === '') { + await onRemoveTodo(todo.id); + } else { + await onUpdateTodo({ ...todo, title: normalizeTitle }); + } + + setEditedTodoId(null); + } catch (error) { + inputRef?.current?.focus(); + } + + setTitleValue(todo.title); + }; + + const onKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditedTodoId(null); + setTitleValue(todo.title); + } + }; + + return ( +
+ + + {isInEditMode ? ( +
+ setTitleValue(event.target.value)} + onKeyUp={onKeyUp} + ref={inputRef} + /> +
+ ) : ( + <> + + {todo.title} + + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..4c6cc717f1 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { TodoItem } from './TodoItem'; +import { Todo } from '../types/Todo'; + +type Props = { + filteredTodoList: Todo[]; + tempTodo: Todo | null; + loadingTodoIds: number[]; + handleRemoveTodo: (todoId: number) => Promise; + handleUpdatedTodo: (todo: Todo) => Promise; +}; + +export const TodoList: React.FC = props => { + const { + filteredTodoList, + loadingTodoIds, + tempTodo, + handleRemoveTodo, + handleUpdatedTodo, + } = props; + + const [editedTodoId, setEditedTodoId] = useState(null); + + return ( +
+ {filteredTodoList.map(todo => ( + + ))} + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/types/ErrorStatus.ts b/src/types/ErrorStatus.ts new file mode 100644 index 0000000000..815f9a2540 --- /dev/null +++ b/src/types/ErrorStatus.ts @@ -0,0 +1,8 @@ +export enum ErrorStatus { + Empty = '', + EmptyTitle = 'Title should not be empty', + UnableToAdd = 'Unable to add a todo', + UnableToLoad = 'Unable to load todos', + 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..dc864cc93b --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} 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'), +}; From 826f16b6b0a472e58f6f189fb625689e9813ac02 Mon Sep 17 00:00:00 2001 From: AsyaDev <142207257+AsyaDev14@users.noreply.github.com> Date: Mon, 23 Dec 2024 12:38:01 +0200 Subject: [PATCH 2/2] test bug fix --- src/App.tsx | 1 + src/components/TodoItem.tsx | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6fbafdebfa..b4ac03bd6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,6 +69,7 @@ export const App: React.FC = () => { setTodoList(prev => prev.filter(todo => todo.id !== todoId)); } catch (err) { setError(ErrorStatus.UnableToDelete); + throw err; } finally { setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); } diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx index 50885d9433..c82548dabe 100644 --- a/src/components/TodoItem.tsx +++ b/src/components/TodoItem.tsx @@ -64,8 +64,6 @@ export const TodoItem: React.FC = props => { } catch (error) { inputRef?.current?.focus(); } - - setTitleValue(todo.title); }; const onKeyUp = (event: React.KeyboardEvent) => {