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

todos

- -
-
- {/* this button should have `active` class only if all todos are completed */} - -
- - {/* 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.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..cd1d41a81 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,60 @@ +import classNames from 'classnames'; +import { FilterType } from '../types/FilterType'; +import { useContext } from 'react'; +import { TodoContext } from '../context/TodoContext'; + +// interface FooterProps { +// todos: Todo[]; +// todosType: FilterType; +// handleTodosTypeChange: (todosType: FilterType) => void; +// handleDeleteTodo: (todoId: number) => void; +// } + +export const Footer: React.FC = () => { + const { todos, todosType, handleDeleteTodo, handleTodosTypeChange } = + useContext(TodoContext)!; + const active = todos.filter(todo => !todo.completed).length; + const completed = todos.filter(todo => todo.completed); + + const handleDeleteButton = () => { + completed.map(todo => { + handleDeleteTodo(todo.id); + }); + }; + + return ( + todos.length > 0 && ( + + ) + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..e4743ae46 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,62 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { TodoContext } from '../context/TodoContext'; + +// interface HeaderProps { +// todos: Todo[]; +// handleAddTodo: (query: string) => void; +// handleToggling: (todosToToggle: Todo[]) => void; +// } + +export const Header: React.FC = () => { + const { todos, handleAddTodo, handleToggling } = useContext(TodoContext)!; + const [query, setQuery] = useState(''); + const [isDisabled, setIsDisabled] = useState(false); + + const newTodoInput = useRef(null); + + const allActive = todos.every(todo => todo.completed); + const toToggle = todos.filter(todo => todo.completed === allActive); + + useEffect(() => { + if (newTodoInput.current) { + newTodoInput.current.focus(); + } + }, [todos, isDisabled]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + setIsDisabled(true); + + handleAddTodo(query.trim()); + setQuery(''); + setIsDisabled(false); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..3f2668059 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,111 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; +import { useContext, useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { TodoContext } from '../context/TodoContext'; + +interface TodoItemProps { + todo: Todo; +} + +export const TodoItem: React.FC = ({ + todo: { id, title, completed }, +}) => { + const { handleUpdateTodo, handleDeleteTodo } = useContext(TodoContext)!; + const [isEditing, setIsEditing] = useState(false); + const [query, setQuery] = useState(title); + + const todoInputRef = useRef(null); + + useEffect(() => { + if (todoInputRef.current) { + todoInputRef.current.focus(); + } + }, [isEditing]); + + const updateTodo = (event: React.ChangeEvent) => { + handleUpdateTodo({ + id, + title, + completed: event.target.checked, + }); + }; + + const handleSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + + if (query === title) { + setIsEditing(false); + + return; + } + + if (!query.trim()) { + handleDeleteTodo(id); + + return; + } + + handleUpdateTodo({ id, title: query.trim(), completed }); + setIsEditing(false); + }; + + const handleOnBlur = () => { + setTimeout(() => { + setIsEditing(false); + handleSubmit(); + }, 100); + }; + + return ( +
+ + + {isEditing ? ( +
+ setQuery(event.target.value)} + onKeyUp={event => event.key === 'Escape' && setIsEditing(false)} + ref={todoInputRef} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..90e92a7ec --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,28 @@ +import { useContext } from 'react'; +import { TodoItem } from './TodoItem'; +import { TodoContext } from '../context/TodoContext'; + +// interface TodoListProps { +// todos: Todo[]; +// handleDeleteTodo: (todoId: number) => void; +// handleUpdateTodo: (updatedTodo: Todo) => void; +// } + +export const TodoList: React.FC = () => { + const { visibleTodos } = useContext(TodoContext)!; + + return ( +
+ {visibleTodos.map(todo => { + return ( + + ); + })} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..55faa2272 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,100 @@ +import React, { createContext, useEffect, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { FilterType } from '../types/FilterType'; +import { addTodo, deleteTodo, getAllTodos, updateTodo } from '../utils/todos'; + +interface TodoContextProps { + todos: Todo[]; + todosType: FilterType; + handleTodosTypeChange: (type: FilterType) => void; + handleAddTodo: (title: string) => void; + handleDeleteTodo: (id: number) => void; + handleUpdateTodo: (updatedTodo: Todo) => void; + handleToggling: (todosToToggle: Todo[]) => void; + visibleTodos: Todo[]; +} + +export const TodoContext = createContext( + undefined, +); + +export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [todos, setTodos] = useState([]); + const [todosType, setTodosType] = useState(FilterType.ALL); + + useEffect(() => { + setTodos(getAllTodos()); + }, []); + + const visibleTodos = todos.filter(todo => { + if (todosType === FilterType.ACTIVE) { + return !todo.completed; + } + + if (todosType === FilterType.COMPLETED) { + return todo.completed; + } + + return true; + }); + + const handleTodosTypeChange = (type: FilterType) => { + setTodosType(type); + }; + + const handleAddTodo = (title: string): void => { + if (title.length === 0) { + return; + } + + const newTodo = { id: +new Date(), title: title, completed: false } as Todo; + + addTodo(newTodo); + + setTodos(currentList => [...currentList, newTodo]); + }; + + const handleDeleteTodo = (todoId: number) => { + deleteTodo(todoId); + + setTodos(currentList => currentList?.filter(todo => todo.id !== todoId)); + }; + + const handleUpdateTodo = (updatedTodo: Todo) => { + updateTodo(updatedTodo); + + setTodos(currentList => { + return currentList?.map(todo => + todo.id === updatedTodo.id ? updatedTodo : todo, + ); + }); + }; + + const handleToggling = (todosToToggle: Todo[]) => { + todosToToggle.forEach(todoToToggle => { + handleUpdateTodo({ + ...todoToToggle, + completed: !todoToToggle.completed, + }); + }); + }; + + return ( + + {children} + + ); +}; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..2f9ee48e9 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,3 +1,7 @@ +* { + box-sizing: border-box; +} + .todoapp { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; diff --git a/src/types/FilterType.ts b/src/types/FilterType.ts new file mode 100644 index 000000000..181582e91 --- /dev/null +++ b/src/types/FilterType.ts @@ -0,0 +1,5 @@ +export enum FilterType { + 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/todos.ts b/src/utils/todos.ts new file mode 100644 index 000000000..26fc64e4a --- /dev/null +++ b/src/utils/todos.ts @@ -0,0 +1,34 @@ +import { Todo } from '../types/Todo'; + +export const getAllTodos = (): Todo[] => { + const todos: Todo[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + + if (key) { + const item = localStorage.getItem(key); + + if (item) { + const todo: Todo = JSON.parse(item); + + todos.push(todo); + } + } + } + + return todos.sort((todo1, todo2) => todo1.id - todo2.id); +}; + +export const addTodo = (todo: Todo) => { + localStorage.setItem(`${todo.id}`, JSON.stringify(todo)); +}; + +export const deleteTodo = (todoId: number) => { + localStorage.removeItem(`${todoId}`); +}; + +export const updateTodo = (updatedTodo: Todo) => { + localStorage.removeItem(`${updatedTodo.id}`); + localStorage.setItem(`${updatedTodo.id}`, JSON.stringify(updatedTodo)); +};