diff --git a/README.md b/README.md index 903c876f9..94c65ecc5 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://lishvets.github.io/react_todo-app/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index 1f19b4743..a7f06fc95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.9.12", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1170,10 +1170,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.9.12", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", - "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index 91d7489b9..446974833 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.9.12", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.tsx b/src/App.tsx index a399287bd..410f3fd57 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 { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { TodoList } from './components/TodoList'; +import { useTodos } from './Context/TodoContext'; export const App: React.FC = () => { + const { state } = useTodos(); + + const hasTodos = state.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 - - - -
-
- - {/* 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/Context/TodoContext.tsx b/src/Context/TodoContext.tsx new file mode 100644 index 000000000..e9defb8cc --- /dev/null +++ b/src/Context/TodoContext.tsx @@ -0,0 +1,51 @@ +import React, { useEffect } from 'react'; +import { createContext, useContext, useReducer } from 'react'; +import { Todo } from '../types/Todo'; +import { Action, todoReducer } from './TodoReducer'; +import { StatusType } from '../types/Status'; +import { loadTodos, saveTodos } from '../utils/localStorage'; + +export type TodoState = { + todos: Todo[]; + filter: StatusType; +}; + +const initialState: TodoState = { + todos: loadTodos(), + filter: StatusType.All, +}; + +type TodoContextValue = { + state: TodoState; + dispatch: React.Dispatch; +}; + +export const TodoContext = createContext(null); + +type Props = { + children: React.ReactNode; +}; + +export const useTodos = () => { + const context = useContext(TodoContext); + + if (!context) { + throw Error('useTodos must be used within TodoProvider'); + } + + return context; +}; + +export const TodoProvider = ({ children }: Props) => { + const [state, dispatch] = useReducer(todoReducer, initialState); + + useEffect(() => { + saveTodos(state.todos); + }, [state.todos]); + + return ( + + {children} + + ); +}; diff --git a/src/Context/TodoReducer.tsx b/src/Context/TodoReducer.tsx new file mode 100644 index 000000000..6b5882ef0 --- /dev/null +++ b/src/Context/TodoReducer.tsx @@ -0,0 +1,69 @@ +import { TodoState } from './TodoContext'; +import { Todo } from '../types/Todo'; +import { StatusType } from '../types/Status'; + +export type Action = + | { type: 'add'; payload: Todo } + | { type: 'toggle'; payload: number } + | { type: 'delete'; payload: number } + | { type: 'setFilter'; payload: StatusType } + | { type: 'clearCompleted' } + | { type: 'update'; payload: { id: number; title: string } } + | { type: 'toggleAll' }; + +export const todoReducer = (state: TodoState, action: Action): TodoState => { + switch (action.type) { + case 'add': + return { + ...state, + todos: [...state.todos, action.payload], + }; + + case 'toggle': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload + ? { ...todo, completed: !todo.completed } + : todo, + ), + }; + case 'delete': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload), + }; + case 'setFilter': + return { + ...state, + filter: action.payload, + }; + + case 'clearCompleted': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed), + }; + case 'update': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, title: action.payload.title } + : todo, + ), + }; + case 'toggleAll': + const allCompleted = state.todos.every(todo => todo.completed); + + return { + ...state, + todos: state.todos.map(todo => ({ + ...todo, + completed: !allCompleted, + })), + }; + default: + return state; + } +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..565658055 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,48 @@ +import { useTodos } from '../Context/TodoContext'; +import { filters } from '../types/filters'; + +export const Footer = () => { + const { state, dispatch } = useTodos(); + const { filter } = state; + + const activeTodosCount = state.todos.filter(todo => !todo.completed).length; + const hasCompletedTodos = state.todos.some(todo => todo.completed); + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..13fadb83b --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,24 @@ +import { useTodos } from '../Context/TodoContext'; +import { HeaderForm } from './HeaderForm'; + +export const Header = () => { + const { state, dispatch } = useTodos(); + const hasTodos = state.todos.length > 0; + const allCompleted = state.todos.every(todo => todo.completed); + const isActive = hasTodos && allCompleted; + + return ( +
+ {state.todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/HeaderForm.tsx b/src/components/HeaderForm.tsx new file mode 100644 index 000000000..23d876d86 --- /dev/null +++ b/src/components/HeaderForm.tsx @@ -0,0 +1,50 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useTodos } from '../Context/TodoContext'; +import { Todo } from '../types/Todo'; + +export const HeaderForm = () => { + const [value, setValue] = useState(''); + const { state, dispatch } = useTodos(); + const inputRef = useRef(null); + + const handleSubmit = () => { + const trimmedTitle = value.trim(); + + if (!trimmedTitle) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: trimmedTitle, + completed: false, + }; + + dispatch({ type: 'add', payload: newTodo }); + + setValue(''); + }; + + useEffect(() => { + inputRef.current?.focus(); + }, [state.todos]); + + return ( +
{ + e.preventDefault(); + handleSubmit(); + }} + > + setValue(e.target.value)} + ref={inputRef} + /> +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..8c26d46da --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,117 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { useTodos } from '../Context/TodoContext'; +import { Todo } from '../types/Todo'; +import { useState } from 'react'; +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + + const { dispatch } = useTodos(); + + const handleToggle = () => { + dispatch({ type: 'toggle', payload: todo.id }); + }; + + const handleDelete = () => { + dispatch({ type: 'delete', payload: todo.id }); + }; + + const handleDoubleClick = () => { + setIsEditing(true); + setNewTitle(todo.title); + }; + + const saveTodo = () => { + if (!isEditing) { + return; + } + + const trimmedTitle = newTitle.trim(); + + if (trimmedTitle.length === 0) { + dispatch({ + type: 'delete', + payload: todo.id, + }); + } else { + dispatch({ + type: 'update', + payload: { + id: todo.id, + title: trimmedTitle, + }, + }); + } + + setIsEditing(false); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + saveTodo(); + }; + + const handleCancelEdit = () => { + setNewTitle(todo.title); + setIsEditing(false); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + handleCancelEdit(); + } + }; + + return ( +
+ + + {isEditing ? ( +
+ setNewTitle(e.target.value)} + onBlur={saveTodo} + onKeyUp={handleKeyUp} + autoFocus + /> +
+ ) : ( + + {todo.title} + + )} + + {!isEditing && ( + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..572a1f2f1 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,28 @@ +import { useTodos } from '../Context/TodoContext'; +import { TodoItem } from './TodoItem'; +import { StatusType } from '../types/Status'; + +export const TodoList = () => { + const { state } = useTodos(); + const { todos, filter } = state; + + const visibleTodos = todos.filter(todo => { + if (filter === StatusType.Active) { + return !todo.completed; + } + + if (filter === StatusType.Completed) { + return todo.completed; + } + + return true; + }); + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..87c6c6786 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/todoapp.scss b/src/styles/todoapp.scss index e289a9458..29383a1e2 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -56,6 +56,7 @@ } &__new-todo { + box-sizing: border-box; width: 100%; padding: 16px 16px 16px 60px; diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 000000000..e2f87b7a1 --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum StatusType { + 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; +} diff --git a/src/types/filters.ts b/src/types/filters.ts new file mode 100644 index 000000000..063ac91e1 --- /dev/null +++ b/src/types/filters.ts @@ -0,0 +1,19 @@ +import { StatusType } from './Status'; + +export const filters = [ + { + label: 'All', + href: '#/', + status: StatusType.All, + }, + { + label: 'Active', + href: '#/active', + status: StatusType.Active, + }, + { + label: 'Completed', + href: '#/completed', + status: StatusType.Completed, + }, +]; diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts new file mode 100644 index 000000000..7ba6fd583 --- /dev/null +++ b/src/utils/localStorage.ts @@ -0,0 +1,15 @@ +import { Todo } from '../types/Todo'; + +export const loadTodos = (): Todo[] => { + try { + const data = localStorage.getItem('todos'); + + return data ? JSON.parse(data) : []; + } catch { + return []; + } +}; + +export const saveTodos = (todos: Todo[]) => { + localStorage.setItem('todos', JSON.stringify(todos)); +}; diff --git a/tsconfig.json b/tsconfig.json index cfb168bb2..6c7614e0e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ ], "compilerOptions": { "sourceMap": false, - "types": ["node", "cypress"] + "types": ["node", "cypress"], + "jsx": "react-jsx" } }