From 03b88672d499f7723a4a2b908c3e4b7642e83525 Mon Sep 17 00:00:00 2001 From: Yuliia Zubenko <95251336+yuliiazubenko@users.noreply.github.com> Date: Sun, 22 Dec 2024 00:45:55 +0100 Subject: [PATCH 1/2] solution --- src/App.tsx | 195 ++++++++++++++++-- src/api/todos.ts | 20 ++ .../ErrorNotification/ErrorNotification.tsx | 59 ++++++ src/components/TodoFooter/TodoFooter.tsx | 55 +++++ src/components/TodoHeader/TodoHeader.tsx | 85 ++++++++ src/components/TodoItem/TodoItem.tsx | 129 ++++++++++++ src/components/TodoList/TodoList.tsx | 50 +++++ src/styles/index.scss | 53 ++++- src/types/ErrorType.ts | 8 + src/types/FilterStatus.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++ 12 files changed, 689 insertions(+), 22 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification/ErrorNotification.tsx create mode 100644 src/components/TodoFooter/TodoFooter.tsx create mode 100644 src/components/TodoHeader/TodoHeader.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/types/ErrorType.ts create mode 100644 src/types/FilterStatus.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..2fcf038d82 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,183 @@ -/* 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, useMemo, useState, useRef } from 'react'; +import { + addTodo, + deleteTodo, + getTodos, + updateTodo, + USER_ID, +} from './api/todos'; +import { TodoList } from './components/TodoList/TodoList'; +import { Todo } from './types/Todo'; +import { TodoFooter } from './components/TodoFooter/TodoFooter'; +import { ErrorType } from './types/ErrorType'; +// eslint-disable-next-line max-len +import { ErrorNotification } from './components/ErrorNotification/ErrorNotification'; +import { TodoHeader } from './components/TodoHeader/TodoHeader'; +import { FilterStatus } from './types/FilterStatus'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [filterStatus, setFilterStatus] = useState( + FilterStatus.All, + ); + const [errorMessage, setErrorMessage] = useState(ErrorType.Empty); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + + const inputRef = useRef(null); + + const filteredTodos = useMemo( + () => + todos.filter(todo => { + if (filterStatus === FilterStatus.All) { + return true; + } + + return filterStatus === FilterStatus.Completed + ? todo.completed + : !todo.completed; + }), + + [todos, filterStatus], + ); + + const activeTodosCount = useMemo( + () => todos.filter(todo => !todo.completed).length, + [todos], + ); + const completedTodosCount = useMemo( + () => todos.filter(todo => todo.completed).length, + [todos], + ); + const areAllTodosCompleted = useMemo( + () => todos.every(todo => todo.completed), + [todos], + ); + + const onAddTodo = async (todoTitle: string) => { + try { + setTempTodo({ + id: 0, + title: todoTitle, + completed: false, + userId: USER_ID, + }); + const newTodo = await addTodo({ title: todoTitle, completed: false }); + + setTodos(prev => [...prev, newTodo]); + } catch (error) { + setErrorMessage(ErrorType.UnableToAdd); + throw error; + } finally { + setTempTodo(null); + if (inputRef.current) { + inputRef.current.focus(); + } + } + }; + + const onRemoveTodo = async (todoId: number) => { + setLoadingTodoIds(prev => [...prev, todoId]); + + try { + await deleteTodo(todoId); + + setTodos(prev => prev.filter(todo => todo.id !== todoId)); + } catch (error) { + setErrorMessage(ErrorType.UnableToDelete); + throw error; + } finally { + if (inputRef.current) { + inputRef.current.focus(); + } + } + }; + + const onClearCompleted = async () => { + const completedTodos = todos.filter(todo => todo.completed); + + completedTodos.forEach(todo => { + onRemoveTodo(todo.id); + }); + }; + + const onUpdateTodo = async (todoToUpdate: Todo) => { + setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); + try { + const updatedTodo = await updateTodo(todoToUpdate); + + setTodos(prev => + prev.map(todo => (todo.id === updatedTodo.id ? updatedTodo : todo)), + ); + } catch (error) { + setErrorMessage(ErrorType.UnableToUpdate); + throw error; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoToUpdate.id)); + } + }; + + const onToggleAll = async () => { + if (activeTodosCount > 0) { + const activeTodos = todos.filter(todo => !todo.completed); + + activeTodos.forEach(todo => { + onUpdateTodo({ ...todo, completed: true }); + }); + } else { + todos.forEach(todo => { + onUpdateTodo({ ...todo, completed: false }); + }); + } + }; + + useEffect(() => { + (async () => { + try { + const data = await getTodos(); + + setTodos(data); + } catch (error) { + setErrorMessage(ErrorType.UnableToLoad); + } + })(); + }, []); return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ + {(todos.length > 0 || tempTodo) && ( + <> + + + + )} +
+ +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..a28d320dd8 --- /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 = 2162; + +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/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..6b88eeb7c7 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,59 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import cn from 'classnames'; +import { ErrorType } from '../../types/ErrorType'; + +type Props = { + error: ErrorType; + setError: Dispatch>; +}; + +export const ErrorNotification: React.FC = props => { + const { error, setError } = props; + + const [isHidden, setIsHidden] = useState(true); + + useEffect(() => { + if (error === ErrorType.Empty) { + setIsHidden(true); + + return; + } + + setIsHidden(false); + + const timerId = setTimeout(() => { + setError(ErrorType.Empty); + setIsHidden(true); + }, 3000); + + return () => { + clearTimeout(timerId); + }; + }, [error, setError]); + + const handleClose = () => { + setIsHidden(true); + setError(ErrorType.Empty); + }; + + return ( +
+
+ ); +}; diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 0000000000..d7a38dbcfa --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,55 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import { FilterStatus } from '../../types/FilterStatus'; +import cn from 'classnames'; + +type Props = { + activeTodosCount: number; + setFilter: Dispatch>; + filter: FilterStatus; + onClearCompleted: () => Promise; + completedTodosCount: number; +}; + +export const TodoFooter: React.FC = props => { + const { + activeTodosCount, + setFilter, + filter, + onClearCompleted, + completedTodosCount, + } = props; + + return ( + + ); +}; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 0000000000..ca5809ccea --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,85 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { ErrorType } from '../../types/ErrorType'; +import cn from 'classnames'; + +type Props = { + onAddTodo: (value: string) => Promise; + setErrorMessage: Dispatch>; + inputRef: React.RefObject; + onToggleAll: () => Promise; + todosLength: number; + areAllTodosCompleted: boolean; +}; + +export const TodoHeader: React.FC = props => { + const { + onAddTodo, + setErrorMessage, + inputRef, + onToggleAll, + todosLength, + areAllTodosCompleted, + } = props; + + const [inputValue, setInputValue] = useState(''); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [todosLength, inputRef]); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const inputElement = inputRef.current; + + if (inputValue.trim() === '') { + setErrorMessage(ErrorType.EmptyTitle); + + return; + } + + if (inputElement) { + inputElement.disabled = true; + } + + try { + await onAddTodo(inputValue.trim()); + setInputValue(''); + } catch (error) { + } finally { + if (inputElement) { + inputElement.disabled = false; + inputElement.focus(); + } + } + }; + + return ( +
+ {todosLength !== 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..7f89982620 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,129 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { Dispatch, SetStateAction, useRef, useState } from 'react'; +import { Todo } from '../../types/Todo'; +import cn from 'classnames'; + +type Props = { + todo: Todo; + isLoading?: boolean; + isInEditMode?: boolean; + onRemoveTodo: (todoId: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; + setEditedTodoId: Dispatch>; +}; + +export const TodoItem: React.FC = props => { + const { + todo, + isLoading, + isInEditMode, + onRemoveTodo, + onUpdateTodo, + setEditedTodoId, + } = props; + + const [todoTitleValue, setTodoTitleValue] = useState(todo.title); + + const inputRef = useRef(null); + + const onCheckTodo = () => { + const todoToUpdate = { ...todo, completed: !todo.completed }; + + onUpdateTodo(todoToUpdate); + }; + + const onDoubleClick = () => { + setEditedTodoId(todo.id); + }; + + const onBlur = async ( + event: // eslint-disable-next-line @typescript-eslint/indent + | React.FocusEvent // eslint-disable-next-line @typescript-eslint/indent + | React.FormEvent, + ) => { + event.preventDefault(); + const normalizedTitle = todoTitleValue.trim(); + + if (todo.title === normalizedTitle) { + setEditedTodoId(null); + + return; + } + + try { + if (!normalizedTitle) { + await onRemoveTodo(todo.id); + } else { + await onUpdateTodo({ ...todo, title: normalizedTitle }); + } + + setEditedTodoId(null); + } catch (error) { + inputRef?.current?.focus(); + } + }; + + const onKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setEditedTodoId(null); + setTodoTitleValue(todo.title); + } + }; + + return ( +
+ + + {isInEditMode ? ( +
+ setTodoTitleValue(event.target.value)} + onKeyUp={onKeyUp} + ref={inputRef} + /> +
+ ) : ( + <> + + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..777357bcf4 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { TransitionGroup } from 'react-transition-group'; +import { CSSTransition } from 'react-transition-group'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + loadingTodoIds: number[]; + onRemoveTodo: (todoId: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; +}; + +export const TodoList: React.FC = props => { + const { todos, tempTodo, loadingTodoIds, onRemoveTodo, onUpdateTodo } = props; + + const [editedTodoId, setEditedTodoId] = useState(null); + + return ( +
+ + {todos.map(todo => ( + + + + ))} + {tempTodo && ( + + + + )} + +
+ ); +}; diff --git a/src/styles/index.scss b/src/styles/index.scss index bccd80c8bc..a5997bb111 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -20,6 +20,53 @@ body { pointer-events: none; } -@import "./todoapp"; -@import "./todo"; -@import "./filter"; +@import './todoapp'; +@import './todo'; +@import './filter'; + +.item-enter { + max-height: 0; +} + +.item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.item-exit { + max-height: 58px; +} + +.item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-enter { + max-height: 0; +} + +.temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-exit { + max-height: 58px; +} + +.temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; +} + +.has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; +} diff --git a/src/types/ErrorType.ts b/src/types/ErrorType.ts new file mode 100644 index 0000000000..edaef68cc6 --- /dev/null +++ b/src/types/ErrorType.ts @@ -0,0 +1,8 @@ +export enum ErrorType { + Empty = '', + EmptyTitle = 'Title should not be empty', + UnableToAdd = 'Unable to add a todo', + UnableToDelete = 'Unable to delete a todo', + UnableToLoad = 'Unable to load todos', + UnableToUpdate = 'Unable to update a todo', +} diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts new file mode 100644 index 0000000000..7ca17f289b --- /dev/null +++ b/src/types/FilterStatus.ts @@ -0,0 +1,5 @@ +export enum FilterStatus { + 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 ce7181c7e6ca29e8b94f90a42aee02943f7c9b1e Mon Sep 17 00:00:00 2001 From: Yuliia Zubenko <95251336+yuliiazubenko@users.noreply.github.com> Date: Sun, 22 Dec 2024 00:45:55 +0100 Subject: [PATCH 2/2] fix from mentor --- src/App.tsx | 25 ++++++++----------- .../ErrorNotification/ErrorNotification.tsx | 12 ++------- 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2fcf038d82..de3a2419c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,20 +28,17 @@ export const App: React.FC = () => { const inputRef = useRef(null); - const filteredTodos = useMemo( - () => - todos.filter(todo => { - if (filterStatus === FilterStatus.All) { - return true; - } - - return filterStatus === FilterStatus.Completed - ? todo.completed - : !todo.completed; - }), - - [todos, filterStatus], - ); + const filteredTodos = useMemo(() => { + if (filterStatus === FilterStatus.All) { + return todos; + } + + return todos.filter(todo => + filterStatus === FilterStatus.Completed + ? todo.completed + : !todo.completed, + ); + }, [todos, filterStatus]); const activeTodosCount = useMemo( () => todos.filter(todo => !todo.completed).length, diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx index 6b88eeb7c7..694b5cb1cf 100644 --- a/src/components/ErrorNotification/ErrorNotification.tsx +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -1,4 +1,4 @@ -import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import React, { Dispatch, SetStateAction, useEffect } from 'react'; import cn from 'classnames'; import { ErrorType } from '../../types/ErrorType'; @@ -10,20 +10,13 @@ type Props = { export const ErrorNotification: React.FC = props => { const { error, setError } = props; - const [isHidden, setIsHidden] = useState(true); - useEffect(() => { if (error === ErrorType.Empty) { - setIsHidden(true); - return; } - setIsHidden(false); - const timerId = setTimeout(() => { setError(ErrorType.Empty); - setIsHidden(true); }, 3000); return () => { @@ -32,7 +25,6 @@ export const ErrorNotification: React.FC = props => { }, [error, setError]); const handleClose = () => { - setIsHidden(true); setError(ErrorType.Empty); }; @@ -44,7 +36,7 @@ export const ErrorNotification: React.FC = props => { 'is-danger', 'is-light', 'has-text-weight-normal', - { hidden: isHidden }, + { hidden: error === ErrorType.Empty }, )} >