diff --git a/README.md b/README.md index 903c876f9..7fdc427d0 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://vl-tsr.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..59ed454ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,13 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { TodoApp } from './components/TodoApp'; +import { TodoProvider } from './context/TodoContext'; + 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 */} - -
-
-
+ + + ); }; diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..93a16863e --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,20 @@ +import { useTodoContext } from '../context/TodoContext'; +import { TodoFooter } from './TodoFooter'; +import { TodoHeader } from './TodoHeader'; +import { TodoList } from './TodoList'; + +export const TodoApp = () => { + const { todos } = useTodoContext(); + + return ( +
+

todos

+ +
+ + + {!!todos.length && } +
+
+ ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 000000000..df5c9c9e4 --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,57 @@ +import cn from 'classnames'; +import { useTodoContext } from '../context/TodoContext'; +import { TodoFilters } from '../types/TodoFilter'; + +export const TodoFooter = () => { + const { + uncompletedTodos, + todoFilter, + setTodoFilter, + completedTodos, + dispatch, + newTodoField, + } = useTodoContext(); + + const filterOptions = [ + TodoFilters.all, + TodoFilters.active, + TodoFilters.completed, + ]; + + const handleCompleted = () => { + dispatch({ type: 'clearCompleted' }); + newTodoField.current?.focus(); + }; + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 000000000..bfb46c53b --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { useTodoContext } from '../context/TodoContext'; +import cn from 'classnames'; + +export const TodoHeader = () => { + const [query, setQuery] = useState(''); + const { dispatch, newTodoField, todos, completedTodos } = useTodoContext(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedQuery = query.trim(); + + if (!trimmedQuery) { + return; + } + + dispatch({ type: 'addTodo', payload: trimmedQuery }); + setQuery(''); + }; + + const handleToggleAll = () => { + if (todos.length === completedTodos.length) { + dispatch({ type: 'toggleAll' }); + } else { + dispatch({ type: 'allCompleted' }); + } + }; + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..49cd75ff4 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,118 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; +import { useTodoContext } from '../context/TodoContext'; + +interface Props { + todo: Todo; +} + +export const TodoItem: React.FC = ({ todo }) => { + const { dispatch, newTodoField } = useTodoContext(); + + const [editingTodo, setEditingTodo] = useState(null); + const editingTodoElement = useRef(null); + + useEffect(() => { + if (editingTodo) { + editingTodoElement.current?.focus(); + } + }, [editingTodo]); + + const handleDeleteTodo = () => { + dispatch({ type: 'deleteTodo', payload: todo.id }); + newTodoField.current?.focus(); + }; + + const handleToggleTodo = ( + e: React.ChangeEvent, + id: number, + ) => { + dispatch({ + type: 'toggleTodo', + payload: { id, checked: e.target.checked }, + }); + }; + + const updateTodo = () => { + const trimmedValue = editingTodo?.title.trim(); + + if (!trimmedValue) { + dispatch({ type: 'deleteTodo', payload: todo.id }); + } else { + dispatch({ + type: 'editTodo', + payload: { id: todo.id, title: trimmedValue }, + }); + } + + setEditingTodo(null); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + updateTodo(); + }; + + return ( +
+ + + {editingTodo?.id === todo.id ? ( +
handleSubmit(e)}> + { + if (e.key === 'Escape') { + setEditingTodo(null); + } + }} + onBlur={() => { + updateTodo(); + setEditingTodo(null); + }} + onChange={e => + setEditingTodo(prev => + prev ? { ...prev, title: e.target.value } : null, + ) + } + value={editingTodo.title} + /> +
+ ) : ( + <> + setEditingTodo(todo)} + data-cy="TodoTitle" + className="todo__title" + > + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..9c659c92f --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,14 @@ +import { useTodoContext } from '../context/TodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList = () => { + const { preparedTodos } = useTodoContext(); + + return ( +
+ {preparedTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..702593b8a --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,89 @@ +import React, { + createContext, + useContext, + useEffect, + useReducer, + useRef, + useState, +} from 'react'; +import { Todo } from '../types/Todo'; +import { Action, reducer } from './TodoReducer'; +import { TodoFilters } from '../types/TodoFilter'; + +type TodoContextType = { + dispatch: React.Dispatch; + setTodoFilter: React.Dispatch>; + todoFilter: TodoFilters; + todos: Todo[]; + uncompletedTodos: Todo[]; + completedTodos: Todo[]; + preparedTodos: Todo[]; + newTodoField: React.RefObject; +}; + +export const TodoContext = createContext(null); + +export const useTodoContext = () => { + const context = useContext(TodoContext); + + if (!context) { + throw new Error('TodoContext is not available'); + } + + return context; +}; + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todoFilter, setTodoFilter] = useState(TodoFilters.all); + const [todos, dispatch] = useReducer( + reducer, + JSON.parse(localStorage.getItem('todos') || '[]'), + ); + + const newTodoField = useRef(null); + + const uncompletedTodos = todos.filter(todo => !todo.completed); + const completedTodos = todos.filter(todo => todo.completed); + + const preparedTodos = todos.filter(todo => { + switch (todoFilter) { + case TodoFilters.active: + return !todo.completed; + + case TodoFilters.completed: + return todo.completed; + + default: + return true; + } + }); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + useEffect(() => { + newTodoField.current?.focus(); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/context/TodoReducer.ts b/src/context/TodoReducer.ts new file mode 100644 index 000000000..61d26f2e9 --- /dev/null +++ b/src/context/TodoReducer.ts @@ -0,0 +1,49 @@ +import { Todo } from '../types/Todo'; + +export type Action = + | { type: 'addTodo'; payload: string } + | { type: 'deleteTodo'; payload: number } + | { type: 'clearCompleted' } + | { type: 'toggleTodo'; payload: { id: number; checked: boolean } } + | { type: 'allCompleted' } + | { type: 'toggleAll' } + | { type: 'editTodo'; payload: { id: number; title: string } }; + +export const reducer = (state: Todo[], action: Action): Todo[] => { + switch (action.type) { + case 'addTodo': + return [ + ...state, + { title: action.payload, completed: false, id: Date.now() }, + ]; + + case 'deleteTodo': + return state.filter(todo => todo.id !== action.payload); + + case 'clearCompleted': + return state.filter(todo => !todo.completed); + + case 'toggleTodo': + return state.map(todo => + todo.id === action.payload.id + ? { ...todo, completed: action.payload.checked } + : todo, + ); + + case 'allCompleted': + return state.map(todo => ({ ...todo, completed: true })); + + case 'toggleAll': + return state.map(todo => ({ ...todo, completed: !todo.completed })); + + case 'editTodo': + return state.map(todo => + todo.id === action.payload.id + ? { ...todo, title: action.payload.title } + : todo, + ); + + default: + return state; + } +}; 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/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..181388562 --- /dev/null +++ b/src/types/TodoFilter.ts @@ -0,0 +1,5 @@ +export enum TodoFilters { + all = 'All', + active = 'Active', + completed = 'Completed', +}