diff --git a/README.md b/README.md index 903c876f9..fd9388010 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,24 @@ Implement a simple [TODO app](https://mate-academy.github.io/react_todo-app/) th ![todoapp](./description/todoapp.gif) -1. Learn the markup in `App.tsx`. -2. Show only a field to create a new todo if there are no todos yet. -3. Use React Context to manage todos. -4. Each todo should have an `id` (you can use `+new Date()`), a `title`, and a `completed` status (`false` by default). -5. Save `todos` to `localStorage` using `JSON.stringify` after each change. -6. Display the number of not completed todos in `TodoApp`. -7. Implement filtering by status (`All`/`Active`/`Completed`). -8. Add the ability to delete a todo using the `x` button. -9. Implement the `clearCompleted` button (disabled if there are no completed todos). -10. Implement individual todo status toggling. -11. Implement the `toggleAll` checkbox (checked only when all todos are completed). -12. Enable inline editing for the `TodoItem`: +1. - Learn the markup in `App.tsx`. +2. - Show only a field to create a new todo if there are no todos yet. +3. - Use React Context to manage todos. +4. - Each todo should have an `id` (you can use `+new Date()`), a `title`, and a `completed` status (`false` by default). +5. - Save `todos` to `localStorage` using `JSON.stringify` after each change. +6. - Display the number of not completed todos in `TodoApp`. +7. - Implement filtering by status (`All`/`Active`/`Completed`). +8. - Add the ability to delete a todo using the `x` button. +9. - Implement the `clearCompleted` button (disabled if there are no completed todos). +10. - Implement individual todo status toggling. +11. - Implement the `toggleAll` checkbox (checked only when all todos are completed). +12. - Enable inline editing for the `TodoItem`: - Double-clicking on the todo title shows a text field instead of the title and `deleteButton`. - Form submission saves changes (press `Enter` to save). - Trim the saved text. - Delete the todo if the title is empty. - Save changes `onBlur`. - - Pressing `Escape` cancels editing (use `onKeyUp` and check if `event.key === 'Escape'`). + + Pressing `Escape` cancels editing (use `onKeyUp` and check if `event.key === 'Escape'`). ![todoedit](./description/edittodo.gif) @@ -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://RVDotsenko.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..ab957a32a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,186 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; +import { DispatchContext, StateContext } from './context/TodoContext'; +import { TodoComponent } from './components/Todo'; export const App: React.FC = () => { + const dispatch = useContext(DispatchContext); + const { todos, filteredTodos, todosToComplete, selectedFilter } = + useContext(StateContext); + + const [newTodoTitle, setNewTodoTitle] = useState(''); + + const inputRef = useRef(null); + const isHydrated = useRef(false); + + /* Hydrate todos from local storage */ + useEffect(() => { + const localTodos = localStorage.getItem('todos'); + + if (localTodos) { + dispatch({ + type: 'HYDRATE_TODOS', + payload: JSON.parse(localTodos), + }); + } + + isHydrated.current = true; + }, [dispatch]); + + /* Save todos to local storage */ + useEffect(() => { + if (!isHydrated.current) { + return; + } + + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const handleSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + + const trimmedTitle = newTodoTitle.trim(); + + if (trimmedTitle.length > 0) { + dispatch({ type: 'ADD_TODO', payload: { title: trimmedTitle } }); + setNewTodoTitle(''); + + if (inputRef.current) { + inputRef.current.focus(); + } + } + }; + + const handleGlobalClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + + if ( + target.closest('[data-cy="TodoDelete"]') || + target.closest('[data-cy="ClearCompletedButton"]') + ) { + inputRef.current?.focus(); + } + }; + return ( -
+
handleGlobalClick(e)}>

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 - - - -
+ {filteredTodos.map(todo => ( + + ))}
- {/* Hide the footer if there are no todos */} - + Clear completed + + + )}
); diff --git a/src/components/Todo.tsx b/src/components/Todo.tsx new file mode 100644 index 000000000..63b19286f --- /dev/null +++ b/src/components/Todo.tsx @@ -0,0 +1,91 @@ +import { useContext } from 'react'; +import { Todo } from '../types/todo'; +import cn from 'classnames'; +import { DispatchContext } from '../context/TodoContext'; +import { useTodoEdit } from '../hooks/useTodoEdit'; + +type Props = { + todo: Todo; +}; + +export const TodoComponent: React.FC = ({ todo }) => { + const dispatch = useContext(DispatchContext); + const { isEditing, setIsEditing, newTodoTitle, setNewTodoTitle, handleSave } = + useTodoEdit(todo); + + const checkboxId = `todo__status-${todo.id}`; + + return ( +
+ + + {isEditing ? ( +
{ + event.preventDefault(); + handleSave(); + }} + > + setNewTodoTitle(e.target.value)} + onBlur={handleSave} + onKeyDown={e => { + if (e.key !== 'Escape') { + return; + } + + setIsEditing(false); + setNewTodoTitle(todo.title); + }} + autoFocus + /> +
+ ) : ( + setIsEditing(true)} + > + {todo.title} + + )} + + {!isEditing && ( + + )} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..5c66b2219 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,137 @@ +import { createContext, useReducer } from 'react'; +import { Todo } from '../types/todo'; +import { TodoAction } from '../types/actions'; + +export interface State { + todos: Todo[]; + selectedFilter: 'all' | 'active' | 'completed'; +} + +export interface ContextState extends State { + filteredTodos: Todo[]; + todosToComplete: number; +} + +function reducer(state: State, action: TodoAction): State { + switch (action.type) { + case 'HYDRATE_TODOS': + return { + ...state, + todos: action.payload, + }; + + case 'ADD_TODO': + return { + ...state, + todos: [ + ...state.todos, + { + id: Date.now().toString(), + title: action.payload.title, + completed: false, + }, + ], + }; + + case 'UPDATE_TODO': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, title: action.payload.title } + : todo, + ), + }; + + case 'TOGGLE_TODO': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, completed: !todo.completed } + : todo, + ), + }; + + case 'CHECK_AS_COMPLETED': + return { + ...state, + todos: state.todos.map(todo => + action.payload.completed + ? { ...todo, completed: false } + : { ...todo, completed: true }, + ), + }; + + case 'CLEAR_COMPLETED': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed), + }; + + case 'SET_SELECTED_FILTER': + return { + ...state, + selectedFilter: action.payload.selectedFilter, + }; + + case 'DELETE_TODO': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload.id), + }; + + default: + return state; + } +} + +const initialState: State = { + todos: [], + selectedFilter: 'all', +}; + +export const StateContext = createContext({ + ...initialState, + filteredTodos: [], + todosToComplete: 0, +}); +export const DispatchContext = createContext>( + () => {}, +); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + const todosToComplete = state.todos.filter(todo => !todo.completed).length; + + const filteredTodos = state.todos.filter(todo => { + if (state.selectedFilter === 'active') { + return !todo.completed; + } + + if (state.selectedFilter === 'completed') { + return todo.completed; + } + + return true; + }); + + const contextValue = { + ...state, + todosToComplete, + filteredTodos, + }; + + return ( + + + {children} + + + ); +}; diff --git a/src/hooks/useTodoEdit.ts b/src/hooks/useTodoEdit.ts new file mode 100644 index 000000000..af263997f --- /dev/null +++ b/src/hooks/useTodoEdit.ts @@ -0,0 +1,43 @@ +import { useState, useContext } from 'react'; +import { DispatchContext } from '../context/TodoContext'; +import { Todo } from '../types/todo'; + +export const useTodoEdit = (todo: Todo) => { + const dispatch = useContext(DispatchContext); + const [isEditing, setIsEditing] = useState(false); + const [newTodoTitle, setNewTodoTitle] = useState(todo.title); + + const handleSave = () => { + const trimmedTitle = newTodoTitle.trim(); + + setNewTodoTitle(trimmedTitle); + + if (trimmedTitle.length === 0) { + setIsEditing(false); + + dispatch({ type: 'DELETE_TODO', payload: { id: todo.id } }); + + return; + } + + if (newTodoTitle.trim() === todo.title.trim()) { + setIsEditing(false); + + return; + } + + dispatch({ + type: 'UPDATE_TODO', + payload: { id: todo.id, title: newTodoTitle.trim() }, + }); + setIsEditing(false); + }; + + return { + isEditing, + setIsEditing, + newTodoTitle, + setNewTodoTitle, + handleSave, + }; +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..6a134f58b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,15 @@ import { createRoot } from 'react-dom/client'; import './styles/index.scss'; +import 'bulma/css/bulma.min.css'; import { App } from './App'; +import { GlobalProvider } from './context/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/store/GlobaslContext.tsx b/src/store/GlobaslContext.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..c6e18bd03 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,3 +1,7 @@ +:root { + background-color: white; +} + iframe { display: none; } diff --git a/src/types/actions.ts b/src/types/actions.ts new file mode 100644 index 000000000..aa8b1b6ff --- /dev/null +++ b/src/types/actions.ts @@ -0,0 +1,15 @@ +import { Todo } from './todo'; + +// prettier-ignore +export type TodoAction = + | { type: 'HYDRATE_TODOS'; payload: Todo[] } + | { type: 'ADD_TODO'; payload: { title: string } } + | { type: 'UPDATE_TODO'; payload: { id: string; title: string } } + | { type: 'TOGGLE_TODO'; payload: { id: string } } + | { type: 'CHECK_AS_COMPLETED'; payload: { completed: boolean } } + | { type: 'DELETE_TODO'; payload: { id: string } } + | { type: 'CLEAR_COMPLETED' } + | { + type: 'SET_SELECTED_FILTER'; + payload: { selectedFilter: 'all' | 'active' | 'completed' }; + }; diff --git a/src/types/todo.ts b/src/types/todo.ts new file mode 100644 index 000000000..18c5f945f --- /dev/null +++ b/src/types/todo.ts @@ -0,0 +1,5 @@ +export type Todo = { + id: string; + title: string; + completed: boolean; +};