From 1cf267a3864a84cabed8e6d956fba2565497a44d Mon Sep 17 00:00:00 2001 From: Denis Reznik Date: Thu, 22 Aug 2024 00:05:59 +0300 Subject: [PATCH 1/2] add solution --- README.md | 2 +- src/App.tsx | 172 ++++-------------------- src/api/todos.ts | 37 ++++++ src/castomHuks/useDispatch.ts | 4 + src/castomHuks/useGlobalState.ts | 4 + src/components/Footer.tsx | 66 ++++++++++ src/components/FooterButton.tsx | 42 ++++++ src/components/Header.tsx | 61 +++++++++ src/components/Main.tsx | 17 +++ src/components/TodoFromList.tsx | 183 ++++++++++++++++++++++++++ src/components/ToggleButtonHeader.tsx | 63 +++++++++ src/context/GlobalProvider.tsx | 30 +++++ src/context/Reduser.ts | 38 ++++++ src/index.tsx | 14 +- src/styles/todo.scss | 1 + src/styles/todoapp.scss | 1 + src/types/Action.ts | 10 ++ src/types/Actions.ts | 5 + src/types/RootState.ts | 8 ++ src/types/SelectedState.ts | 5 + src/types/Todo.ts | 5 + src/utils/filteredTodos.ts | 18 +++ 22 files changed, 636 insertions(+), 150 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/castomHuks/useDispatch.ts create mode 100644 src/castomHuks/useGlobalState.ts create mode 100644 src/components/Footer.tsx create mode 100644 src/components/FooterButton.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/Main.tsx create mode 100644 src/components/TodoFromList.tsx create mode 100644 src/components/ToggleButtonHeader.tsx create mode 100644 src/context/GlobalProvider.tsx create mode 100644 src/context/Reduser.ts create mode 100644 src/types/Action.ts create mode 100644 src/types/Actions.ts create mode 100644 src/types/RootState.ts create mode 100644 src/types/SelectedState.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/filteredTodos.ts diff --git a/README.md b/README.md index 903c876f9..498a96b03 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,4 @@ Implement a simple [TODO app](https://mate-academy.github.io/react_todo-app/) th - Implement a solution following the [React task guidelines](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). - Open another terminal and run tests with `npm test` to ensure your solution is correct. -- Replace `` with your GitHub username in the [DEMO LINK](https://.github.io/react_todo-app/) and add it to the PR description. +- Replace `` with your GitHub username in the [DEMO LINK](https://reznik-denis.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..6bbc1f3d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,38 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect } from 'react'; +import { Header } from './components/Header'; +import { Main } from './components/Main'; +import { Footer } from './components/Footer'; -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 */} -
- +import { useDispatch } from './castomHuks/useDispatch'; +import { Todo } from './types/Todo'; +import { getTodosFromStorage } from './api/todos'; +import { useGlobalState } from './castomHuks/useGlobalState'; - - 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 - - - -
-
+export const App: React.FC = () => { + const { todos } = useGlobalState(); + const dispatch = useDispatch(); - {/* Hide the footer if there are no todos */} -
- - 3 items left - + useEffect(() => { + const todosFromStorage = getTodosFromStorage(); - {/* Active link should have the 'selected' class */} - + return ( +
+

todos

- {/* this button should be disabled if there are no completed todos */} - -
+
+
+
+ {todos.length > 0 &&
}
); diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..aa1acb5ad --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,37 @@ +import { Todo } from '../types/Todo'; + +export const getTodosFromStorage = () => { + const storedItems = localStorage.getItem('todos'); + + if (storedItems) { + return JSON.parse(storedItems); + } + + return []; +}; + +export const addTodoToStorage = (todo: Todo) => { + const todos = getTodosFromStorage(); + + localStorage.setItem('todos', JSON.stringify([...todos, todo])); +}; + +export const deleteTodoFromStorage = (id: number) => { + const todos = getTodosFromStorage(); + const newTodos = todos.filter((todo: Todo) => todo.id !== id); + + localStorage.setItem('todos', JSON.stringify(newTodos)); +}; + +export const patchTodoFromStorage = (id: number, data: Omit) => { + const todos = getTodosFromStorage(); + const newTodos = todos.map((todo: Todo) => { + if (todo.id === id) { + return { ...todo, ...data }; + } else { + return todo; + } + }); + + localStorage.setItem('todos', JSON.stringify(newTodos)); +}; diff --git a/src/castomHuks/useDispatch.ts b/src/castomHuks/useDispatch.ts new file mode 100644 index 000000000..475169027 --- /dev/null +++ b/src/castomHuks/useDispatch.ts @@ -0,0 +1,4 @@ +import React from 'react'; +import { DispatchContext } from '../context/GlobalProvider'; + +export const useDispatch = () => React.useContext(DispatchContext); diff --git a/src/castomHuks/useGlobalState.ts b/src/castomHuks/useGlobalState.ts new file mode 100644 index 000000000..331cd2f73 --- /dev/null +++ b/src/castomHuks/useGlobalState.ts @@ -0,0 +1,4 @@ +import React from 'react'; +import { StateContext } from '../context/GlobalProvider'; + +export const useGlobalState = () => React.useContext(StateContext); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..10129e60e --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { useGlobalState } from '../castomHuks/useGlobalState'; +import { Actions } from '../types/Actions'; +import classNames from 'classnames'; +import { useDispatch } from '../castomHuks/useDispatch'; +import { filteredTodos } from '../utils/filteredTodos'; +import { FooterButton } from './FooterButton'; +import { SelectedState } from '../types/SelectedState'; + +export const Footer: React.FC = () => { + const [selected, setSelected] = useState({ + all: true, + active: false, + completed: false, + }); + const { todos } = useGlobalState(); + const dispatch = useDispatch(); + + const handleSelected = (action: Actions) => { + setSelected(prevState => { + const newState = { ...prevState }; + + for (const key in newState) { + if (key !== action) { + newState[key as keyof SelectedState] = false; + } else { + newState[key as keyof SelectedState] = true; + } + } + + return newState; + }); + dispatch({ type: 'setActions', payload: action }); + }; + + return ( +
+ + {`${filteredTodos(todos, Actions.ACTIVE).length} items left`} + + + + + +
+ ); +}; diff --git a/src/components/FooterButton.tsx b/src/components/FooterButton.tsx new file mode 100644 index 000000000..f466fbb07 --- /dev/null +++ b/src/components/FooterButton.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useGlobalState } from '../castomHuks/useGlobalState'; +import { deleteTodoFromStorage } from '../api/todos'; +import { useDispatch } from '../castomHuks/useDispatch'; + +export const FooterButton: React.FC = () => { + const { todos, inputHeaderRef } = useGlobalState(); + const dispatch = useDispatch(); + + const clearCompleted = async () => { + const completedTodos = todos.filter(todo => todo.completed); + + const deletePromises = completedTodos.map( + todo => + new Promise(resolve => { + deleteTodoFromStorage(todo.id); + dispatch({ type: 'deleteTodo', payload: todo.id }); + resolve(); + }), + ); + + if (inputHeaderRef?.current) { + inputHeaderRef.current.focus(); + } + + await Promise.allSettled(deletePromises); + }; + + const hasCompletedTodos = todos.some(todo => todo.completed); + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..ad9228d7d --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch } from '../castomHuks/useDispatch'; +import { addTodoToStorage } from '../api/todos'; +import { ToggleButton } from './ToggleButtonHeader'; +import { useGlobalState } from '../castomHuks/useGlobalState'; + +export const Header: React.FC = () => { + const [titleInput, setTitleInput] = useState(''); + + const { todos } = useGlobalState(); + const dispatch = useDispatch(); + + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + dispatch({ type: 'addInputRef', payload: inputRef }); + } + }, []); + + const handleTitle = (e: React.ChangeEvent) => { + setTitleInput(e.target.value); + }; + + const addTitle = (e: React.FormEvent) => { + e.preventDefault(); + + if (titleInput.trim().length === 0) { + return; + } + + const newTodo = { + id: Date.now(), + completed: false, + title: titleInput.trim(), + }; + + addTodoToStorage(newTodo); + dispatch({ type: 'addTodo', payload: newTodo }); + setTitleInput(''); + }; + + return ( +
+ {todos.length > 0 && } + +
+ +
+
+ ); +}; diff --git a/src/components/Main.tsx b/src/components/Main.tsx new file mode 100644 index 000000000..c2696945f --- /dev/null +++ b/src/components/Main.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { useGlobalState } from '../castomHuks/useGlobalState'; +import { filteredTodos } from '../utils/filteredTodos'; +import { TodoFromList } from './TodoFromList'; + +export const Main: React.FC = () => { + const { todos, filterActions } = useGlobalState(); + + return ( +
+ {todos.length > 0 && + filteredTodos(todos, filterActions).map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodoFromList.tsx b/src/components/TodoFromList.tsx new file mode 100644 index 000000000..2be63b065 --- /dev/null +++ b/src/components/TodoFromList.tsx @@ -0,0 +1,183 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch } from '../castomHuks/useDispatch'; +import { deleteTodoFromStorage, patchTodoFromStorage } from '../api/todos'; +import classNames from 'classnames'; +import { useGlobalState } from '../castomHuks/useGlobalState'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoFromList: React.FC = ({ todo }) => { + const [isEditTodo, setIsEditTodo] = useState<{ + [id: number]: boolean; + } | null>(null); + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(''); + const inputRefFocus = useRef(null); + const returnEditionRef = useRef<(e: KeyboardEvent) => void>(() => {}); + + const { todos, inputHeaderRef } = useGlobalState(); + const dispatch = useDispatch(); + + const deleteTodo = (id: number) => { + deleteTodoFromStorage(id); + dispatch({ type: 'deleteTodo', payload: id }); + + if (inputHeaderRef?.current) { + inputHeaderRef.current.focus(); + } + }; + + const updatedTodo = (id: number, data: Omit) => { + patchTodoFromStorage(id, data); + dispatch({ type: 'patchTodo', payload: { id, data } }); + }; + + const toggleCompleted = (id: number, completed: boolean, title: string) => { + const data = { + completed: !completed, + title: title, + }; + + updatedTodo(id, data); + }; + + const updateTitleTodo = (id: number, completed: boolean, title: string) => { + const data = { + completed: completed, + title: title, + }; + + updatedTodo(id, data); + }; + + const { id, completed, title } = todo; + + useEffect(() => { + const returnEdition = (e: KeyboardEvent) => { + if (e.code === 'Escape') { + setEditTitle(''); + setIsEditTodo(null); + setIsEditing(false); + } + }; + + returnEditionRef.current = returnEdition; + + if (isEditTodo) { + window.addEventListener('keyup', returnEditionRef.current); + } + + return () => { + if (returnEditionRef.current) { + window.removeEventListener('keyup', returnEditionRef.current); + } + }; + }, [isEditTodo]); + + const handleEditTodo = (idTodo: number, titleTodo: string) => { + setIsEditTodo(null); + setIsEditTodo({ [idTodo]: true }); + setEditTitle(titleTodo); + setIsEditing(true); + }; + + useEffect(() => { + if (isEditing && inputRefFocus.current) { + inputRefFocus.current.focus(); + } + }, [isEditing]); + + const handleInputTitle = (e: React.ChangeEvent) => { + setEditTitle(e.currentTarget.value); + }; + + const submitEditTitle = (idTodo: number) => { + const index = todos.findIndex(t => idTodo === t.id); + + if (todos[index].title === editTitle) { + setEditTitle(''); + setIsEditTodo(null); + setIsEditing(false); + + return; + } + + if (editTitle.length === 0) { + deleteTodo(idTodo); + + return; + } + + updateTitleTodo(idTodo, todos[index].completed, editTitle.trim()); + setEditTitle(''); + setIsEditTodo(null); + setIsEditing(false); + }; + + const handleBlur = (idTodo: number) => { + submitEditTitle(idTodo); + }; + + return ( +
+ + + {(isEditTodo === null || isEditTodo[id] === undefined) && ( + <> + handleEditTodo(id, title)} + > + {title} + + + + + )} + {isEditTodo !== null && isEditTodo[id] && ( +
{ + e.preventDefault(); + submitEditTitle(id); + }} + > + {isEditing && ( + handleBlur(id)} + /> + )} +
+ )} +
+ ); +}; diff --git a/src/components/ToggleButtonHeader.tsx b/src/components/ToggleButtonHeader.tsx new file mode 100644 index 000000000..29bd40fe9 --- /dev/null +++ b/src/components/ToggleButtonHeader.tsx @@ -0,0 +1,63 @@ +import classNames from 'classnames'; +import React from 'react'; +import { useGlobalState } from '../castomHuks/useGlobalState'; +import { patchTodoFromStorage } from '../api/todos'; +import { useDispatch } from '../castomHuks/useDispatch'; +import { Todo } from '../types/Todo'; + +export const ToggleButton: React.FC = () => { + const { todos } = useGlobalState(); + const dispatch = useDispatch(); + + const isCompletedTodo = todos.every(todo => todo.completed === true); + + const promiseToggleComleted = ({ id, completed, title }: Todo) => { + return new Promise(resolve => { + const data = { + completed: completed, + title: title, + }; + + patchTodoFromStorage(id, data); + dispatch({ type: 'patchTodo', payload: { id, data } }); + + resolve(); + }); + }; + + const handleCompletedAllTodos = async () => { + let togglePromises; + + if (isCompletedTodo) { + togglePromises = todos.map(todo => { + const toggleTodo = { ...todo, completed: false }; + + return promiseToggleComleted(toggleTodo); + }); + } else { + const completedTodos = todos.filter(todo => !todo.completed); + + togglePromises = completedTodos.map(todo => { + const toggleTodo = { + ...todo, + completed: todo.completed ? false : true, + }; + + return promiseToggleComleted(toggleTodo); + }); + } + + await Promise.allSettled(togglePromises); + }; + + return ( +