From 890e450ffb2db4b2cc3aaa8e8a45f754cd68088f Mon Sep 17 00:00:00 2001 From: Stanislav Chyrva Date: Thu, 19 Dec 2024 19:12:29 +0200 Subject: [PATCH 1/6] solution --- src/App.tsx | 188 ++++++++++++++++-- src/api/todos.ts | 20 ++ .../ErrorNotification/ErrorNotification.tsx | 41 ++++ src/components/ErrorNotification/index.ts | 1 + src/components/TodoFooter/TodoFooter.tsx | 54 +++++ src/components/TodoFooter/index.ts | 1 + src/components/TodoHeader/TodoHeader.tsx | 79 ++++++++ src/components/TodoHeader/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 131 ++++++++++++ src/components/TodoItem/index.ts | 1 + src/components/TodoList/TodoList.tsx | 46 +++++ src/components/TodoList/index.ts | 1 + src/types/ErrorType.ts | 8 + src/types/FilterStatus.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++ 16 files changed, 609 insertions(+), 20 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification/ErrorNotification.tsx create mode 100644 src/components/ErrorNotification/index.ts create mode 100644 src/components/TodoFooter/TodoFooter.tsx create mode 100644 src/components/TodoFooter/index.ts create mode 100644 src/components/TodoHeader/TodoHeader.tsx create mode 100644 src/components/TodoHeader/index.ts create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts 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..70aeb53179 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,174 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoFooter } from './components/TodoFooter'; +import { ErrorNotification } from './components/ErrorNotification'; +import { Todo } from './types/Todo'; +import { + addTodo, + deleteTodo, + getTodos, + updateTodo, + USER_ID, +} from './api/todos'; +import { ErrorType } from './types/ErrorType'; +import { FilterStatus } from './types/FilterStatus'; +import { TodoList } from './components/TodoList'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(ErrorType.Empty); + const [filterStatus, setFilterStatus] = useState( + FilterStatus.All, + ); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + + useEffect(() => { + (async () => { + try { + const data = await getTodos(); + + setTodos(data); + } catch (err) { + setErrorMessage(ErrorType.LoadTodos); + } + })(); + }, []); + + const filteredTodos = useMemo( + () => + todos.filter(todo => { + if (filterStatus === FilterStatus.All) { + return true; + } + + return filterStatus === FilterStatus.Completed + ? todo.completed + : !todo.completed; + }), + [todos, filterStatus], + ); + + const todosCompletedNum = useMemo( + () => todos.filter(todo => todo.completed).length, + [todos], + ); + + const areAllTodosCompleated = useMemo( + () => todos.every(todo => todo.completed), + [todos], + ); + + const todosActiveNum = useMemo( + () => todos.filter(todo => !todo.completed).length, + [todos], + ); + + const onAddTodo = async (todoTitle: string) => { + setTempTodo({ id: 0, title: todoTitle, completed: false, userId: USER_ID }); + try { + const newTodo = await addTodo({ title: todoTitle, completed: false }); + + setTodos(prev => [...prev, newTodo]); + } catch (err) { + setErrorMessage(ErrorType.AddTodo); + inputAddRef?.current?.focus(); + throw err; + } finally { + setTempTodo(null); + } + }; + + const onRemoveTodo = async (todoId: number) => { + setLoadingTodoIds(prev => [...prev, todoId]); + try { + await deleteTodo(todoId); + setTodos(prev => prev.filter(todo => todo.id !== todoId)); + } catch (err) { + setErrorMessage(ErrorType.DeleteTodo); + inputAddRef?.current?.focus(); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoId)); + } + }; + + 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 (err) { + setErrorMessage(ErrorType.UpdateTodo); + throw err; + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todoToUpdate.id)); + } + }; + + const onClearCompleted = async () => { + const completedTodos = todos.filter(todo => todo.completed); + + completedTodos.forEach(todo => { + onRemoveTodo(todo.id); + }); + }; + + const onToggleAll = async () => { + if (todosActiveNum > 0) { + const activeTodos = todos.filter(todo => !todo.completed); + + activeTodos.forEach(todo => { + onUpdateTodo({ ...todo, completed: true }); + }); + } else { + todos.forEach(todo => { + onUpdateTodo({ ...todo, completed: false }); + }); + } + }; + + const inputAddRef = useRef(null); 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..b586a74597 --- /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 = 2157; + +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..8be1bd351c --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,41 @@ +import { Dispatch, SetStateAction, useEffect } from 'react'; +import cn from 'classnames'; +import { ErrorType } from '../../types/ErrorType'; + +type Props = { + error: ErrorType; + setError: Dispatch>; +}; + +export const ErrorNotification: React.FC = ({ error, setError }) => { + useEffect(() => { + if (error === ErrorType.Empty) { + return; + } + + const timerId = setTimeout(() => { + setError(ErrorType.Empty); + }, 3000); + + return () => { + clearTimeout(timerId); + }; + }, [error, setError]); + + return ( +
+
+ ); +}; diff --git a/src/components/ErrorNotification/index.ts b/src/components/ErrorNotification/index.ts new file mode 100644 index 0000000000..8cb4787920 --- /dev/null +++ b/src/components/ErrorNotification/index.ts @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 0000000000..3f3effe83f --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,54 @@ +import { Dispatch, SetStateAction } from 'react'; + +import cn from 'classnames'; +import { FilterStatus } from '../../types/FilterStatus'; + +type Props = { + filterStatus: FilterStatus; + setFilterStatus: Dispatch>; + todosLeft: number; + todosCompleted: number; + onClearCompleted: () => Promise; +}; + +export const TodoFooter: React.FC = ({ + filterStatus, + setFilterStatus, + todosLeft, + todosCompleted, + onClearCompleted, +}) => { + return ( + + ); +}; diff --git a/src/components/TodoFooter/index.ts b/src/components/TodoFooter/index.ts new file mode 100644 index 0000000000..544d07114e --- /dev/null +++ b/src/components/TodoFooter/index.ts @@ -0,0 +1 @@ +export * from './TodoFooter'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 0000000000..0c9fe8098e --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,79 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { ErrorType } from '../../types/ErrorType'; +import cn from 'classnames'; + +type Props = { + onAddTodo: (value: string) => Promise; + setErrorMessage: Dispatch>; + isInputDisabled: boolean; + inputRef: React.RefObject | null; + onToggleAll: () => Promise; + areAllTodosCompleated: boolean; + todosLength: number; +}; + +export const TodoHeader: React.FC = ({ + onAddTodo, + setErrorMessage, + isInputDisabled, + inputRef, + onToggleAll, + areAllTodosCompleated, + todosLength, +}) => { + const [inputValue, setInputValue] = useState(''); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedValue = inputValue.trim(); + + if (!trimmedValue) { + setErrorMessage(ErrorType.EmptyTitle); + + return; + } + + try { + await onAddTodo(trimmedValue); + setInputValue(''); + } catch {} + }; + + useEffect(() => { + inputRef?.current?.focus(); + }, [todosLength, inputRef]); + + useEffect(() => { + if (!isInputDisabled) { + inputRef?.current?.focus(); + } + }, [isInputDisabled, inputRef]); + + return ( +
+ {todosLength !== 0 && ( +
+ ); +}; diff --git a/src/components/TodoHeader/index.ts b/src/components/TodoHeader/index.ts new file mode 100644 index 0000000000..c4db4bc408 --- /dev/null +++ b/src/components/TodoHeader/index.ts @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 0000000000..2e8fd34d18 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,131 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import cn from 'classnames'; +import { Todo } from '../../types/Todo'; +import { Dispatch, SetStateAction, useRef, useState } from 'react'; + +type Props = { + todo: Todo; + isLoading?: boolean; + isInEditMode?: boolean; + onRemoveTodo: (todoId: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; + setEditedTodoId: Dispatch>; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + onRemoveTodo, + onUpdateTodo, + isInEditMode, + setEditedTodoId, +}) => { + 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: React.FormEvent | React.FocusEvent, + ) => { + event.preventDefault(); + + const normalizedTitle = todoTitleValue.trim(); + + if (todo.title === normalizedTitle) { + setEditedTodoId(null); + + return; + } + + if (normalizedTitle === '') { + try { + await onRemoveTodo(todo.id); + setEditedTodoId(null); + } catch (err) {} + + inputRef?.current?.focus(); + + return; + } + + try { + await onUpdateTodo({ ...todo, title: normalizedTitle }); + setEditedTodoId(null); + } catch (err) { + 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/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 0000000000..21f4abac39 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..753cd3c889 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { TodoItem } from '../TodoItem'; +import { Todo } from '../../types/Todo'; + +type Props = { + filteredTodos: Todo[]; + onRemoveTodo: (todoId: number) => Promise; + onUpdateTodo: (todo: Todo) => Promise; + loadingTodoIds: number[]; + tempTodo: Todo | null; +}; + +export const TodoList: React.FC = ({ + filteredTodos, + onRemoveTodo, + onUpdateTodo, + loadingTodoIds, + tempTodo, +}) => { + const [editedTodoId, setEditedTodoId] = useState(null); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/types/ErrorType.ts b/src/types/ErrorType.ts new file mode 100644 index 0000000000..66f0a8d5ad --- /dev/null +++ b/src/types/ErrorType.ts @@ -0,0 +1,8 @@ +export enum ErrorType { + Empty = '', + LoadTodos = 'Unable to load todos', + EmptyTitle = 'Title should not be empty', + AddTodo = 'Unable to add a todo', + DeleteTodo = 'Unable to delete a todo', + UpdateTodo = '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 9c0e9678b3eb54de2400a501496b8de2212e3f1e Mon Sep 17 00:00:00 2001 From: Stanislav Chyrva Date: Thu, 19 Dec 2024 19:18:54 +0200 Subject: [PATCH 2/6] moved ref at top --- src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 70aeb53179..eefe473a68 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,8 @@ export const App: React.FC = () => { })(); }, []); + const inputAddRef = useRef(null); + const filteredTodos = useMemo( () => todos.filter(todo => { @@ -131,8 +133,6 @@ export const App: React.FC = () => { } }; - const inputAddRef = useRef(null); - return (

todos

From 489fe9c818a4e1f65c7a4e58bd81eeb08168ab8e Mon Sep 17 00:00:00 2001 From: Stanislav Chyrva Date: Thu, 19 Dec 2024 20:06:06 +0200 Subject: [PATCH 3/6] added animations --- src/components/TodoList/TodoList.tsx | 47 ++++++++++++++++------------ src/styles/index.scss | 47 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx index 753cd3c889..c9eee932f9 100644 --- a/src/components/TodoList/TodoList.tsx +++ b/src/components/TodoList/TodoList.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { TodoItem } from '../TodoItem'; import { Todo } from '../../types/Todo'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; type Props = { filteredTodos: Todo[]; @@ -21,26 +22,32 @@ export const TodoList: React.FC = ({ return (
- {filteredTodos.map(todo => ( - - ))} - {tempTodo && ( - - )} + + {filteredTodos.map(todo => ( + + + + ))} + {tempTodo && ( + + + + )} +
); }; diff --git a/src/styles/index.scss b/src/styles/index.scss index bccd80c8bc..f2f19f4f7f 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -23,3 +23,50 @@ body { @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; +} From 071c9456b785965ed234373274dab8e1e1502d96 Mon Sep 17 00:00:00 2001 From: Stanislav Chyrva Date: Fri, 20 Dec 2024 17:09:36 +0200 Subject: [PATCH 4/6] adjusted function names to align with naming conventions --- src/App.tsx | 28 ++++++++++++------------ src/components/TodoFooter/TodoFooter.tsx | 6 ++--- src/components/TodoHeader/TodoHeader.tsx | 16 +++++++------- src/components/TodoItem/TodoItem.tsx | 16 +++++++------- src/components/TodoList/TodoList.tsx | 16 +++++++------- 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index eefe473a68..8498690752 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,7 +66,7 @@ export const App: React.FC = () => { [todos], ); - const onAddTodo = async (todoTitle: string) => { + const handleAddTodo = async (todoTitle: string) => { setTempTodo({ id: 0, title: todoTitle, completed: false, userId: USER_ID }); try { const newTodo = await addTodo({ title: todoTitle, completed: false }); @@ -81,7 +81,7 @@ export const App: React.FC = () => { } }; - const onRemoveTodo = async (todoId: number) => { + const handleRemoveTodo = async (todoId: number) => { setLoadingTodoIds(prev => [...prev, todoId]); try { await deleteTodo(todoId); @@ -95,7 +95,7 @@ export const App: React.FC = () => { } }; - const onUpdateTodo = async (todoToUpdate: Todo) => { + const handleUpdateTodo = async (todoToUpdate: Todo) => { setLoadingTodoIds(prev => [...prev, todoToUpdate.id]); try { const updatedTodo = await updateTodo(todoToUpdate); @@ -111,24 +111,24 @@ export const App: React.FC = () => { } }; - const onClearCompleted = async () => { + const handleClearCompleted = async () => { const completedTodos = todos.filter(todo => todo.completed); completedTodos.forEach(todo => { - onRemoveTodo(todo.id); + handleRemoveTodo(todo.id); }); }; - const onToggleAll = async () => { + const handleToggleAll = async () => { if (todosActiveNum > 0) { const activeTodos = todos.filter(todo => !todo.completed); activeTodos.forEach(todo => { - onUpdateTodo({ ...todo, completed: true }); + handleUpdateTodo({ ...todo, completed: true }); }); } else { todos.forEach(todo => { - onUpdateTodo({ ...todo, completed: false }); + handleUpdateTodo({ ...todo, completed: false }); }); } }; @@ -139,21 +139,21 @@ export const App: React.FC = () => {
- {(todos.length > 0 || tempTodo) && ( + {(!!todos.length || tempTodo) && ( <> @@ -162,7 +162,7 @@ export const App: React.FC = () => { setFilterStatus={setFilterStatus} todosLeft={todosActiveNum} todosCompleted={todosCompletedNum} - onClearCompleted={onClearCompleted} + handleClearCompleted={handleClearCompleted} /> )} diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx index 3f3effe83f..f661adb7b1 100644 --- a/src/components/TodoFooter/TodoFooter.tsx +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -8,7 +8,7 @@ type Props = { setFilterStatus: Dispatch>; todosLeft: number; todosCompleted: number; - onClearCompleted: () => Promise; + handleClearCompleted: () => Promise; }; export const TodoFooter: React.FC = ({ @@ -16,7 +16,7 @@ export const TodoFooter: React.FC = ({ setFilterStatus, todosLeft, todosCompleted, - onClearCompleted, + handleClearCompleted, }) => { return (