diff --git a/README.md b/README.md index 903c876f9..e8dabf8a2 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ Implement a simple [TODO app](https://mate-academy.github.io/react_todo-app/) th ![todoedit](./description/edittodo.gif) -## Instructions +## Instructions!!! - Install the Prettier Extension and use these [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. - 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://Nika-Andriy.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..e796059fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,10 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ 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 */} - -
-
-
- ); -}; +import { TodoProvider } from './components/TodoContext'; +import { TodoApp } from './components/TodoApp'; + +export const App: React.FC = () => ( + + + +); diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..6c6f31e68 --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useTodo } from './TodoContext'; +import { TodoHeader } from './TodoHeader'; +import { TodoFooter } from './TodoFooter'; +import { TodoList } from './TodoList'; + +export const TodoApp: React.FC = () => { + const { todos } = useTodo(); + + return ( +
+

todos

+ +
+ + + {todos.length > 0 && } + + {todos.length > 0 && } +
+
+ ); +}; diff --git a/src/components/TodoContext.tsx b/src/components/TodoContext.tsx new file mode 100644 index 000000000..9922a436c --- /dev/null +++ b/src/components/TodoContext.tsx @@ -0,0 +1,85 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { Filter } from '../types/Filter'; + +type TodoContextType = { + todos: Todo[]; + setTodos: React.Dispatch>; + title: string; + setTitle: React.Dispatch>; + editTitle: string; + setEditTitle: React.Dispatch>; + errorMessage: string; + setErrorMessage: React.Dispatch>; + editTodoId: number | null; + setEditTodoId: React.Dispatch>; + filter: Filter; + setFilter: React.Dispatch>; + inputRef: React.RefObject; +}; + +export const TodoContext = React.createContext({ + todos: [], + setTodos: () => {}, + title: '', + setTitle: () => {}, + editTitle: '', + setEditTitle: () => {}, + errorMessage: '', + setErrorMessage: () => {}, + editTodoId: null, + setEditTodoId: () => {}, + filter: Filter.All, + setFilter: () => {}, + inputRef: { current: null }, +}); + +export const useTodo = () => { + return useContext(TodoContext); +}; + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState(() => { + const savedTodos = localStorage.getItem('todos'); + + return savedTodos ? JSON.parse(savedTodos) : []; + }); + const [title, setTitle] = useState(''); + const [editTitle, setEditTitle] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [editTodoId, setEditTodoId] = useState(null); + const [filter, setFilter] = useState(Filter.All); + const inputRef = useRef(null); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + + inputRef.current?.focus(); + }, [todos]); + + return ( + + {children} + + ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 000000000..da472fb85 --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,67 @@ +import classNames from 'classnames'; +import { Filter } from '../types/Filter'; +import { useTodo } from './TodoContext'; + +export const TodoFooter: React.FC = () => { + const { todos, filter, setFilter, setTodos } = useTodo(); + const activeTodos = todos.filter(todo => !todo.completed).length; + const completedTodos = todos.filter(todo => todo.completed); + + const handleDeleteCompleted = () => { + setTodos(current => current.filter(t => !t.completed)); + }; + + const filterring = [ + { + title: 'All', + type: Filter.All, + dataCy: 'FilterLinkAll', + }, + { + title: 'Active', + type: Filter.Active, + dataCy: 'FilterLinkActive', + }, + { + title: 'Completed', + type: Filter.Completed, + dataCy: 'FilterLinkCompleted', + }, + ]; + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 000000000..36f5a98c0 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,73 @@ +import classNames from 'classnames'; +import { useTodo } from './TodoContext'; +import { Todo } from '../types/Todo'; + +export const TodoHeader: React.FC = () => { + const { todos, inputRef, title, setTitle, setTodos, setErrorMessage } = + useTodo(); + const areAllTodosCompleted = + todos.length > 0 ? todos.every(todo => todo.completed) : false; + + const handleChange: React.ChangeEventHandler = event => { + setErrorMessage(''); + setTitle(event.target.value); + }; + + const handleSubmit: React.FormEventHandler = event => { + event.preventDefault(); + + if (!title.trim()) { + setErrorMessage('Input cant be empty'); + + return; + } + + const maxTodoId = Math.max(0, ...todos.map(todo => todo.id)); + const newTodo: Todo = { + id: maxTodoId + 1, + title: title.trim(), + completed: false, + }; + + setTodos(current => [...current, newTodo]); + setTitle(''); + }; + + const handleChangeAll = () => { + setTodos(current => { + const shouldCompleteAll = current.some(todo => !todo.completed); + + return current.map(todo => ({ + ...todo, + completed: shouldCompleteAll, + })); + }); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..5aca6b610 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,135 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React from 'react'; +import classNames from 'classnames'; +import { useTodo } from './TodoContext'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { setTodos, editTodoId, setEditTodoId, editTitle, setEditTitle } = + useTodo(); + + const handleChange: React.ChangeEventHandler = event => { + setEditTitle(event.target.value); + }; + + const handleDelete = (id: number) => { + setTodos(current => current.filter(t => t.id !== id)); + }; + + const saveEditedTodo = () => { + const preparedTitle = editTitle.trim(); + + setTodos(current => { + if (!preparedTitle) { + return current.filter(t => t.id !== editTodoId); + } + + return current.map(t => { + if (t.id === editTodoId) { + return { ...t, title: preparedTitle }; + } + + return t; + }); + }); + + setEditTodoId(null); + }; + + const handleSubmit: React.FormEventHandler = event => { + event.preventDefault(); + saveEditedTodo(); + }; + + const handleBlur = () => { + saveEditedTodo(); + }; + + const handleCancel = () => { + setEditTodoId(null); + }; + + const handleKeyDown: React.KeyboardEventHandler = event => { + if (event.key === 'Escape') { + event.preventDefault(); + handleCancel(); + } + }; + + const handleDoubleCkick = () => { + setEditTodoId(todo.id); + setEditTitle(todo.title); + }; + + const handleClick = (id: number) => { + setTodos(current => + current.map(t => { + if (t.id === id) { + return { + ...t, + completed: !t.completed, + }; + } + + return t; + }), + ); + }; + + return ( +
+ + + {editTodoId === todo.id ? ( +
+ +
+ ) : ( + <> + handleDoubleCkick()} + > + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..d39d44fb8 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,33 @@ +import { Filter } from '../types/Filter'; +import { useTodo } from './TodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, filter } = useTodo(); + + const getVisibleTodos = () => { + switch (filter) { + case Filter.All: + return todos; + + case Filter.Active: + return todos.filter(todo => !todo.completed); + + case Filter.Completed: + return todos.filter(todo => todo.completed); + + default: + return todos; + } + }; + + const visibleTodos = getVisibleTodos(); + + return ( +
+ {visibleTodos.map(todo => { + return ; + })} +
+ ); +}; diff --git a/src/styles/todo-list.scss b/src/styles/todo-list.scss index 4576af434..cfb34ec2f 100644 --- a/src/styles/todo-list.scss +++ b/src/styles/todo-list.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/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..174408fd6 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..d94ea1bff --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export type Todo = { + id: number; + title: string; + completed: boolean; +};