diff --git a/README.md b/README.md index 903c876f9..772de732c 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://YuliiaKosenchuk.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..b501e0bf2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,18 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { Header } from './components/Header/Header'; +import { TodoList } from './components/TodoList/TodoList'; +import { Footer } from './components/Footer/Footer'; +import { TodoProvider } from './components/TodoContext/TodoContext'; -export const App: React.FC = () => { - return ( +export const App: React.FC = () => ( +

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 */} - -
+
+ +
- ); -}; +
+); diff --git a/src/AppCopy.tsx b/src/AppCopy.tsx new file mode 100644 index 000000000..1a0bd07d6 --- /dev/null +++ b/src/AppCopy.tsx @@ -0,0 +1,158 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +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 */} + +
+
+
+ ); +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..b28cf4616 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,50 @@ +import classNames from 'classnames'; +import { Status } from '../../types/Status'; +import { Todo } from '../../types/Todo'; +import { useContext } from 'react'; +import { TodoContext } from '../TodoContext/TodoContext'; + +export const Footer: React.FC = () => { + const { todos, filterStatus, setFilterStatus, handleClearComplete } = + useContext(TodoContext); + + const filtredActiveTodos = todos.filter((todo: Todo) => !todo.completed); + const filtredCompleteTodos = todos.filter((todo: Todo) => todo.completed); + + return ( + <> + {todos.length > 0 && ( + + )} + + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..bb261d5fe --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,50 @@ +import classNames from 'classnames'; +import { useContext, useEffect, useRef } from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoContext } from '../TodoContext/TodoContext'; + +export const Header: React.FC = () => { + const { addNewTodo, title, setTitle, todos, handleAllChangeStatus } = + useContext(TodoContext); + + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [todos]); + + const handleTitleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addNewTodo(title); + }; + + const isAllCompleteTodos = todos.every((todo: Todo) => todo.completed); + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/TodoContext/TodoContext.tsx b/src/components/TodoContext/TodoContext.tsx new file mode 100644 index 000000000..0d99d6f75 --- /dev/null +++ b/src/components/TodoContext/TodoContext.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useState } from 'react'; +import { Status } from '../../types/Status'; +import { Todo } from '../../types/Todo'; +import { getStoredTodos } from '../../utils/getStoredTodos'; +import { storeTodos } from '../../utils/storeTodos'; + +type TodoContextType = { + todos: Todo[]; + title: string; + filterStatus: Status; + setTitle: (title: string) => void; + setFilterStatus: (status: Status) => void; + addNewTodo: (newTitle: string) => void; + deleteSelectTodo: (todoId: number) => void; + handleEditTitle: (newTitle: string, currentTodo: Todo) => void; + handleUpdateComplete: (todo: Todo) => void; + handleClearComplete: () => void; + handleAllChangeStatus: () => void; + filteredTodos: Todo[]; +}; + +const defaultContext: TodoContextType = { + todos: [], + title: '', + filterStatus: Status.All, + setTitle: () => {}, + setFilterStatus: () => {}, + addNewTodo: () => {}, + deleteSelectTodo: () => {}, + handleEditTitle: () => {}, + handleUpdateComplete: () => {}, + handleClearComplete: () => {}, + handleAllChangeStatus: () => {}, + filteredTodos: [], +}; + +export const TodoContext = React.createContext(defaultContext); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [title, setTitle] = useState(''); + const [filterStatus, setFilterStatus] = useState(Status.All); + + useEffect(() => { + const currentTodos = getStoredTodos(); + + if (currentTodos.length) { + setTodos(currentTodos); + } else { + localStorage.setItem('todos', JSON.stringify([])); + } + }, []); + + const filterTodosByStatus = () => { + switch (filterStatus) { + case Status.Active: + return todos.filter((todo: Todo) => !todo.completed); + + case Status.Completed: + return todos.filter((todo: Todo) => todo.completed); + + default: + return todos; + } + }; + + const filteredTodos = filterTodosByStatus(); + + const addNewTodo = (newTitle: string) => { + const trimedTitle = newTitle.trim(); + + if (trimedTitle) { + const newTodo = { + title: trimedTitle, + completed: false, + id: +new Date(), + }; + const newTodos = [...todos, newTodo]; + + setTodos(newTodos); + storeTodos(newTodos); + setTitle(''); + } + }; + + const deleteSelectTodo = (todoId: number) => { + const updatedTodos = todos.filter(todo => todo.id !== todoId); + + setTodos(updatedTodos); + storeTodos(updatedTodos); + }; + + const handleEditTitle = (newTitle: string, currentTodo: Todo) => { + const currentTodos = todos.map((todo: Todo) => + todo.id === currentTodo.id ? { ...todo, title: newTitle } : todo, + ); + + setTodos(currentTodos); + storeTodos(currentTodos); + }; + + const handleUpdateComplete = (todo: Todo) => { + const currentTodos = todos.map((item: Todo) => + item.id === todo.id ? { ...item, completed: !item.completed } : item, + ); + + setTodos(currentTodos); + storeTodos(currentTodos); + }; + + const handleClearComplete = () => { + const currentTodos = todos.filter((todo: Todo) => !todo.completed); + + setTodos(currentTodos); + storeTodos(currentTodos); + }; + + const handleAllChangeStatus = () => { + const allCompleted = todos.every(todo => todo.completed); + + const changedTodos = todos.map((item: Todo) => ({ + ...item, + completed: allCompleted ? false : true, + })); + + setTodos(changedTodos); + storeTodos(changedTodos); + }; + + const valuesContext = { + todos, + title, + filterStatus, + setTitle, + setFilterStatus, + addNewTodo, + deleteSelectTodo, + handleEditTitle, + handleUpdateComplete, + handleClearComplete, + handleAllChangeStatus, + filteredTodos, + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..0fbcd0e5e --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,95 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; +import { useContext, useState } from 'react'; +import { TodoContext } from '../TodoContext/TodoContext'; +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { deleteSelectTodo, handleUpdateComplete, handleEditTitle } = + useContext(TodoContext); + + const { id, completed, title } = todo; + const [newTitle, setNewTitle] = useState(title); + const [showEditId, setShowEditId] = useState(null); + + const handleEditTitleTodo = () => { + if (newTitle === title) { + setShowEditId(null); + + return; + } + + if (newTitle.length === 0) { + deleteSelectTodo(id); + } else if (showEditId && newTitle.trim()) { + handleEditTitle(newTitle.trim(), todo); + setShowEditId(null); + } + }; + + const handleEscape = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setNewTitle(title); + setShowEditId(null); + } + }; + + return ( +
+ + {showEditId === id ? ( +
{ + e.preventDefault(); + handleEditTitleTodo(); + }} + > + setNewTitle(e.target.value)} + onBlur={handleEditTitleTodo} + onKeyUp={handleEscape} + autoFocus + /> +
+ ) : ( + <> + setShowEditId(id)} + > + {newTitle.trim()} + + + + )} +
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..025a28b3e --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { TodoContext } from '../TodoContext/TodoContext'; + +export const TodoList: React.FC = () => { + const { filteredTodos } = useContext(TodoContext); + + return ( +
+ {filteredTodos.map((todo: Todo) => ( + + ))} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..fbe5f132e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,8 @@ import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import './styles/index.scss'; +import './styles/todo.scss'; +import './styles/filter.scss'; import { App } from './App'; diff --git a/src/styles/todo.scss b/src/styles/todo.scss index 4576af434..cfb34ec2f 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -71,6 +71,7 @@ } &__title-field { + box-sizing: border-box; width: 100%; padding: 11px 14px; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..29383a1e2 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -56,6 +56,7 @@ } &__new-todo { + box-sizing: border-box; width: 100%; padding: 16px 16px 16px 60px; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 000000000..7ff532cf0 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + loadError = 'Unable to load todos', + titleError = 'Title should not be empty', + addError = 'Unable to add a todo', + deleteError = 'Unable to delete a todo', + updateError = 'Unable to update a todo', +} diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 000000000..dc864cc93 --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..f9e06b381 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; +} diff --git a/src/utils/getStoredTodos.ts b/src/utils/getStoredTodos.ts new file mode 100644 index 000000000..369a16776 --- /dev/null +++ b/src/utils/getStoredTodos.ts @@ -0,0 +1,7 @@ +import { Todo } from '../types/Todo'; + +export const getStoredTodos = (): Todo[] => { + const localTodos = localStorage.getItem('todos'); + + return localTodos ? JSON.parse(localTodos) : []; +}; diff --git a/src/utils/storeTodos.ts b/src/utils/storeTodos.ts new file mode 100644 index 000000000..22f014f17 --- /dev/null +++ b/src/utils/storeTodos.ts @@ -0,0 +1,5 @@ +import { Todo } from '../types/Todo'; + +export const storeTodos = (todos: Todo[]) => { + localStorage.setItem('todos', JSON.stringify(todos)); +};