diff --git a/README.md b/README.md index 903c876f9..80009bc49 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://gallik-dev.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..d79e895e3 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, { useState } from 'react'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { FilterStatus } from './types/FilterStatus'; export const App: React.FC = () => { + const [status, setStatus] = useState(FilterStatus.All); + 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/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..616f71600 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,59 @@ +import classNames from 'classnames'; +import { FilterStatus } from '../types/FilterStatus'; +import { useTodoDispatch, useTodoState } from './TodoProvider'; +import React from 'react'; + +type Props = { + status: FilterStatus; + setStatus: (status: FilterStatus) => void; +}; + +export const Footer: React.FC = ({ status, setStatus }) => { + const { todos } = useTodoState(); + const dispatch = useTodoDispatch(); + const activeTodos = todos.filter(todo => !todo.completed); + const hasCompletedTodos = todos.some(todo => todo.completed); + const filters = Object.values(FilterStatus); + + const handleClearCompleted = () => { + dispatch({ type: 'CLEAR_COMPLETED' }); + }; + + return ( + <> + {todos.length > 0 && ( + + )} + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..661e35628 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTodoDispatch, useTodoState } from './TodoProvider'; +import classNames from 'classnames'; + +export const Header = () => { + const [title, setTitle] = useState(''); + const { todos } = useTodoState(); + const allCompleted = todos.every(todo => todo.completed); + const dispatch = useTodoDispatch(); + const newTodoField = useRef(null); + + useEffect(() => { + if (newTodoField.current) { + newTodoField.current.focus(); + } + }, [todos]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = title.trim(); + + if (trimmedTitle === '') { + return; + } + + dispatch({ type: 'ADD', payload: trimmedTitle }); + + setTitle(''); + }; + + const handleToogleAll = () => { + dispatch({ type: 'TOGGLE_ALL', payload: !allCompleted }); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..3e4451c04 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,126 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ + +import React, { useState } from 'react'; +import { Todo } from '../types/todo'; +import classNames from 'classnames'; +import { useTodoDispatch } from './TodoProvider'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const [isEditing, setIsEditing] = useState(false); + const [newTitlte, setNewTitle] = useState(''); + const dispatch = useTodoDispatch(); + + const handleDelete = () => { + dispatch({ type: 'DELETE', payload: todo.id }); + }; + + const handleChange = () => { + dispatch({ + type: 'CHANGE', + payload: { + id: todo.id, + data: { completed: !todo.completed }, + }, + }); + }; + + const handleDoubleClick = () => { + setIsEditing(true); + setNewTitle(todo.title); + }; + + const savedTodo = () => { + const trimmedTitle = newTitlte.trim(); + + if (trimmedTitle === todo.title) { + setIsEditing(false); + + return; + } + + if (trimmedTitle === '') { + dispatch({ type: 'DELETE', payload: todo.id }); + + return; + } + + dispatch({ + type: 'CHANGE', + payload: { + id: todo.id, + data: { title: trimmedTitle }, + }, + }); + + setIsEditing(false); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + savedTodo(); + } else if (event.key === 'Escape') { + setNewTitle(''); + setIsEditing(false); + } + }; + + return ( + <> +
+ + {!isEditing ? ( + <> + + {todo.title} + + + + ) : ( +
+ setNewTitle(event.target.value)} + onBlur={savedTodo} + onKeyUp={handleKeyUp} + autoFocus + /> +
+ )} +
+ + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..a0c348cf7 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,30 @@ +import React, { useMemo } from 'react'; +import { FilterStatus } from '../types/FilterStatus'; +import { TodoItem } from './TodoItem'; +import { useTodoState } from './TodoProvider'; + +type Props = { + status: FilterStatus; +}; + +export const TodoList: React.FC = ({ status }) => { + const { todos } = useTodoState(); + + const visibleTodos = useMemo(() => { + if (status === FilterStatus.Active) { + return todos.filter(todo => !todo.completed); + } else if (status === FilterStatus.Completed) { + return todos.filter(todo => todo.completed); + } + + return todos; + }, [todos, status]); + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodoProvider.tsx b/src/components/TodoProvider.tsx new file mode 100644 index 000000000..652beaec4 --- /dev/null +++ b/src/components/TodoProvider.tsx @@ -0,0 +1,113 @@ +import React, { useContext, useEffect, useReducer } from 'react'; +import { Todo } from '../types/todo'; + +interface State { + todos: Todo[]; +} + +type Action = + | { type: 'ADD'; payload: string } + | { type: 'DELETE'; payload: number } + | { type: 'CHANGE'; payload: { id: number; data: Partial } } + | { type: 'CLEAR_COMPLETED' } + | { type: 'TOGGLE_ALL'; payload: boolean }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'ADD': + return { + ...state, + todos: [ + ...state.todos, + { + id: +new Date(), + title: action.payload, + completed: false, + }, + ], + }; + + case 'DELETE': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload), + }; + + case 'CHANGE': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, ...action.payload.data } + : todo, + ), + }; + + case 'CLEAR_COMPLETED': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed), + }; + + case 'TOGGLE_ALL': + return { + ...state, + todos: state.todos.map(todo => ({ + ...todo, + completed: action.payload, + })), + }; + + default: + return state; + } +} + +const initialState: State = { + todos: JSON.parse(localStorage.getItem('todos') || '[]'), +}; + +export const TodoStateContext = React.createContext(null!); +export const TodoDispatchContext = React.createContext>( + () => {}, +); + +type Props = { + children: React.ReactNode; +}; + +export const useTodoState = () => { + const context = useContext(TodoStateContext); + + if (!context) { + throw new Error('State Error'); + } + + return context; +}; + +export const useTodoDispatch = () => { + const context = useContext(TodoDispatchContext); + + if (!context) { + throw new Error('Dispatch Error'); + } + + return context; +}; + +export const GlobalTodoProvide: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(state.todos)); + }, [state.todos]); + + return ( + + + {children} + + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..0a0edb86d 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 { GlobalTodoProvide } from './components/TodoProvider'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/filters.scss b/src/styles/filters.scss index 75b5804e5..787f21282 100644 --- a/src/styles/filters.scss +++ b/src/styles/filters.scss @@ -20,3 +20,7 @@ } } } + +* { + box-sizing: border-box; +} diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts new file mode 100644 index 000000000..7ca17f289 --- /dev/null +++ b/src/types/FilterStatus.ts @@ -0,0 +1,5 @@ +export enum FilterStatus { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} 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; +}