diff --git a/README.md b/README.md index 903c876f9..ac6c571f4 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://devTym.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..31f7da40e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,24 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoList } from './components/TodoList'; +import { TodoFooter } from './components/TodoFooter'; +import { TodoProvider } from './contexts/TodoContext'; +import { ErrorNotification } from './components/ErrorNotification'; 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 */} - -
+ +
+

todos

+ +
+ + + +
+
-
+ ); }; diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 000000000..0c282929e --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,22 @@ +import React, { useContext } from 'react'; +import { TodoContext } from '../../contexts/TodoContext'; + +export const ErrorNotification: React.FC = () => { + const { errorMessage, setErrorMessage } = useContext(TodoContext); + + return ( + errorMessage && ( +
+
+ ) + ); +}; diff --git a/src/components/ErrorNotification/index.tsx b/src/components/ErrorNotification/index.tsx new file mode 100644 index 000000000..8cb478792 --- /dev/null +++ b/src/components/ErrorNotification/index.tsx @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..113158f57 --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,61 @@ +/* eslint-disable prettier/prettier */ +import React, { useContext } from 'react'; +import classNames from 'classnames'; +import { TodoContext } from '../../contexts/TodoContext'; +import { + TODO_FILTER_STATUS, + TodoFilterStatus, +} from '../../types/TodoFilterStatus'; + +export const TodoFooter: React.FC = () => { + const { todos, clearCompleted, filterStatus, setFilterStatus } = + useContext(TodoContext); + + const itemsLeft = todos.filter(todo => !todo.completed).length; + + const handleFilterClick = + (status: TodoFilterStatus) => + (event: React.MouseEvent) => { + event.preventDefault(); + setFilterStatus(status); + }; + + return ( + todos.length > 0 && ( + // {/* Hide the footer if there are no todos */} +
+ + {itemsLeft} 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/TodoFooter/index.tsx b/src/components/TodoFooter/index.tsx new file mode 100644 index 000000000..544d07114 --- /dev/null +++ b/src/components/TodoFooter/index.tsx @@ -0,0 +1 @@ +export * from './TodoFooter'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..3cf8f71d9 --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,46 @@ +import React, { useContext, useState } from 'react'; +import { TodoContext } from '../../contexts/TodoContext'; +import classNames from 'classnames'; + +export const TodoHeader: React.FC = () => { + const [title, setTitle] = useState(''); + const { todos, addTodo, toggleAll, newTitleFieldRef } = + useContext(TodoContext); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + addTodo(title); + setTitle(''); + }; + + const itemsLeftCount = todos.filter(todo => !todo.completed).length; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoHeader/index.tsx b/src/components/TodoHeader/index.tsx new file mode 100644 index 000000000..c4db4bc40 --- /dev/null +++ b/src/components/TodoHeader/index.tsx @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/components/TodoList/TodoItem.tsx b/src/components/TodoList/TodoItem.tsx new file mode 100644 index 000000000..a034e8890 --- /dev/null +++ b/src/components/TodoList/TodoItem.tsx @@ -0,0 +1,111 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { Todo } from '../../types/todo'; +import classNames from 'classnames'; +import { TodoContext } from '../../contexts/TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { removeTodo, updateTodo, newTitleFieldRef } = useContext(TodoContext); + const [title, setTitle] = useState(todo.title); + const [isEditing, setIsEditing] = useState(false); + const editTitleFieldRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editTitleFieldRef.current?.focus(); + } else { + newTitleFieldRef.current?.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditing]); + + const saveEditedTitle = () => { + const newTitle = title.trim(); + + setIsEditing(false); + + if (!newTitle) { + removeTodo(todo.id); + + return; + } + + if (newTitle === todo.title) { + return; + } + + updateTodo(todo.id, { title: newTitle }); + }; + + const handleEditTodoTitle = (event: React.FormEvent) => { + event.preventDefault(); + saveEditedTitle(); + }; + + const handleTitleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setTitle(todo.title); + setIsEditing(false); + } + }; + + return ( +
+ + + {!isEditing ? ( + <> + setIsEditing(true)} + > + {todo.title} + + {/* Remove button appears only on hover */} + + + ) : ( + // {/* This form is shown instead of the title and remove button */} +
+ setTitle(event.currentTarget.value)} + onKeyDown={handleTitleKeyDown} + onBlur={saveEditedTitle} + /> +
+ )} +
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..affd57f8c --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,32 @@ +import React, { useContext } from 'react'; +import { TodoItem } from './TodoItem'; +import { TodoContext } from '../../contexts/TodoContext'; +import { TODO_FILTER_STATUS } from '../../types/TodoFilterStatus'; + +export const TodoList: React.FC = () => { + const { todos, filterStatus } = useContext(TodoContext); + + const visibleTodos = todos.filter(todo => { + switch (filterStatus) { + case TODO_FILTER_STATUS.ACTIVE: + return !todo.completed; + + case TODO_FILTER_STATUS.COMPLETED: + return todo.completed; + + default: + return true; + } + }); + + return ( + todos.length > 0 && ( +
+ {/* This is a completed todo */} + {visibleTodos.map(todo => ( + + ))} +
+ ) + ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/contexts/TodoContext.tsx b/src/contexts/TodoContext.tsx new file mode 100644 index 000000000..56bea9b84 --- /dev/null +++ b/src/contexts/TodoContext.tsx @@ -0,0 +1,135 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/todo'; +import { todosService } from '../services/todosService'; +import { + TODO_FILTER_STATUS, + TodoFilterStatus, +} from '../types/TodoFilterStatus'; + +type TodoContextType = { + todos: Todo[]; + addTodo: (title: string) => void; + updateTodo: (todoId: number, data: Partial>) => void; + removeTodo: (id: number) => void; + toggleAll: () => void; + clearCompleted: () => void; + filterStatus: TodoFilterStatus; + setFilterStatus: (filter: TodoFilterStatus) => void; + newTitleFieldRef: React.RefObject; + errorMessage: string | null; + setErrorMessage: (message: string | null) => void; +}; + +export const TodoContext = React.createContext({ + todos: [], + addTodo: () => {}, + updateTodo: () => {}, + removeTodo: () => {}, + toggleAll: () => {}, + clearCompleted: () => {}, + filterStatus: TODO_FILTER_STATUS.ALL, + setFilterStatus: () => {}, + newTitleFieldRef: { current: null }, + errorMessage: null, + setErrorMessage: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState(() => { + return todosService.getTodos(); + }); + const [filterStatus, setFilterStatus] = useState( + TODO_FILTER_STATUS.ALL, + ); + const [errorMessage, setErrorMessage] = useState(null); + const newTitleFieldRef = useRef(null); + + useEffect(() => { + todosService.setTodos(todos); + }, [todos]); + + useEffect(() => { + newTitleFieldRef.current?.focus(); + }, [todos, filterStatus]); + + const addTodo = (title: string) => { + const normalizedTitle = title.trim(); + + if (!normalizedTitle) { + setErrorMessage('Title should not be empty'); + + return; + } + + setErrorMessage(null); + + setTodos(prev => { + const maxId = prev.reduce((acc, todo) => { + return acc > todo.id ? acc : todo.id; + }, 0); + + return [ + ...prev, + { + id: maxId + 1, + title: normalizedTitle, + completed: false, + }, + ]; + }); + }; + + const updateTodo = ( + updatedTodoId: number, + data: Partial>, + ) => { + setTodos(prev => + prev.map(todo => + todo.id === updatedTodoId ? { ...todo, ...data } : todo, + ), + ); + }; + + const removeTodo = (id: number) => { + setTodos(prev => prev.filter(todo => todo.id !== id)); + }; + + const toggleAll = () => { + const todosActive = todos.filter(todo => !todo.completed); + const newStatus = todosActive.length > 0; + + setTodos(prev => + prev.map(todo => { + return { ...todo, completed: newStatus }; + }), + ); + }; + + const clearCompleted = () => { + setTodos(prev => prev.filter(todo => !todo.completed)); + }; + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..37414c7f0 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 { TodoProvider } from './contexts/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/services/todosService.ts b/src/services/todosService.ts new file mode 100644 index 000000000..a46fd36b8 --- /dev/null +++ b/src/services/todosService.ts @@ -0,0 +1,28 @@ +import { Todo } from '../types/todo'; + +const TODOS_KEY = 'todos'; + +export const todosService = { + getTodos(): Todo[] { + const data = localStorage.getItem(TODOS_KEY); + + if (!data) { + return []; + } + + try { + return JSON.parse(data) as Todo[]; + } catch { + // log error + return []; + } + }, + + setTodos(todos: Todo[]) { + try { + localStorage.setItem(TODOS_KEY, JSON.stringify(todos)); + } catch { + // log error + } + }, +}; diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..8f4712eae 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -23,3 +23,4 @@ body { @import './todoapp'; @import './todo-list'; @import './filters'; +@import 'bulma/css/bulma.css'; diff --git a/src/types/TodoFilterStatus.ts b/src/types/TodoFilterStatus.ts new file mode 100644 index 000000000..8c17994cd --- /dev/null +++ b/src/types/TodoFilterStatus.ts @@ -0,0 +1,8 @@ +export const TODO_FILTER_STATUS = { + ALL: 'All', + ACTIVE: 'Active', + COMPLETED: 'Completed', +} as const; + +export type TodoFilterStatus = + (typeof TODO_FILTER_STATUS)[keyof typeof TODO_FILTER_STATUS]; 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; +};