From af5d1bc96d5f4e081e61e10a4e9017906333ce6e Mon Sep 17 00:00:00 2001 From: ab3MN Date: Fri, 15 Nov 2024 13:18:30 +0200 Subject: [PATCH 1/5] feat:implement solution --- src/App.tsx | 165 +----------------- .../ErrorNotification/ErrorNotification.tsx | 20 +++ src/components/Todo/TodoFooter/TodoFooter.tsx | 61 +++++++ src/components/Todo/TodoForm/TodoForm.tsx | 25 +++ src/components/Todo/TodoHeader/TodoHeader.tsx | 32 ++++ src/components/Todo/TodoItem/TodoItem.tsx | 98 +++++++++++ src/components/Todo/TodoList/TodoList.tsx | 15 ++ src/components/Todo/Todos.tsx | 35 ++++ src/constants/TodoFilter.ts | 12 ++ src/context/TodoContext.tsx | 76 ++++++++ src/hooks/useDeleteTodo.ts | 26 +++ src/hooks/useSelectedTodo.ts | 21 +++ src/hooks/useTodoErrors.ts | 13 ++ src/hooks/useTodoFilter.ts | 17 ++ src/hooks/useTodoFormManager.ts | 88 ++++++++++ src/hooks/useTodoInput.ts | 11 ++ src/hooks/useTodos.ts | 97 ++++++++++ src/index.tsx | 10 +- src/styles/todo.scss | 56 +++++- src/styles/todoapp.scss | 18 +- src/types/Todo.ts | 5 + src/utils/enums/FilterStatuses.ts | 5 + src/utils/enums/TodoErrors.ts | 7 + src/utils/getRandomId.ts | 1 + src/utils/string/isOnlyWhiteSpace.ts | 2 + src/utils/todos/filterTodo.ts | 14 ++ src/utils/todos/getTodoErrorsMessage.ts | 18 ++ src/utils/todos/getTodos.ts | 16 ++ src/utils/todos/removeTodos.ts | 4 + src/utils/todos/updateTodo.ts | 4 + src/utils/todos/validationTodo.ts | 7 + 31 files changed, 806 insertions(+), 173 deletions(-) create mode 100644 src/components/ErrorNotification/ErrorNotification.tsx create mode 100644 src/components/Todo/TodoFooter/TodoFooter.tsx create mode 100644 src/components/Todo/TodoForm/TodoForm.tsx create mode 100644 src/components/Todo/TodoHeader/TodoHeader.tsx create mode 100644 src/components/Todo/TodoItem/TodoItem.tsx create mode 100644 src/components/Todo/TodoList/TodoList.tsx create mode 100644 src/components/Todo/Todos.tsx create mode 100644 src/constants/TodoFilter.ts create mode 100644 src/context/TodoContext.tsx create mode 100644 src/hooks/useDeleteTodo.ts create mode 100644 src/hooks/useSelectedTodo.ts create mode 100644 src/hooks/useTodoErrors.ts create mode 100644 src/hooks/useTodoFilter.ts create mode 100644 src/hooks/useTodoFormManager.ts create mode 100644 src/hooks/useTodoInput.ts create mode 100644 src/hooks/useTodos.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/enums/FilterStatuses.ts create mode 100644 src/utils/enums/TodoErrors.ts create mode 100644 src/utils/getRandomId.ts create mode 100644 src/utils/string/isOnlyWhiteSpace.ts create mode 100644 src/utils/todos/filterTodo.ts create mode 100644 src/utils/todos/getTodoErrorsMessage.ts create mode 100644 src/utils/todos/getTodos.ts create mode 100644 src/utils/todos/removeTodos.ts create mode 100644 src/utils/todos/updateTodo.ts create mode 100644 src/utils/todos/validationTodo.ts diff --git a/src/App.tsx b/src/App.tsx index a399287bd..feb441ef5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,8 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; - -export const App: React.FC = () => { - return ( -
-

todos

- -
-
- {/* this button should have `active` class only if all todos are completed */} -
- -
- {/* This is a completed todo */} -
- - - - Completed Todo - - - {/* Remove button appears only on hover */} - -
- - {/* This todo is an active todo */} -
- - - - Not Completed Todo - - - -
- - {/* This todo is being edited */} -
- - - {/* This form is shown instead of the title and remove button */} -
- -
-
- - {/* This todo is in loadind state */} -
- - - - Todo is being saved now - - - -
-
- - {/* Hide the footer if there are no todos */} -
- - 3 items left - - - {/* Active link should have the 'selected' class */} - - - {/* this button should be disabled if there are no completed todos */} - -
-
-
- ); -}; +import { Todos } from './components/Todo/Todos'; +import { TodosProvider } from './context/TodoContext'; + +export const App = () => ( + + + +); diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 000000000..41d53e745 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react'; +import { getTodoErrorsMessage } from '../../utils/todos/getTodoErrorsMessage'; +import cn from 'classnames'; +import { TodoErrors } from '../../utils/enums/TodoErrors'; + +interface ErrorNotificationProps { + error: TodoErrors | null; +} + +export const ErrorNotification: FC = ({ error }) => ( +
+
+); diff --git a/src/components/Todo/TodoFooter/TodoFooter.tsx b/src/components/Todo/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..6a02e2eea --- /dev/null +++ b/src/components/Todo/TodoFooter/TodoFooter.tsx @@ -0,0 +1,61 @@ +import { Dispatch, FC, SetStateAction } from 'react'; +import cn from 'classnames'; + +import { Todo } from '../../../types/Todo'; +import { TODO_FILTER_OPTIONS } from '../../../constants/TodoFilter'; + +import { FilterStatuses } from '../../../utils/enums/FilterStatuses'; +import { + getInCompletedTodos, + hasCompletedTodos, +} from '../../../utils/todos/getTodos'; +import { useDeleteTodo } from '../../../hooks/useDeleteTodo'; + +interface TodoFooterProps { + todos: Todo[]; + setStatus: Dispatch>; + status: FilterStatuses; +} + +export const TodoFooter: FC = ({ + todos, + setStatus, + status, +}) => { + const isCompletedTodoCounter = getInCompletedTodos(todos).length; + const { handleDeleteCompletedTodos } = useDeleteTodo(); + + return ( +
+ + {isCompletedTodoCounter} + {isCompletedTodoCounter === 1 ? ' item ' : ' items '} + left + + + + + +
+ ); +}; diff --git a/src/components/Todo/TodoForm/TodoForm.tsx b/src/components/Todo/TodoForm/TodoForm.tsx new file mode 100644 index 000000000..2745fe7ae --- /dev/null +++ b/src/components/Todo/TodoForm/TodoForm.tsx @@ -0,0 +1,25 @@ +import { useContext } from 'react'; + +import { TodosContext } from '../../../context/TodoContext'; +import { useTodoFormManager } from '../../../hooks/useTodoFormManager'; + +export const TodoForm = () => { + const { inputRef } = useContext(TodosContext); + const { title, handleSubmit, handleChangeTitle, isInputDisabled } = + useTodoFormManager(); + + return ( +
+ +
+ ); +}; diff --git a/src/components/Todo/TodoHeader/TodoHeader.tsx b/src/components/Todo/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..a5b374afa --- /dev/null +++ b/src/components/Todo/TodoHeader/TodoHeader.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react'; +import cn from 'classnames'; + +import { Todo } from '../../../types/Todo'; +import { isAllTodosCompleted } from '../../../utils/todos/getTodos'; +import { TodoForm } from '../TodoForm/TodoForm'; +import { useTodoFormManager } from '../../../hooks/useTodoFormManager'; + +interface TodoHeaderProps { + todos: Todo[]; +} + +export const TodoHeader: FC = ({ todos }) => { + const { handleToogleAllTodoStatus } = useTodoFormManager(); + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/Todo/TodoItem/TodoItem.tsx b/src/components/Todo/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..d194e8b2d --- /dev/null +++ b/src/components/Todo/TodoItem/TodoItem.tsx @@ -0,0 +1,98 @@ +import { FC, FormEvent } from 'react'; +import { Todo } from '../../../types/Todo'; +import cn from 'classnames'; +import { useDeleteTodo } from '../../../hooks/useDeleteTodo'; +import { useTodoFormManager } from '../../../hooks/useTodoFormManager'; +import { useSelectedTodo } from '../../../hooks/useSelectedTodo'; + +interface TodoItemProps { + todo: Todo; + isLoading?: boolean; +} + +export const TodoItem: FC = ({ todo, isLoading = false }) => { + const { completed, id, title } = todo; + const { isDeleting, handleDeleteTodo } = useDeleteTodo(); + const { selectedTodo, setSelectedTodo } = useSelectedTodo(); + const { + title: updatingTitle, + isUpdating, + setTitle, + handleUpdateTodo, + } = useTodoFormManager(todo.title); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (title == updatingTitle) { + return setSelectedTodo(null); + } + + const res = handleUpdateTodo({ ...todo, title: updatingTitle }); + + if (res) { + setSelectedTodo(null); + } + }; + + return ( +
+ {/* eslint-disable jsx-a11y/label-has-associated-control */} + + + {selectedTodo ? ( +
+ setTitle(e.target.value)} + autoFocus + /> +
+ ) : ( + { + setSelectedTodo(todo); + }} + > + {title.trim()} + + )} + {!selectedTodo && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/Todo/TodoList/TodoList.tsx b/src/components/Todo/TodoList/TodoList.tsx new file mode 100644 index 000000000..9e6eac322 --- /dev/null +++ b/src/components/Todo/TodoList/TodoList.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import { Todo } from '../../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +interface TodoListProps { + todos: Todo[]; +} + +export const TodoList: FC = ({ todos }) => ( +
+ {todos.map(todo => ( + + ))} +
+); diff --git a/src/components/Todo/Todos.tsx b/src/components/Todo/Todos.tsx new file mode 100644 index 000000000..473cad9d5 --- /dev/null +++ b/src/components/Todo/Todos.tsx @@ -0,0 +1,35 @@ +import { useContext } from 'react'; + +import { TodoHeader } from './TodoHeader/TodoHeader'; +import { TodoList } from './TodoList/TodoList'; +import { TodoFooter } from './TodoFooter/TodoFooter'; +import { ErrorNotification } from '../ErrorNotification/ErrorNotification'; +import { TodosContext } from '../../context/TodoContext'; +import { useTodoFilter } from '../../hooks/useTodoFilter'; + +export const Todos = () => { + const { todos, error } = useContext(TodosContext); + const { filtredTodos, setTodoStatus, todoStatus } = useTodoFilter(todos); + + return ( +
+

todos

+ +
+ + + + + {!!todos.length && ( + + )} +
+ + +
+ ); +}; diff --git a/src/constants/TodoFilter.ts b/src/constants/TodoFilter.ts new file mode 100644 index 000000000..6f0c78d15 --- /dev/null +++ b/src/constants/TodoFilter.ts @@ -0,0 +1,12 @@ +import { FilterStatuses } from '../utils/enums/FilterStatuses'; + +export const TODO_FILTER_OPTIONS = [ + { value: FilterStatuses.All, title: 'All', href: '#/', id: 1 }, + { value: FilterStatuses.Active, title: 'Active', href: '#/active', id: 2 }, + { + value: FilterStatuses.Completed, + title: 'Completed', + href: '#/completed', + id: 3, + }, +]; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..dcd51335c --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,76 @@ +import { createContext, ReactNode, useMemo, RefObject } from 'react'; +import { TodoErrors } from '../utils/enums/TodoErrors'; +import { Todo } from '../types/Todo'; +import { useTodoInput } from '../hooks/useTodoInput'; +import { useTodoErrors } from '../hooks/useTodoErrors'; +import { useTodos } from '../hooks/useTodos'; + +interface ITodosContext { + todos: Todo[]; + error: TodoErrors | null; + inputRef: RefObject | null; + + onFocus: () => void; + fetchTodos: () => void; + addTodo: (title: string) => Todo | void; + deleteTodo: (todoId: string) => string | void; + deleteCompletedTodos: () => void; + updateTodo: (todo: Todo) => Todo | void; + updatedAllTodo: () => void; + showError: (err: TodoErrors) => void; +} + +export const TodosContext = createContext({ + todos: [], + error: null, + inputRef: null, + + fetchTodos: () => {}, + addTodo: () => {}, + deleteTodo: () => {}, + deleteCompletedTodos: () => {}, + updateTodo: () => {}, + updatedAllTodo: () => {}, + showError: () => {}, + onFocus: () => {}, +}); + +export const TodosProvider = ({ + children, +}: { + children: ReactNode; +}): ReactNode => { + const { error, showError } = useTodoErrors(); + const { inputRef, onFocus } = useTodoInput(); + const { + todos, + fetchTodos, + addTodo, + deleteTodo, + deleteCompletedTodos, + updateTodo, + updatedAllTodo, + } = useTodos(); + + const store = useMemo( + () => ({ + todos, + error, + inputRef, + + fetchTodos, + addTodo, + deleteTodo, + showError, + deleteCompletedTodos, + updateTodo, + updatedAllTodo, + onFocus, + }), + [todos, error, inputRef], + ); + + return ( + {children} + ); +}; diff --git a/src/hooks/useDeleteTodo.ts b/src/hooks/useDeleteTodo.ts new file mode 100644 index 000000000..8b8971760 --- /dev/null +++ b/src/hooks/useDeleteTodo.ts @@ -0,0 +1,26 @@ +import { useContext, useState } from 'react'; + +import { TodosContext } from '../context/TodoContext'; + +export const useDeleteTodo = () => { + const [isDeleting, setDeleting] = useState(false); + const { deleteCompletedTodos, onFocus, deleteTodo } = + useContext(TodosContext); + + const handleDeleteTodo = (id: string) => { + setDeleting(true); + + deleteTodo(id); + + setDeleting(false); + onFocus(); + }; + + const handleDeleteCompletedTodos = () => { + deleteCompletedTodos(); + + onFocus(); + }; + + return { handleDeleteTodo, isDeleting, handleDeleteCompletedTodos }; +}; diff --git a/src/hooks/useSelectedTodo.ts b/src/hooks/useSelectedTodo.ts new file mode 100644 index 000000000..d31dc9a96 --- /dev/null +++ b/src/hooks/useSelectedTodo.ts @@ -0,0 +1,21 @@ +import { useLayoutEffect, useState } from 'react'; +import { Todo } from '../types/Todo'; + +export const useSelectedTodo = () => { + const [selectedTodo, setSelectedTodo] = useState(null); + + const handleKeyPress = (event: KeyboardEvent) => { + if (event.key === 'Escape' && selectedTodo) { + setSelectedTodo(null); + } + }; + useLayoutEffect(() => { + window.addEventListener('keydown', handleKeyPress); + + return () => { + window.removeEventListener('keydown', handleKeyPress); + }; + }, [selectedTodo]); + + return { selectedTodo, setSelectedTodo }; +}; diff --git a/src/hooks/useTodoErrors.ts b/src/hooks/useTodoErrors.ts new file mode 100644 index 000000000..a698a4ff8 --- /dev/null +++ b/src/hooks/useTodoErrors.ts @@ -0,0 +1,13 @@ +import { useState } from 'react'; +import { TodoErrors } from '../utils/enums/TodoErrors'; + +export const useTodoErrors = () => { + const [error, setError] = useState(null); + + const showError = (err: TodoErrors) => { + setError(err); + setTimeout(() => setError(null), 3000); + }; + + return { error, showError }; +}; diff --git a/src/hooks/useTodoFilter.ts b/src/hooks/useTodoFilter.ts new file mode 100644 index 000000000..fb5b5b664 --- /dev/null +++ b/src/hooks/useTodoFilter.ts @@ -0,0 +1,17 @@ +import { useMemo, useState } from 'react'; +import { FilterStatuses } from '../utils/enums/FilterStatuses'; +import { getFiltredTodo } from '../utils/todos/filterTodo'; +import { Todo } from '../types/Todo'; + +export const useTodoFilter = (todos: Todo[]) => { + const [todoStatus, setTodoStatus] = useState( + FilterStatuses.All, + ); + + const filtredTodos = useMemo( + () => getFiltredTodo(todos, todoStatus), + [todoStatus, todos], + ); + + return { filtredTodos, setTodoStatus, todoStatus }; +}; diff --git a/src/hooks/useTodoFormManager.ts b/src/hooks/useTodoFormManager.ts new file mode 100644 index 000000000..8912aed6e --- /dev/null +++ b/src/hooks/useTodoFormManager.ts @@ -0,0 +1,88 @@ +import { useState, useContext, useLayoutEffect } from 'react'; +import { TodosContext } from '../context/TodoContext'; +import { Todo } from '../types/Todo'; +import { isOnlyWhiteSpace } from '../utils/string/isOnlyWhiteSpace'; +import { TodoErrors } from '../utils/enums/TodoErrors'; + +export const useTodoFormManager = (initialTitle = '') => { + const { + addTodo, + updateTodo, + updatedAllTodo, + showError, + onFocus, + deleteTodo, + } = useContext(TodosContext); + const [title, setTitle] = useState(initialTitle); + const [isInputDisabled, setInputDisabled] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + useLayoutEffect(() => { + if (!isInputDisabled) { + onFocus(); + } + }, [isInputDisabled]); + + const handleAddTodo = () => { + setInputDisabled(true); + + try { + addTodo(title.trim()); + setTitle(''); + } catch { + showError(TodoErrors.add); + } finally { + setInputDisabled(false); + } + }; + + const handleUpdateTodo = (todo: Todo) => { + setIsUpdating(true); + + try { + if (todo.title.trim()) { + const updatedTodo = updateTodo(todo); + setIsUpdating(false); + + return updatedTodo; + } else { + deleteTodo(todo.id); + } + } catch (err) { + showError(TodoErrors.update); + } + + setIsUpdating(false); + }; + + const handleToogleAllTodoStatus = () => { + updatedAllTodo(); + }; + + const handleChangeTitle = (e: React.ChangeEvent) => { + setTitle(e.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title || isOnlyWhiteSpace(title)) { + showError(TodoErrors.title); + return; + } + + handleAddTodo(); + }; + + return { + title, + isInputDisabled, + isUpdating, + setTitle, + handleSubmit, + handleChangeTitle, + handleAddTodo, + handleUpdateTodo, + handleToogleAllTodoStatus, + }; +}; diff --git a/src/hooks/useTodoInput.ts b/src/hooks/useTodoInput.ts new file mode 100644 index 000000000..e38feff34 --- /dev/null +++ b/src/hooks/useTodoInput.ts @@ -0,0 +1,11 @@ +import { useRef } from 'react'; + +export const useTodoInput = () => { + const inputRef = useRef(null); + + const onFocus = () => { + inputRef.current?.focus(); + }; + + return { inputRef, onFocus }; +}; diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts new file mode 100644 index 000000000..a344b5b24 --- /dev/null +++ b/src/hooks/useTodos.ts @@ -0,0 +1,97 @@ +import { useLayoutEffect, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { + getInCompletedTodos, + hasInCompletedTodos, +} from '../utils/todos/getTodos'; +import { validUpdatedTodos } from '../utils/todos/validationTodo'; +import { updateTodosCompleted } from '../utils/todos/updateTodo'; +import { getRandomId } from '../utils/getRandomId'; + +export const useTodos = () => { + const [todos, setTodos] = useState([]); + + const fetchTodos = () => { + const todos = localStorage.getItem('todos'); + + setTodos(todos ? JSON.parse(todos) : []); + }; + + const addTodo = (title: string): Todo | void => { + const todo = { + title, + completed: false, + id: getRandomId(), + }; + + localStorage.setItem('todos', JSON.stringify([...todos, todo])); + + setTodos(prevState => [...prevState, todo]); + }; + + const deleteTodo = (todoId: string) => { + const newTodos = todos.filter(todo => todo.id !== todoId); + + setTodos(newTodos); + + localStorage.setItem('todos', JSON.stringify(newTodos)); + }; + + const deleteCompletedTodos = async () => { + const inCompletedTodos = getInCompletedTodos(todos); + + setTodos(inCompletedTodos); + + localStorage.setItem('todos', JSON.stringify(inCompletedTodos)); + }; + + const updateTodo = (todo: Todo): Todo | void => { + const updatedTodos = todos.map(currentTodo => + currentTodo.id === todo.id ? todo : currentTodo, + ); + + setTodos(updatedTodos); + + return todo; + }; + + const updatedAllTodo = (): void => { + const isIncompletedTodo = hasInCompletedTodos(todos); + + const newTodos = isIncompletedTodo + ? updateTodosCompleted(getInCompletedTodos(todos)) + : updateTodosCompleted(todos); + + const res = newTodos.map(todo => updateTodo(todo)); + + const updatedTodos = validUpdatedTodos(res); + + if (!!updatedTodos.length) { + setTodos(prevState => { + const updatedState = prevState.map(todo => { + const updatedTodo = updatedTodos.find( + updated => updated.id === todo.id, + ); + + return updatedTodo ? updatedTodo : todo; + }); + + return updatedState; + }); + } + }; + + useLayoutEffect(() => { + fetchTodos(); + }, []); + + return { + todos, + fetchTodos, + addTodo, + deleteTodo, + deleteCompletedTodos, + updateTodo, + updatedAllTodo, + }; +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..fee7a5959 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,9 @@ import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import 'bulma/css/bulma.css'; +import '@fortawesome/fontawesome-free/css/all.css'; +import './styles/index.scss'; import { App } from './App'; -const container = document.getElementById('root') as HTMLDivElement; - -createRoot(container).render(); +createRoot(document.getElementById('root') as HTMLDivElement).render(); diff --git a/src/styles/todo.scss b/src/styles/todo.scss index 4576af434..c7f93ff6b 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -15,13 +15,13 @@ &__status-label { cursor: pointer; - background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center left; } &.completed &__status-label { - background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E"); } &__status { @@ -92,8 +92,58 @@ .overlay { position: absolute; - inset: 0; + top: 0; + left: 0; + right: 0; + height: 58px; opacity: 0.5; } } + +.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/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..ad28bcb2f 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,5 +1,6 @@ + .todoapp { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 300; color: #4d4d4d; @@ -8,8 +9,7 @@ &__content { margin-bottom: 20px; background: #fff; - box-shadow: - 0 2px 4px 0 rgba(0, 0, 0, 0.2), + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); } @@ -49,7 +49,7 @@ } &::before { - content: '❯'; + content: "❯"; transform: translateY(2px) rotate(90deg); line-height: 0; } @@ -69,7 +69,7 @@ border: none; background: rgba(0, 0, 0, 0.01); - box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); &::placeholder { font-style: italic; @@ -97,8 +97,7 @@ text-align: center; border-top: 1px solid #e6e6e6; - box-shadow: - 0 1px 1px rgba(0, 0, 0, 0.2), + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, @@ -122,6 +121,7 @@ appearance: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + transition: opacity 0.3s; &:hover { text-decoration: underline; @@ -130,5 +130,9 @@ &:active { text-decoration: none; } + + &:disabled { + visibility: hidden; + } } } diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..476a3f9ef --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: string; + title: string; + completed: boolean; +} diff --git a/src/utils/enums/FilterStatuses.ts b/src/utils/enums/FilterStatuses.ts new file mode 100644 index 000000000..bb9a642ef --- /dev/null +++ b/src/utils/enums/FilterStatuses.ts @@ -0,0 +1,5 @@ +export enum FilterStatuses { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/utils/enums/TodoErrors.ts b/src/utils/enums/TodoErrors.ts new file mode 100644 index 000000000..5b0735cff --- /dev/null +++ b/src/utils/enums/TodoErrors.ts @@ -0,0 +1,7 @@ +export enum TodoErrors { + load = 'load', + title = 'title', + add = 'add', + delete = 'delete', + update = 'update', +} diff --git a/src/utils/getRandomId.ts b/src/utils/getRandomId.ts new file mode 100644 index 000000000..ffa785e90 --- /dev/null +++ b/src/utils/getRandomId.ts @@ -0,0 +1 @@ +export const getRandomId = (): string => self.crypto.randomUUID(); diff --git a/src/utils/string/isOnlyWhiteSpace.ts b/src/utils/string/isOnlyWhiteSpace.ts new file mode 100644 index 000000000..5ff1d9610 --- /dev/null +++ b/src/utils/string/isOnlyWhiteSpace.ts @@ -0,0 +1,2 @@ +export const isOnlyWhiteSpace = (string: string): boolean => + /^\s*$/.test(string); diff --git a/src/utils/todos/filterTodo.ts b/src/utils/todos/filterTodo.ts new file mode 100644 index 000000000..ba4d0b364 --- /dev/null +++ b/src/utils/todos/filterTodo.ts @@ -0,0 +1,14 @@ +import { Todo } from '../../types/Todo'; +import { FilterStatuses } from '../enums/FilterStatuses'; + +export const getFiltredTodo = (todos: Todo[], status: FilterStatuses) => + todos.filter(({ completed }) => { + switch (status) { + case FilterStatuses.Active: + return !completed; + case FilterStatuses.Completed: + return completed; + default: + return true; + } + }); diff --git a/src/utils/todos/getTodoErrorsMessage.ts b/src/utils/todos/getTodoErrorsMessage.ts new file mode 100644 index 000000000..33cf878bc --- /dev/null +++ b/src/utils/todos/getTodoErrorsMessage.ts @@ -0,0 +1,18 @@ +import { TodoErrors } from '../enums/TodoErrors'; + +export const getTodoErrorsMessage = (error: TodoErrors) => { + switch (error) { + case TodoErrors.load: + return 'Unable to load todos'; + case TodoErrors.title: + return 'Title should not be empty'; + case TodoErrors.add: + return 'Unable to add a todo'; + case TodoErrors.delete: + return 'Unable to delete a todo'; + case TodoErrors.update: + return 'Unable to update a todo'; + default: + return 'An unexpected error'; + } +}; diff --git a/src/utils/todos/getTodos.ts b/src/utils/todos/getTodos.ts new file mode 100644 index 000000000..e2e8d485e --- /dev/null +++ b/src/utils/todos/getTodos.ts @@ -0,0 +1,16 @@ +import { Todo } from '../../types/Todo'; + +export const getInCompletedTodos = (todos: Todo[]) => + todos.filter(({ completed }) => !completed); + +export const getCompletedTodos = (todos: Todo[]) => + todos.filter(({ completed }) => completed); + +export const isAllTodosCompleted = (todos: Todo[]) => + todos.every(({ completed }) => completed); + +export const hasCompletedTodos = (todos: Todo[]) => + todos.some(({ completed }) => completed); + +export const hasInCompletedTodos = (todos: Todo[]) => + todos.some(({ completed }) => completed === false); diff --git a/src/utils/todos/removeTodos.ts b/src/utils/todos/removeTodos.ts new file mode 100644 index 000000000..f0e961fee --- /dev/null +++ b/src/utils/todos/removeTodos.ts @@ -0,0 +1,4 @@ +import { Todo } from '../../types/Todo'; + +export const revomesTodosById = (todos: Todo[], ids: string[]) => + todos.filter(({ id }) => !ids.includes(id)); diff --git a/src/utils/todos/updateTodo.ts b/src/utils/todos/updateTodo.ts new file mode 100644 index 000000000..bd43eb2cd --- /dev/null +++ b/src/utils/todos/updateTodo.ts @@ -0,0 +1,4 @@ +import { Todo } from '../../types/Todo'; + +export const updateTodosCompleted = (todos: Todo[]) => + todos.map(todo => ({ ...todo, completed: !todo.completed })); diff --git a/src/utils/todos/validationTodo.ts b/src/utils/todos/validationTodo.ts new file mode 100644 index 000000000..d7796c541 --- /dev/null +++ b/src/utils/todos/validationTodo.ts @@ -0,0 +1,7 @@ +import { Todo } from '../../types/Todo'; + +export const validUpdatedTodos = (updatedTodos: (Todo | void)[]): Todo[] => + updatedTodos.filter((todo): todo is Todo => todo !== undefined); + +export const validTodoIds = (todoIds: (string | void)[]): string[] => + todoIds.filter((id): id is string => id !== undefined); From e38039b11c7dbe25a245a53db162db52760ac304 Mon Sep 17 00:00:00 2001 From: ab3MN Date: Fri, 15 Nov 2024 13:32:38 +0200 Subject: [PATCH 2/5] fix:fix eslint erros --- src/context/TodoContext.tsx | 14 +++++++++++++- src/hooks/useSelectedTodo.ts | 9 +++++---- src/hooks/useTodoFormManager.ts | 2 +- src/hooks/useTodoInput.ts | 6 +++--- src/hooks/useTodos.ts | 4 ++-- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx index dcd51335c..a8ee015cc 100644 --- a/src/context/TodoContext.tsx +++ b/src/context/TodoContext.tsx @@ -67,7 +67,19 @@ export const TodosProvider = ({ updatedAllTodo, onFocus, }), - [todos, error, inputRef], + [ + todos, + error, + inputRef, + fetchTodos, + addTodo, + deleteTodo, + showError, + deleteCompletedTodos, + updateTodo, + updatedAllTodo, + onFocus, + ], ); return ( diff --git a/src/hooks/useSelectedTodo.ts b/src/hooks/useSelectedTodo.ts index d31dc9a96..621a79684 100644 --- a/src/hooks/useSelectedTodo.ts +++ b/src/hooks/useSelectedTodo.ts @@ -1,21 +1,22 @@ -import { useLayoutEffect, useState } from 'react'; +import { useCallback, useLayoutEffect, useState } from 'react'; import { Todo } from '../types/Todo'; export const useSelectedTodo = () => { const [selectedTodo, setSelectedTodo] = useState(null); - const handleKeyPress = (event: KeyboardEvent) => { + const handleKeyPress = useCallback((event: KeyboardEvent) => { if (event.key === 'Escape' && selectedTodo) { setSelectedTodo(null); } - }; + }, []); + useLayoutEffect(() => { window.addEventListener('keydown', handleKeyPress); return () => { window.removeEventListener('keydown', handleKeyPress); }; - }, [selectedTodo]); + }, [selectedTodo, handleKeyPress]); return { selectedTodo, setSelectedTodo }; }; diff --git a/src/hooks/useTodoFormManager.ts b/src/hooks/useTodoFormManager.ts index 8912aed6e..c1cfe091d 100644 --- a/src/hooks/useTodoFormManager.ts +++ b/src/hooks/useTodoFormManager.ts @@ -21,7 +21,7 @@ export const useTodoFormManager = (initialTitle = '') => { if (!isInputDisabled) { onFocus(); } - }, [isInputDisabled]); + }, [isInputDisabled, onFocus]); const handleAddTodo = () => { setInputDisabled(true); diff --git a/src/hooks/useTodoInput.ts b/src/hooks/useTodoInput.ts index e38feff34..9c08d14ea 100644 --- a/src/hooks/useTodoInput.ts +++ b/src/hooks/useTodoInput.ts @@ -1,11 +1,11 @@ -import { useRef } from 'react'; +import { useCallback, useRef } from 'react'; export const useTodoInput = () => { const inputRef = useRef(null); - const onFocus = () => { + const onFocus = useCallback(() => { inputRef.current?.focus(); - }; + }, []); return { inputRef, onFocus }; }; diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts index a344b5b24..613342800 100644 --- a/src/hooks/useTodos.ts +++ b/src/hooks/useTodos.ts @@ -12,9 +12,9 @@ export const useTodos = () => { const [todos, setTodos] = useState([]); const fetchTodos = () => { - const todos = localStorage.getItem('todos'); + const localTodos = localStorage.getItem('todos'); - setTodos(todos ? JSON.parse(todos) : []); + setTodos(localTodos ? JSON.parse(localTodos) : []); }; const addTodo = (title: string): Todo | void => { From 491c1d3ae3ebfa010a8df81e6ec4bbd0c937ad8a Mon Sep 17 00:00:00 2001 From: ab3MN Date: Fri, 15 Nov 2024 13:34:21 +0200 Subject: [PATCH 3/5] fix:fix eslint erros --- src/hooks/useSelectedTodo.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/hooks/useSelectedTodo.ts b/src/hooks/useSelectedTodo.ts index 621a79684..0e909ddc7 100644 --- a/src/hooks/useSelectedTodo.ts +++ b/src/hooks/useSelectedTodo.ts @@ -4,11 +4,14 @@ import { Todo } from '../types/Todo'; export const useSelectedTodo = () => { const [selectedTodo, setSelectedTodo] = useState(null); - const handleKeyPress = useCallback((event: KeyboardEvent) => { - if (event.key === 'Escape' && selectedTodo) { - setSelectedTodo(null); - } - }, []); + const handleKeyPress = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape' && selectedTodo) { + setSelectedTodo(null); + } + }, + [setSelectedTodo], + ); useLayoutEffect(() => { window.addEventListener('keydown', handleKeyPress); From 905c87185be186dc5cdc6c01b059db4f3101fd4b Mon Sep 17 00:00:00 2001 From: ab3MN Date: Fri, 15 Nov 2024 15:22:41 +0200 Subject: [PATCH 4/5] refactor:refactor useTodos,add useLocalStorage --- src/hooks/useLocaLStorage.ts | 25 +++++++++++++++++ src/hooks/useTodos.ts | 46 ++++++++++++++----------------- src/utils/todos/removeTodos.ts | 4 --- src/utils/todos/validationTodo.ts | 7 ----- 4 files changed, 46 insertions(+), 36 deletions(-) create mode 100644 src/hooks/useLocaLStorage.ts delete mode 100644 src/utils/todos/removeTodos.ts delete mode 100644 src/utils/todos/validationTodo.ts diff --git a/src/hooks/useLocaLStorage.ts b/src/hooks/useLocaLStorage.ts new file mode 100644 index 000000000..ae0df653b --- /dev/null +++ b/src/hooks/useLocaLStorage.ts @@ -0,0 +1,25 @@ +interface IUseLocalStorage { + setItem: (value: unknown) => void; + getItem: () => unknown; + removeItem: () => void; +} + +const useLocaLStorage = (key: string): IUseLocalStorage => { + const setItem = (value: unknown): void => { + typeof value === 'string' + ? localStorage.setItem(key, value) + : localStorage.setItem(key, JSON.stringify(value)); + }; + + const getItem = (): unknown => { + const item = localStorage.getItem(key); + + return item ? JSON.parse(item) : null; + }; + + const removeItem = (): void => localStorage.removeItem(key); + + return { setItem, getItem, removeItem }; +}; + +export default useLocaLStorage; diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts index 613342800..b7910b819 100644 --- a/src/hooks/useTodos.ts +++ b/src/hooks/useTodos.ts @@ -1,22 +1,23 @@ import { useLayoutEffect, useState } from 'react'; import { Todo } from '../types/Todo'; import { + getCompletedTodos, getInCompletedTodos, hasInCompletedTodos, } from '../utils/todos/getTodos'; -import { validUpdatedTodos } from '../utils/todos/validationTodo'; import { updateTodosCompleted } from '../utils/todos/updateTodo'; import { getRandomId } from '../utils/getRandomId'; +import useLocaLStorage from './useLocaLStorage'; export const useTodos = () => { const [todos, setTodos] = useState([]); + const { getItem, setItem } = useLocaLStorage('todos'); const fetchTodos = () => { - const localTodos = localStorage.getItem('todos'); + const localTodos = getItem(); - setTodos(localTodos ? JSON.parse(localTodos) : []); + setTodos(localTodos ? localTodos : []); }; - const addTodo = (title: string): Todo | void => { const todo = { title, @@ -24,7 +25,7 @@ export const useTodos = () => { id: getRandomId(), }; - localStorage.setItem('todos', JSON.stringify([...todos, todo])); + setItem([...todos, todo]); setTodos(prevState => [...prevState, todo]); }; @@ -34,7 +35,7 @@ export const useTodos = () => { setTodos(newTodos); - localStorage.setItem('todos', JSON.stringify(newTodos)); + setItem(newTodos); }; const deleteCompletedTodos = async () => { @@ -42,7 +43,7 @@ export const useTodos = () => { setTodos(inCompletedTodos); - localStorage.setItem('todos', JSON.stringify(inCompletedTodos)); + setItem(inCompletedTodos); }; const updateTodo = (todo: Todo): Todo | void => { @@ -52,33 +53,28 @@ export const useTodos = () => { setTodos(updatedTodos); + setItem(updatedTodos); + return todo; }; const updatedAllTodo = (): void => { const isIncompletedTodo = hasInCompletedTodos(todos); - const newTodos = isIncompletedTodo - ? updateTodosCompleted(getInCompletedTodos(todos)) - : updateTodosCompleted(todos); - - const res = newTodos.map(todo => updateTodo(todo)); - - const updatedTodos = validUpdatedTodos(res); + let newTodos: Todo[] = []; - if (!!updatedTodos.length) { - setTodos(prevState => { - const updatedState = prevState.map(todo => { - const updatedTodo = updatedTodos.find( - updated => updated.id === todo.id, - ); + if (isIncompletedTodo) { + newTodos = [ + ...updateTodosCompleted(getInCompletedTodos(todos)), + ...getCompletedTodos(todos), + ]; + } else { + newTodos = updateTodosCompleted(todos); + } - return updatedTodo ? updatedTodo : todo; - }); + setTodos(newTodos); - return updatedState; - }); - } + setItem(newTodos); }; useLayoutEffect(() => { diff --git a/src/utils/todos/removeTodos.ts b/src/utils/todos/removeTodos.ts deleted file mode 100644 index f0e961fee..000000000 --- a/src/utils/todos/removeTodos.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Todo } from '../../types/Todo'; - -export const revomesTodosById = (todos: Todo[], ids: string[]) => - todos.filter(({ id }) => !ids.includes(id)); diff --git a/src/utils/todos/validationTodo.ts b/src/utils/todos/validationTodo.ts deleted file mode 100644 index d7796c541..000000000 --- a/src/utils/todos/validationTodo.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Todo } from '../../types/Todo'; - -export const validUpdatedTodos = (updatedTodos: (Todo | void)[]): Todo[] => - updatedTodos.filter((todo): todo is Todo => todo !== undefined); - -export const validTodoIds = (todoIds: (string | void)[]): string[] => - todoIds.filter((id): id is string => id !== undefined); From fa4c274d697e90fb680b83ae58f044a5002b4ca4 Mon Sep 17 00:00:00 2001 From: ab3MN Date: Fri, 15 Nov 2024 15:27:56 +0200 Subject: [PATCH 5/5] fix:fix eslint errors --- src/hooks/useLocaLStorage.ts | 8 +++++--- src/hooks/useSelectedTodo.ts | 2 +- src/hooks/useTodos.ts | 9 +++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/hooks/useLocaLStorage.ts b/src/hooks/useLocaLStorage.ts index ae0df653b..e56b4c042 100644 --- a/src/hooks/useLocaLStorage.ts +++ b/src/hooks/useLocaLStorage.ts @@ -6,9 +6,11 @@ interface IUseLocalStorage { const useLocaLStorage = (key: string): IUseLocalStorage => { const setItem = (value: unknown): void => { - typeof value === 'string' - ? localStorage.setItem(key, value) - : localStorage.setItem(key, JSON.stringify(value)); + if (typeof value === 'string') { + localStorage.setItem(key, value); + } else { + localStorage.setItem(key, JSON.stringify(value)); + } }; const getItem = (): unknown => { diff --git a/src/hooks/useSelectedTodo.ts b/src/hooks/useSelectedTodo.ts index 0e909ddc7..8de40fe4d 100644 --- a/src/hooks/useSelectedTodo.ts +++ b/src/hooks/useSelectedTodo.ts @@ -10,7 +10,7 @@ export const useSelectedTodo = () => { setSelectedTodo(null); } }, - [setSelectedTodo], + [selectedTodo], ); useLayoutEffect(() => { diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts index b7910b819..9c7a3a939 100644 --- a/src/hooks/useTodos.ts +++ b/src/hooks/useTodos.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState } from 'react'; +import { useCallback, useLayoutEffect, useState } from 'react'; import { Todo } from '../types/Todo'; import { getCompletedTodos, @@ -13,11 +13,12 @@ export const useTodos = () => { const [todos, setTodos] = useState([]); const { getItem, setItem } = useLocaLStorage('todos'); - const fetchTodos = () => { + const fetchTodos = useCallback(() => { const localTodos = getItem(); setTodos(localTodos ? localTodos : []); - }; + }, []); + const addTodo = (title: string): Todo | void => { const todo = { title, @@ -79,7 +80,7 @@ export const useTodos = () => { useLayoutEffect(() => { fetchTodos(); - }, []); + }, [fetchTodos]); return { todos,