diff --git a/README.md b/README.md index c5078685e..698d2bcbc 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://MariaSnegireva.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 20e932bab..ef72a188a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,93 +1,12 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { TodoProvider } from './contexts/TodosContext'; +import { TodoApp } from './components/TodoApp'; export const App: React.FC = () => { return ( -
-
-

todos

- -
- -
-
- -
- - - -
    -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • -
-
- -
- - 3 items left - - - - - -
-
+ + + ); }; diff --git a/src/components/ClearComplitedButton.tsx b/src/components/ClearComplitedButton.tsx new file mode 100644 index 000000000..e479fd7da --- /dev/null +++ b/src/components/ClearComplitedButton.tsx @@ -0,0 +1,22 @@ +import React, { useContext } from 'react'; +import { TodosContext } from '../contexts/TodosContext'; + +export const ClearCompletedButton: React.FC = () => { + const { todos, setTodos } = useContext(TodosContext); + + const handleClear = () => { + const modifiedTodos = todos.filter(todo => !todo.completed); + + setTodos(modifiedTodos); + }; + + return ( + + ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..360857628 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,18 @@ +import React, { useContext } from 'react'; +import { TodoCount } from './TodoCount'; +import { ClearCompletedButton } from './ClearComplitedButton'; +import { TodosContext } from '../contexts/TodosContext'; +import { TodosFilter } from './TodosFilter'; + +export const Footer: React.FC = () => { + const { todos } = useContext(TodosContext); + const isAreCompleted = todos.filter(todo => todo.completed).length > 0; + + return ( +
+ + + {isAreCompleted && } +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..f995b9c61 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,45 @@ +import React, { useContext, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { TodosContext } from '../contexts/TodosContext'; + +export const Header: React.FC = () => { + const [title, setTitle] = useState(''); + const { todos, setTodos } = useContext(TodosContext); + + const handleSabmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (title.trim()) { + const newTodo: Todo = { + id: +(new Date()), + title, + completed: false, + }; + + setTitle(''); + + setTodos([...todos, newTodo]); + } + }; + + const handleSetTitle = (event:React.ChangeEvent) => { + setTitle(event.target.value); + }; + + return ( +
+

todos

+ +
+ +
+
+ ); +}; diff --git a/src/components/Main.tsx b/src/components/Main.tsx new file mode 100644 index 000000000..904eecc29 --- /dev/null +++ b/src/components/Main.tsx @@ -0,0 +1,35 @@ +import React, { useContext } from 'react'; +import { TodoList } from './TodoList'; +import { TodosContext } from '../contexts/TodosContext'; + +export const Main: React.FC = () => { + const { todos, setTodos } = useContext(TodosContext); + + const isChecked = todos.every(todo => todo.completed); + + const handleToggleAll = () => { + const isAllCompleted = todos.every(todo => todo.completed); + const modifiedTodos = todos.map(todo => ({ + ...todo, + completed: !isAllCompleted, + })); + + setTodos(modifiedTodos); + }; + + return ( +
+ + + + +
+ ); +}; diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..c10999b44 --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,23 @@ +import React, { useContext } from 'react'; +import { TodosContext } from '../contexts/TodosContext'; +import { Header } from './Header'; +import { Main } from './Main'; +import { Footer } from './Footer'; + +export const TodoApp: React.FC = () => { + const { todos } = useContext(TodosContext); + + const isContentDisplayed = todos.length > 0; + + return ( +
+
+ {isContentDisplayed && ( + <> +
+
+ + )} +
+ ); +}; diff --git a/src/components/TodoCount.tsx b/src/components/TodoCount.tsx new file mode 100644 index 000000000..fdc63f651 --- /dev/null +++ b/src/components/TodoCount.tsx @@ -0,0 +1,13 @@ +import React, { useContext } from 'react'; +import { TodosContext } from '../contexts/TodosContext'; + +export const TodoCount: React.FC = () => { + const { todos } = useContext(TodosContext); + const todosLeft = todos.filter(todo => !todo.completed).length; + + return ( + + {`${todosLeft} ${todosLeft === 1 ? 'item' : 'items'} left`} + + ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..867821b07 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,128 @@ +import React, { useContext, useState, useRef } from 'react'; +import { Todo } from '../types/Todo'; +import { TodoStatus } from '../types/TodoStatus'; +import { TodosContext } from '../contexts/TodosContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { todos, setTodos } = useContext(TodosContext); + const [newTitle, setNewTitle] = useState(todo.title); + const [isEditing, setIsEditing] = useState(false); + + const { id, title, completed } = todo; + + const editInputRef = useRef(null); + + const handleDelete = (todoId: number) => { + const filteredTodo = todos.filter(currentTodo => currentTodo.id !== todoId); + + setTodos(filteredTodo); + }; + + const handleToggle = () => { + const modifiedTodos = todos.map(currentTodo => { + return currentTodo.id === id + ? { + ...currentTodo, + completed: !currentTodo.completed, + } + : currentTodo; + }); + + setTodos(modifiedTodos); + }; + + const handleDoubleClick = () => { + setIsEditing(true); + + setTimeout(() => { + editInputRef.current?.focus(); + }, 1); + }; + + const saveChanges = (titleToSet: string) => { + if (!titleToSet) { + handleDelete(todo.id); + } else { + const modifiedTodos = todos.map(currentTodo => { + if (currentTodo.id === todo.id) { + return { + ...currentTodo, + title: titleToSet, + }; + } + + return currentTodo; + }); + + setTodos(modifiedTodos); + } + + setIsEditing(false); + }; + + const discardChanges = () => { + setNewTitle(todo.title); + setIsEditing(false); + }; + + const handleBlur = () => { + saveChanges(newTitle); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleBlur(); + } + + if (event.key === 'Escape') { + discardChanges(); + handleBlur(); + } + }; + + let todoClassName = TodoStatus.View; + + if (isEditing) { + todoClassName = TodoStatus.Editing; + } else if (completed) { + todoClassName = TodoStatus.Completed; + } + + return ( +
  • +
    + + +
    + setNewTitle(event.target.value)} + ref={editInputRef} + /> +
  • + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..77beae1dd --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,15 @@ +import React, { useContext } from 'react'; +import { TodoItem } from './TodoItem'; +import { TodosContext } from '../contexts/TodosContext'; + +export const TodoList: React.FC = () => { + const { isShownTodos } = useContext(TodosContext); + + return ( +
      + {isShownTodos.map(todo => ( + + ))} +
    + ); +}; diff --git a/src/components/TodosFilter.tsx b/src/components/TodosFilter.tsx new file mode 100644 index 000000000..7eab17f0c --- /dev/null +++ b/src/components/TodosFilter.tsx @@ -0,0 +1,30 @@ +import React, { useContext, useMemo } from 'react'; +import classNames from 'classnames'; +import { TodosContext } from '../contexts/TodosContext'; +import { Status } from '../types/Status'; + +export const TodosFilter: React.FC = () => { + const { selectedFilter, setSelectedFilter } = useContext(TodosContext); + + const filters = useMemo(() => { + return Object.values(Status); + }, []); + + return ( + + ); +}; diff --git a/src/contexts/TodosContext.tsx b/src/contexts/TodosContext.tsx new file mode 100644 index 000000000..86ba2cd84 --- /dev/null +++ b/src/contexts/TodosContext.tsx @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { Todo } from '../types/Todo'; +import { Status } from '../types/Status'; +import { useLocalStorage } from '../hooks/UseLocalStorage'; + +type Props = { + todos: Todo[]; + setTodos: (todosToSet: Todo[]) => void; + selectedFilter: Status; + setSelectedFilter: (action: Status) => void; + isShownTodos: Todo[] +}; + +export const TodosContext = React.createContext({ + todos: [], + setTodos: () => { }, + selectedFilter: Status.All, + setSelectedFilter: () => { }, + isShownTodos: [], +}); + +type PropsWithChildren = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useLocalStorage('todos', []); + const [selectedFilter, setSelectedFilter] = useState(Status.All); + + const isShownTodos = todos.filter(todo => { + if (selectedFilter === Status.All) { + return true; + } + + if (selectedFilter === Status.Active) { + return !todo.completed; + } + + return todo.completed; + }); + + return ( + + {children} + + ); +}; diff --git a/src/hooks/UseLocalStorage.tsx b/src/hooks/UseLocalStorage.tsx new file mode 100644 index 000000000..6d61318d6 --- /dev/null +++ b/src/hooks/UseLocalStorage.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; +import { Todo } from '../types/Todo'; + +type FunctionReturn = [Todo[], (todosToSet: Todo[]) => void]; + +export function useLocalStorage( + key: string, + initialTodos: Todo[], +): FunctionReturn { + const [todos, setTodos] = useState(() => { + try { + const storedData = localStorage.getItem(key); + + return storedData ? JSON.parse(storedData) : initialTodos; + } catch (e) { + return initialTodos; + } + }); + + const save = (todosToSet: Todo[]) => { + setTodos(todosToSet); + localStorage.setItem(key, JSON.stringify(todosToSet)); + }; + + return [todos, save]; +} diff --git a/src/types/Status.tsx b/src/types/Status.tsx new file mode 100644 index 000000000..dc864cc93 --- /dev/null +++ b/src/types/Status.tsx @@ -0,0 +1,5 @@ +export enum Status { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/types/Todo.tsx b/src/types/Todo.tsx new file mode 100644 index 000000000..f9e06b381 --- /dev/null +++ b/src/types/Todo.tsx @@ -0,0 +1,5 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; +} diff --git a/src/types/TodoStatus.tsx b/src/types/TodoStatus.tsx new file mode 100644 index 000000000..d76a4cec7 --- /dev/null +++ b/src/types/TodoStatus.tsx @@ -0,0 +1,5 @@ +export enum TodoStatus { + View = 'view', + Editing = 'editing', + Completed = 'completed', +}