diff --git a/README.md b/README.md index 903c876f9..55cbcfe3c 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://redfield-mp.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..325f00a16 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,23 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext } from 'react'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { TodoContext } from './context/TodoContext'; export const App: React.FC = () => { + const { todos } = useContext(TodoContext); + const hasTodos = todos.length > 0; + 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 - - - -
-
- +
+ {hasTodos && } {/* 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 */} - -
+ {hasTodos &&
}
); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..a19e5339a --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,70 @@ +import { useContext } from 'react'; +import { TodoContext } from '../context/TodoContext'; +import classNames from 'classnames'; + +export const Footer = () => { + const { todos, filter, setFilter, clearCompleted } = useContext(TodoContext); + const notCompletedTodosCount = todos.filter(todo => !todo.completed).length; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..a81e532dc --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,70 @@ +import React, { useContext, useState } from 'react'; +import { TodoContext } from '../context/TodoContext'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; + +export const Header = () => { + const [inputText, setInputText] = useState(''); + const { todos, setTodos, toggleAll, inputRef, focusInput } = + useContext(TodoContext); + + const allCompleted = todos.every(todo => todo.completed); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmedInputText = inputText.trim(); + + if (!trimmedInputText) { + return; + } + + const newTodo: Todo = { + id: Date.now(), + title: trimmedInputText, + completed: false, + }; + + setTodos(prevTodos => [...prevTodos, newTodo]); + + setInputText(''); + focusInput(); + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..29ff9015d --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,97 @@ +import { Todo } from '../types/Todo'; +import { useContext, useState } from 'react'; +import { TodoContext } from '../context/TodoContext'; +import classNames from 'classnames'; + +type TodoItemProps = { + todo: Todo; +}; + +export const TodoItem = ({ todo }: TodoItemProps) => { + const { removeTodo, toggleTodo, updateTodoTitle } = useContext(TodoContext); + const [isEditing, setIsEditing] = useState(false); + const [editedValue, setEditedValue] = useState(todo.title); + const handleRemoveButton = () => { + removeTodo(todo.id); + }; + + const handleSave = () => { + const trimmedValue = editedValue.trim(); + + if (!trimmedValue) { + removeTodo(todo.id); + } else { + updateTodoTitle(todo.id, trimmedValue); + } + + setIsEditing(false); + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {!isEditing && ( + <> + { + setIsEditing(true); + setEditedValue(todo.title); + }} + > + {todo.title} + + + + + )} + + {isEditing && ( +
{ + e.preventDefault(); + handleSave(); + }} + > + setEditedValue(e.target.value)} + onBlur={handleSave} + onKeyUp={e => { + if (e.key === 'Escape') { + setIsEditing(false); + setEditedValue(todo.title); + } + }} + /> +
+ )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..f5440bb78 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,26 @@ +import { useContext } from 'react'; +import { TodoContext } from '../context/TodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList = () => { + const { todos, filter } = useContext(TodoContext); + const filteredTodos = todos.filter(todo => { + switch (filter) { + case 'active': + return !todo.completed; + case 'completed': + return todo.completed; + case 'all': + default: + return true; + } + }); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..5ec8651be --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,111 @@ +import React, { + createContext, + ReactNode, + useEffect, + useRef, + useState, +} from 'react'; +import { Todo } from '../types/Todo'; +import { TodoFilter } from '../types/TodoFilter'; + +type TodoContextType = { + todos: Todo[]; + setTodos: React.Dispatch>; + removeTodo: (id: number) => void; + clearCompleted: () => void; + filter: TodoFilter; + setFilter: (filter: TodoFilter) => void; + toggleTodo: (id: number) => void; + toggleAll: () => void; + updateTodoTitle: (id: number, title: string) => void; + inputRef: React.RefObject; + focusInput: () => void; +}; + +const defaultContextValue: TodoContextType = { + todos: [], + setTodos: () => {}, + removeTodo: () => {}, + clearCompleted: () => {}, + filter: 'all', + setFilter: () => {}, + toggleTodo: () => {}, + toggleAll: () => {}, + updateTodoTitle: () => {}, + inputRef: { current: null }, + focusInput: () => {}, +}; + +export const TodoContext = createContext(defaultContextValue); + +export const TodoProvider = ({ children }: { children: ReactNode }) => { + const [todos, setTodos] = useState(() => { + try { + const storedTodos = localStorage.getItem('todos'); + + return storedTodos ? JSON.parse(storedTodos) : []; + } catch (e) { + return []; + } + }); + const [filter, setFilter] = useState('all'); + const inputRef = useRef(null); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const focusInput = () => inputRef.current?.focus(); + + const removeTodo = (id: number) => { + setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id)); + focusInput(); + }; + + const clearCompleted = () => { + setTodos(prevTodos => prevTodos.filter(todo => !todo.completed)); + focusInput(); + }; + + const toggleTodo = (id: number) => { + setTodos(prevTodos => + prevTodos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const toggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + + setTodos(prevTodos => + prevTodos.map(todo => ({ ...todo, completed: !allCompleted })), + ); + }; + + const updateTodoTitle = (id: number, title: string) => { + setTodos(prevTodos => + prevTodos.map(todo => (todo.id === id ? { ...todo, title } : todo)), + ); + }; + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..a4207b1fc 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 './context/TodoContext'; 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..17f421d64 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -20,6 +20,10 @@ body { pointer-events: none; } +* { + box-sizing: border-box; +} + @import './todoapp'; @import './todo-list'; @import './filters'; diff --git a/src/types/ErrorMessages.ts b/src/types/ErrorMessages.ts new file mode 100644 index 000000000..fa2dec70b --- /dev/null +++ b/src/types/ErrorMessages.ts @@ -0,0 +1,9 @@ +export const ERROR_MESSAGES = { + LOAD_FAIL: 'Unable to load todos', + EMPTY_TITLE: 'Title should not be empty', + ADD_FAIL: 'Unable to add a todo', + DELETE_FAIL: 'Unable to delete a todo', + UPDATE_FAIL: 'Unable to update a todo', +} as const; + +export type ErrorMessage = (typeof ERROR_MESSAGES)[keyof typeof ERROR_MESSAGES]; 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/types/TodoFilter.ts b/src/types/TodoFilter.ts new file mode 100644 index 000000000..5d80905a9 --- /dev/null +++ b/src/types/TodoFilter.ts @@ -0,0 +1 @@ +export type TodoFilter = 'all' | 'active' | 'completed';