diff --git a/README.md b/README.md index c5078685e..bc07326ad 100644 --- a/README.md +++ b/README.md @@ -53,4 +53,4 @@ Implement a simple [TODO app](http://todomvc.com/examples/vanillajs/) working as - Implement a solution following the [React task guideline](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 one more 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://vanya-kalyenichenko.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 20e932bab..b4788ce2f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,93 +1,19 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext } from 'react'; +import { TodosFilter } from './Components/Footer/TodosFilter'; +import { Header } from './Components/Header/Header'; +import { Main } from './Components/Main/Main'; +import { TodosContext } from './Context/TodosContext'; export const App: React.FC = () => { + const { todos } = useContext(TodosContext); + return (
-
-

todos

- -
- -
-
- -
- - - -
    -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • -
-
- -
- - 3 items left - - - +
- -
+ {!!todos.length && }
); }; diff --git a/src/Components/Footer/TodosFilter.tsx b/src/Components/Footer/TodosFilter.tsx new file mode 100644 index 000000000..3bd4fc489 --- /dev/null +++ b/src/Components/Footer/TodosFilter.tsx @@ -0,0 +1,75 @@ +import cn from 'classnames'; +import React, { useContext } from 'react'; +import { TodosContext } from '../../Context/TodosContext'; +import { Status } from '../../Types/Status'; +import { Todos } from '../../Types/Todos'; + +export const TodosFilter: React.FC = () => { + const { + todos, + handleStatus, + handleDeleteCompleted, + status, + } = useContext(TodosContext); + + const activeLength = todos.filter((todo: Todos) => !todo.completed); + const completedTodos = todos.filter((todo: Todos) => todo.completed); + + const clearButton = completedTodos.length > 0; + + return ( + + ); +}; diff --git a/src/Components/Header/Header.tsx b/src/Components/Header/Header.tsx new file mode 100644 index 000000000..72577e91c --- /dev/null +++ b/src/Components/Header/Header.tsx @@ -0,0 +1,11 @@ +import { HeaderForm } from './HeaderForm/HeaderForm'; + +export const Header: React.FC = () => { + return ( +
+

todos

+ + +
+ ); +}; diff --git a/src/Components/Header/HeaderForm/HeaderForm.tsx b/src/Components/Header/HeaderForm/HeaderForm.tsx new file mode 100644 index 000000000..1e4b32e4c --- /dev/null +++ b/src/Components/Header/HeaderForm/HeaderForm.tsx @@ -0,0 +1,35 @@ +import React, { useContext, useState } from 'react'; +import { TodosContext } from '../../../Context/TodosContext'; + +export const HeaderForm : React.FC = () => { + const [message, setMessage] = useState(''); + + const { handleTodo } = useContext(TodosContext); + + const handleChange = (event: React.ChangeEvent) => { + setMessage(event.target.value); + }; + + const handleKeyDown = (event: React.FormEvent) => { + event.preventDefault(); + + if (message.trim()) { + handleTodo(message); + + setMessage(''); + } + }; + + return ( +
+ +
+ ); +}; diff --git a/src/Components/Main/Main.tsx b/src/Components/Main/Main.tsx new file mode 100644 index 000000000..07230e2d3 --- /dev/null +++ b/src/Components/Main/Main.tsx @@ -0,0 +1,24 @@ +import React, { useContext } from 'react'; +import { TodosContext } from '../../Context/TodosContext'; +import { TodoList } from './TodoList/TodoList'; + +export const Main: React.FC = () => { + const { + handleAllCompleted, + } = useContext(TodosContext); + + return ( +
+ + + + +
+ ); +}; diff --git a/src/Components/Main/TodoItem/TodoItem.tsx b/src/Components/Main/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..d1e184ed9 --- /dev/null +++ b/src/Components/Main/TodoItem/TodoItem.tsx @@ -0,0 +1,122 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import cn from 'classnames'; +import React, { + useContext, useEffect, useRef, useState, +} from 'react'; +import { TodosContext } from '../../../Context/TodosContext'; +import { Todos } from '../../../Types/Todos'; + +type Props = { + todo: Todos +}; + +const ENTER_KEY = 'Enter'; + +export const TodoItem: React.FC = ({ todo }) => { + const { + handleCompleted, + handleUpdateTodo, + handleDeleteTodo, + } = useContext(TodosContext); + + const [isEdit, setIsEdit] = useState(false); + const [editingText, setEditingText] = useState(todo.title); + + const inputFocus = useRef(null); + + useEffect(() => { + if (inputFocus.current) { + inputFocus.current.focus(); + } + }, [isEdit]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === ENTER_KEY && editingText) { + setIsEdit(false); + handleUpdateTodo(todo.id, editingText); + } + + if (event.key === ENTER_KEY && !editingText) { + setIsEdit(false); + handleDeleteTodo(todo.id); + } + }; + + const handleBlur = () => { + if (editingText) { + setIsEdit(false); + handleUpdateTodo(todo.id, editingText); + } + + if (!editingText) { + setIsEdit(false); + handleDeleteTodo(todo.id); + } + }; + + const handleOnKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEdit(false); + setEditingText(todo.title); + handleUpdateTodo(todo.id, todo.title); + } + }; + + const handleInputChange = (event: React.ChangeEvent) => { + setEditingText(event.target.value); + }; + + const handleLable = () => { + setIsEdit(true); + }; + + return ( +
  • +
    + handleCompleted(todo.id)} + /> + + {isEdit + ? ( + + ) + : ( + + )} + +
    +
  • + ); +}; diff --git a/src/Components/Main/TodoList/TodoList.tsx b/src/Components/Main/TodoList/TodoList.tsx new file mode 100644 index 000000000..a340e467c --- /dev/null +++ b/src/Components/Main/TodoList/TodoList.tsx @@ -0,0 +1,45 @@ +import React, { useContext } from 'react'; +import { TodosContext } from '../../../Context/TodosContext'; +import { Status } from '../../../Types/Status'; +import { Todos } from '../../../Types/Todos'; +import { TodoItem } from '../TodoItem/TodoItem'; + +export const TodoList: React.FC = () => { + const { + todos, + status, + } = useContext(TodosContext); + + let filterTodos = todos; + + const activeFilter = filterTodos.filter((todo: Todos) => !todo.completed); + const completedFilter = filterTodos.filter((todo: Todos) => todo.completed); + + switch (status) { + case Status.All: + filterTodos = todos; + break; + + case Status.Active: + filterTodos = activeFilter; + break; + + case Status.Completed: + filterTodos = completedFilter; + break; + + default: + break; + } + + return ( +
      + {filterTodos.map((todo) => ( + + ))} +
    + ); +}; diff --git a/src/Context/TodosContext.tsx b/src/Context/TodosContext.tsx new file mode 100644 index 000000000..8467bfdfb --- /dev/null +++ b/src/Context/TodosContext.tsx @@ -0,0 +1,109 @@ +import React, { useState, useMemo } from 'react'; +import { Status } from '../Types/Status'; +import { Todos } from '../Types/Todos'; +import { useLocalStorage } from '../Hooks/useLocalStorage'; + +type Props = { + children: React.ReactNode +}; + +interface Context { + todos: Todos[], + status: Status, + handleTodo: (newTodo: string) => void, + handleCompleted: (todoId: number) => void, + handleAllCompleted: () => void, + handleDeleteCompleted: () => void, + handleStatus: (newStatus: Status) => void, + handleUpdateTodo: (changeId: number, updateTitle: string) => void, + handleDeleteTodo: (deleteId: number) => void +} +export const TodosContext = React.createContext({ + todos: [], + status: Status.All, + handleTodo: () => { }, + handleCompleted: () => { }, + handleDeleteCompleted: () => { }, + handleStatus: () => { }, + handleAllCompleted: () => { }, + handleUpdateTodo: () => { }, + handleDeleteTodo: () => { }, +}); + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useLocalStorage('todos', []); + const [status, setStatus] = useState(Status.All); + + const handleDeleteCompleted = () => { + const deleteAllCompleted = todos.filter(todo => !todo.completed); + + setTodos(deleteAllCompleted); + }; + + const handleCompleted = (todoId: number) => { + setTodos(todos.map(todo => (todo.id === todoId + ? { ...todo, completed: !todo.completed } + : todo))); + }; + + const handleAllCompleted = () => { + const statusCompleted = todos.some(todo => !todo.completed); + + if (statusCompleted) { + setTodos(todos.map(todo => (todo.completed === false + ? { ...todo, completed: !todo.completed } + : todo))); + } else { + setTodos(todos.map( + todo => ({ ...todo, completed: !todo.completed }), + )); + } + }; + + const handleTodo = (newPost: string) => { + setTodos( + [ + ...todos, + { + id: +new Date(), + title: newPost, + completed: false, + }, + ], + ); + }; + + const handleUpdateTodo = (changeId: number, updateTitle: string) => { + setTodos(todos.map(todo => (todo.id === changeId + ? { ...todo, title: updateTitle } + : todo))); + }; + + const handleDeleteTodo = (deleteId: number) => { + const filteredTodos = todos.filter(todo => todo.id !== deleteId); + + setTodos(filteredTodos); + }; + + const handleStatus = (newStatus: Status) => { + setStatus(newStatus); + }; + + const value = useMemo(() => ({ + todos, + status, + handleTodo, + handleCompleted, + handleDeleteCompleted, + handleStatus, + handleAllCompleted, + handleUpdateTodo, + handleDeleteTodo, + }), [todos, status]); + + return ( + + {children} + + ); +}; diff --git a/src/Hooks/useLocalStorage.tsx b/src/Hooks/useLocalStorage.tsx new file mode 100644 index 000000000..ab8b8bc1d --- /dev/null +++ b/src/Hooks/useLocalStorage.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; + +export function useLocalStorage( + key: string, startValue: T, +): [T, (v: T) => void] { + const [value, setValue] = useState(() => { + const data = localStorage.getItem(key); + + if (data === null) { + return startValue; + } + + try { + return JSON.parse(data); + } catch (e) { + return startValue; + } + }); + + const save = (newValue: T) => { + localStorage.setItem(key, JSON.stringify(newValue)); + setValue(newValue); + }; + + return [value, save]; +} diff --git a/src/Types/Status.tsx b/src/Types/Status.tsx new file mode 100644 index 000000000..0d617f934 --- /dev/null +++ b/src/Types/Status.tsx @@ -0,0 +1,5 @@ +export enum Status { + All = '#/', + Active = '#/active', + Completed = '#/completed', +} diff --git a/src/Types/Todos.tsx b/src/Types/Todos.tsx new file mode 100644 index 000000000..3ff600cfd --- /dev/null +++ b/src/Types/Todos.tsx @@ -0,0 +1,5 @@ +export type Todos = { + id: number + title: string + completed: boolean +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..5cadc5b02 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,5 @@ import { createRoot } from 'react-dom/client'; +import { TodosProvider } from './Context/TodosContext'; import './styles/index.css'; import './styles/todo-list.css'; @@ -8,4 +9,8 @@ import { App } from './App'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +);