diff --git a/README.md b/README.md index 903c876f9..a84631e18 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://SerhiyShimko.github.io/react_todo-app/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index 1f19b4743..93bd92752 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1170,10 +1170,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.9.12", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", - "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/src/App.tsx b/src/App.tsx index a399287bd..69600aeee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,131 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { TodoList } from './components/TodoList/TodoList'; +import { Filter } from './components/Filter/Filter'; +import { TodoType } from './types/TodoType'; +import classNames from 'classnames'; +import { FilteringStatus } from './types/FilteringStatys'; +import { useTodos } from './context/TodosContext'; +import { focusInput } from './utils/focusInput'; export const App: React.FC = () => { + const { inputFocus, todos, setTodos } = useTodos(); + const [newTodo, setNewTodo] = useState(''); + const [filteringStatus, setFilteringStatus] = useState( + FilteringStatus.All, + ); + + useEffect(() => { + const list = JSON.parse(localStorage.getItem('todos') ?? '[]'); + + setTodos(list); + }, [setTodos]); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const onSubmit = useCallback(() => { + if (newTodo.trim() !== '') { + const createNewTodo: TodoType = { + id: +new Date(), + title: newTodo.trim(), + completed: false, + }; + + const createNewTodoList = [...todos, createNewTodo]; + + setTodos(current => [...current, createNewTodo]); + + localStorage.setItem('todos', JSON.stringify(createNewTodoList)); + setNewTodo(''); + focusInput(inputFocus); + } else { + setNewTodo(''); + } + }, [todos, newTodo, setTodos, inputFocus]); + + const clearCompleted = useCallback(() => { + const newTodos = todos.filter(todo => todo.completed === false); + + setTodos(newTodos); + localStorage.setItem('todos', JSON.stringify(newTodos)); + focusInput(inputFocus); + }, [todos, setTodos, inputFocus]); + + const allButton = useCallback(() => { + if (todos.every(todo => todo.completed === true)) { + const newTodos = todos.map(todo => ({ ...todo, completed: false })); + + setTodos(newTodos); + focusInput(inputFocus); + } else { + const newTodos = todos.map(todo => + todo.completed === false ? { ...todo, completed: true } : todo, + ); + + setTodos(newTodos); + focusInput(inputFocus); + } + }, [todos, setTodos, inputFocus]); + 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 + {todos.length > 0 && ( +
+ + {todos.filter(todo => todo.completed === false).length} items left - -
-
- - {/* Hide the footer if there are no todos */} - + Clear completed + + + )}
); diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx new file mode 100644 index 000000000..dbeb2f748 --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { FilteringStatus } from '../../types/FilteringStatys'; +import classNames from 'classnames'; + +type Props = { + setFilteringStatus: React.Dispatch>; +}; + +enum Selected { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} + +const filterButtons = [ + { + title: 'All', + href: '#/', + 'data-cy': 'FilterLinkAll', + selected: Selected.All, + status: FilteringStatus.All, + }, + { + title: 'Active', + href: '#/active', + 'data-cy': 'FilterLinkActive', + selected: Selected.Active, + status: FilteringStatus.Active, + }, + { + title: 'Completed', + href: '#/completed', + 'data-cy': 'FilterLinkCompleted', + selected: Selected.Completed, + status: FilteringStatus.Completed, + }, +]; + +export const Filter: React.FC = ({ setFilteringStatus }) => { + const [selected, setSelected] = useState(Selected.All); + + return ( + + ); +}; diff --git a/src/components/Todo/Todo.tsx b/src/components/Todo/Todo.tsx new file mode 100644 index 000000000..a1efb38e4 --- /dev/null +++ b/src/components/Todo/Todo.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useState } from 'react'; +import { TodoType } from '../../types/TodoType'; +import classNames from 'classnames'; +import { useTodos } from '../../context/TodosContext'; +import { focusInput } from '../../utils/focusInput'; + +type Props = { + todo: TodoType; +}; + +export const Todo: React.FC = ({ todo }) => { + const { inputFocus, setTodos } = useTodos(); + const [editingTodo, setEditingTodo] = useState(null); + const [newValue, setNewValue] = useState(''); + + const deleteTodo = useCallback( + (id: number | undefined) => { + if (id) { + setTodos(current => current.filter(oneTodo => oneTodo.id !== id)); + focusInput(inputFocus); + } + }, + [setTodos, inputFocus], + ); + + const editTodoTitle = useCallback( + () => + setTodos(current => + current.map(oneTodo => + oneTodo.id === editingTodo?.id + ? { ...oneTodo, title: newValue.trim() } + : oneTodo, + ), + ), + [editingTodo, setTodos, newValue], + ); + + const editTodoCompleted = useCallback( + (id: number, value: boolean) => { + setTodos(current => + current.map(oneTodo => + oneTodo.id === id ? { ...oneTodo, completed: !value } : oneTodo, + ), + ); + focusInput(inputFocus); + }, + [setTodos, inputFocus], + ); + + const editTodos = useCallback(() => { + if (newValue.trim() !== '') { + editTodoTitle(); + setEditingTodo(null); + setNewValue(''); + focusInput(inputFocus); + } else { + deleteTodo(editingTodo?.id); + setEditingTodo(null); + setNewValue(''); + focusInput(inputFocus); + } + }, [editingTodo, newValue, deleteTodo, editTodoTitle, inputFocus]); + + return ( +
+ + {todo.id !== editingTodo?.id ? ( + <> + { + setNewValue(todo.title); + setEditingTodo(todo); + }} + > + {todo.title} + + + + ) : ( +
editTodos()}> + setNewValue(e.target.value)} + onBlur={() => editTodos()} + onKeyUp={e => { + if (e.key === 'Escape') { + setEditingTodo(null); + setNewValue(''); + } + }} + autoFocus + /> +
+ )} +
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..67c545812 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,41 @@ +import React, { useEffect, useState } from 'react'; +import { TodoType } from '../../types/TodoType'; +import { FilteringStatus } from '../../types/FilteringStatys'; +import { Todo } from '../Todo/Todo'; + +type Props = { + todoList: TodoType[]; + filteringStatus: FilteringStatus; +}; + +export const TodoList: React.FC = ({ todoList, filteringStatus }) => { + const [filterTodoList, setFilterTodoList] = useState([]); + + useEffect(() => { + switch (filteringStatus) { + case FilteringStatus.All: + return setFilterTodoList(todoList); + + case FilteringStatus.Active: + return setFilterTodoList( + [...todoList].filter(todo => todo.completed === false), + ); + + case FilteringStatus.Completed: + return setFilterTodoList( + [...todoList].filter(todo => todo.completed === true), + ); + + default: + return setFilterTodoList(todoList); + } + }, [todoList, filteringStatus]); + + return ( +
+ {filterTodoList.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/TodosContext.tsx b/src/context/TodosContext.tsx new file mode 100644 index 000000000..b13615a52 --- /dev/null +++ b/src/context/TodosContext.tsx @@ -0,0 +1,35 @@ +import React, { createContext, useContext, useRef, useState } from 'react'; +import { TodoType } from '../types/TodoType'; + +interface TodosContextType { + todos: TodoType[]; + setTodos: React.Dispatch>; + inputFocus: React.MutableRefObject; +} + +const TodosContext = createContext(undefined); + +type Props = { + children: React.ReactNode; +}; + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const inputFocus = useRef(null); + + return ( + + {children} + + ); +}; + +export const useTodos = () => { + const context = useContext(TodosContext); + + if (!context) { + throw new Error('Error finding context'); + } + + return context; +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..fc71601c9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,12 @@ import { createRoot } from 'react-dom/client'; import './styles/index.scss'; import { App } from './App'; +import { TodosProvider } from './context/TodosContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..48121cbde 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,3 +1,7 @@ +* { + box-sizing: border-box; +} + iframe { display: none; } diff --git a/src/types/FilteringStatys.ts b/src/types/FilteringStatys.ts new file mode 100644 index 000000000..d92fa0091 --- /dev/null +++ b/src/types/FilteringStatys.ts @@ -0,0 +1,5 @@ +export enum FilteringStatus { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/types/TodoType.ts b/src/types/TodoType.ts new file mode 100644 index 000000000..94e7da434 --- /dev/null +++ b/src/types/TodoType.ts @@ -0,0 +1,5 @@ +export type TodoType = { + id: number; + title: string; + completed: boolean; +}; diff --git a/src/utils/focusInput.ts b/src/utils/focusInput.ts new file mode 100644 index 000000000..645b3ca51 --- /dev/null +++ b/src/utils/focusInput.ts @@ -0,0 +1,5 @@ +export const focusInput = ( + element: React.MutableRefObject, +) => { + return element ? element.current?.focus() : ''; +};