diff --git a/README.md b/README.md index 903c876f9..8a887bb28 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://AndreaTkachuk.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..ce0d0b73a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,30 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect } from 'react'; +import { useAppContext } from './context/AppProvider'; +import { getTodos } from './api/api'; +import './styles/todoapp.scss'; +import { Header } from './components/Header/Header'; +import { Footer } from './components/Footer/Footer'; +import { TodoList } from './components/TodoList/TodoList'; export const App: React.FC = () => { + const { todos, setTodos } = useAppContext(); + + useEffect(() => { + setTodos(getTodos()); + }, [setTodos]); + 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 */} - -
+ {todos.length > 0 &&
}
); diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 000000000..6a480f428 --- /dev/null +++ b/src/api/api.ts @@ -0,0 +1,17 @@ +// import { Todo } from '../types/Todo'; + +const STORAGE_KEY = 'todos'; + +export const getTodos = () => { + const value = localStorage.getItem(STORAGE_KEY); + + if (!value) { + localStorage.setItem('todos', JSON.stringify([])); + + return []; + } + + const parsedValue = JSON.parse(value); + + return Array.isArray(parsedValue) ? parsedValue : []; +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..46b48473d --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,51 @@ +import classNames from 'classnames'; +import { Filter } from '../../utils/enamFilter'; +import { useAppContext } from '../../context/AppProvider'; + +export const Footer = () => { + const { todos, setTodos, filter, setFilter } = useAppContext(); + const leftItems = todos.filter(todo => !todo.completed).length; + + const clearCompleted = () => { + const todosCompleted = todos.filter(todo => !todo.completed); + + setTodos(todosCompleted); + localStorage.setItem('todos', JSON.stringify(todosCompleted)); + }; + + return ( +
+ + {leftItems} 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/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..9a472d455 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useRef } from 'react'; +import { useAppContext } from '../../context/AppProvider'; +import { Todo } from '../../types/Todo'; +import classNames from 'classnames'; + +export const Header: React.FC = () => { + const { todos, setTodos, title, setTitle, isEditingId } = useAppContext(); + const allTodoCompleted = todos.every(todo => todo.completed); + + const focus = useRef(null); + + useEffect(() => { + if (!isEditingId) { + focus.current?.focus(); + } + }, [isEditingId, todos]); + + const addTodo = (text: string) => { + const newTodo: Todo = { id: Date.now(), title: text, completed: false }; + + setTodos([...todos, newTodo]); + localStorage.setItem('todos', JSON.stringify([...todos, newTodo])); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + return; + } + + addTodo(title.trim()); + setTitle(''); + }; + + const handleMainInput = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const toggleAllTodos = () => { + const areAllCompleted = todos.every(todo => todo.completed); + + const toggledTodos = todos.map(todo => ({ + ...todo, + completed: !areAllCompleted, + })); + + setTodos(toggledTodos); + localStorage.setItem('todos', JSON.stringify(toggledTodos)); + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..525983eb6 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,139 @@ +import { useAppContext } from '../../context/AppProvider'; +import classNames from 'classnames'; +import { getFilteredItems } from '../../utils/utils'; +import React from 'react'; + +export const TodoList = () => { + const { + todos, + setTodos, + filter, + isEditingId, + setIsEditingId, + setEditTitle, + editTitle, + } = useAppContext(); + + const deleteTodo = (id: number) => { + const filteredTodos = todos.filter(todo => todo.id !== id); + + setTodos(filteredTodos); + localStorage.setItem('todos', JSON.stringify(filteredTodos)); + }; + + const handleCompleted = (id: number) => { + const todoToUpdate = todos.find(todo => todo.id === id); + + if (!todoToUpdate) { + return; + } + + const updatedAll = todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ); + + setTodos(updatedAll); + localStorage.setItem('todos', JSON.stringify(updatedAll)); + }; + + const handleInput = (event: React.ChangeEvent) => { + setEditTitle(event.target.value); + }; + + const handleDoubleClick = (id: number, value: string) => { + setIsEditingId(id); + setEditTitle(value); + }; + + const handleSubmit = (id: number) => { + if (editTitle.trim() === '') { + deleteTodo(id); + + return; + } + + const updatedAll = todos.map(todo => + todo.id === id ? { ...todo, title: editTitle.trim() } : todo, + ); + + setTodos(updatedAll); + localStorage.setItem('todos', JSON.stringify(updatedAll)); + setIsEditingId(0); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditingId(0); + setEditTitle(''); + } + }; + + const filteredTodos = getFilteredItems(todos, filter); + + return ( +
+ {/* This is a completed todo */} + {filteredTodos.map(({ id, title, completed }) => ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {isEditingId === id ? ( +
{ + event.preventDefault(); + handleSubmit(id); + }} + > + handleSubmit(id)} + onKeyUp={handleKeyUp} + onChange={handleInput} + autoFocus + /> +
+ ) : ( + { + handleDoubleClick(id, title); + }} + > + {title} + + )} + + {/* Remove button appears only on hover */} + {isEditingId !== id && ( + + )} +
+ ))} +
+ ); +}; diff --git a/src/context/AppProvider.tsx b/src/context/AppProvider.tsx new file mode 100644 index 000000000..7c2b68900 --- /dev/null +++ b/src/context/AppProvider.tsx @@ -0,0 +1,62 @@ +import React, { createContext, useContext, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { Filter } from '../utils/enamFilter'; + +interface AppState { + todos: Todo[] | []; + setTodos: (todos: Todo[]) => void; + title: string; + setTitle: (title: string) => void; + editTitle: string; + setEditTitle: (editTitle: string) => void; + filter: string; + setFilter: (filterBy: Filter) => void; + isEditingId: number; + setIsEditingId: (id: number) => void; +} + +const AppContext = createContext({ + todos: [], + setTodos: () => {}, + title: '', + setTitle: () => {}, + editTitle: '', + setEditTitle: () => {}, + filter: '', + setFilter: () => {}, + isEditingId: 0, + setIsEditingId: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const AppProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [title, setTitle] = useState(''); + const [editTitle, setEditTitle] = useState(''); + const [filter, setFilter] = useState(Filter.All); + const [isEditingId, setIsEditingId] = useState(0); + + const value: AppState = { + todos, + setTodos, + title, + setTitle, + editTitle, + setEditTitle, + filter, + setFilter, + isEditingId, + setIsEditingId, + }; + + return {children}; +}; + +export const useAppContext = (): AppState => { + const context = useContext(AppContext); + + return context; +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..1f59e5cba 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,16 @@ import { createRoot } from 'react-dom/client'; import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import './styles/todo.scss'; +import './styles/filters.scss'; import { App } from './App'; +import { AppProvider } from './context/AppProvider'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/filters.css b/src/styles/filters.scss similarity index 100% rename from src/styles/filters.css rename to src/styles/filters.scss diff --git a/src/styles/index.css b/src/styles/index.css index a34eec7c6..d8d324941 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -21,5 +21,5 @@ body { } @import './todoapp'; -@import './todo'; -@import './filter'; +@import './todo-list'; +@import './filters'; diff --git a/src/styles/todo-list.css b/src/styles/todo.scss similarity index 100% rename from src/styles/todo-list.css rename to src/styles/todo.scss diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..07e887a27 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,3 +1,6 @@ +@use './todo'; +@use './filters'; + .todoapp { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; @@ -56,7 +59,7 @@ } &__new-todo { - width: 100%; + width: 85%; padding: 16px 16px 16px 60px; font-size: 24px; 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/enamFilter.ts b/src/utils/enamFilter.ts new file mode 100644 index 000000000..174408fd6 --- /dev/null +++ b/src/utils/enamFilter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 000000000..4f14ffe30 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,21 @@ +import { Todo } from '../types/Todo'; +import { Filter } from './enamFilter'; + +export const toggleTodo = (todos: Todo[], id: number): Todo[] => { + return todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ); +}; + +export const clearTodos = (): Todo[] => []; + +export const getFilteredItems = (items: Todo[], filterBy: string): Todo[] => { + switch (filterBy) { + case Filter.Active: + return items.filter(item => !item.completed); + case Filter.Completed: + return items.filter(item => item.completed); + default: + return items; + } +};