From 1eeca6662e7da76d7b87baf7b362792663f8eff6 Mon Sep 17 00:00:00 2001 From: Andriy Date: Mon, 25 May 2026 23:27:44 +0300 Subject: [PATCH 1/3] solution --- README.md | 2 +- src/App.tsx | 163 ++------------------------------- src/components/TodoApp.tsx | 23 +++++ src/components/TodoContext.tsx | 85 +++++++++++++++++ src/components/TodoFooter.tsx | 66 +++++++++++++ src/components/TodoHeader.tsx | 73 +++++++++++++++ src/components/TodoItem.tsx | 131 ++++++++++++++++++++++++++ src/components/TodoList.tsx | 33 +++++++ src/types/Filter.ts | 5 + src/types/Todo.ts | 5 + 10 files changed, 430 insertions(+), 156 deletions(-) create mode 100644 src/components/TodoApp.tsx create mode 100644 src/components/TodoContext.tsx create mode 100644 src/components/TodoFooter.tsx create mode 100644 src/components/TodoHeader.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/types/Filter.ts create mode 100644 src/types/Todo.ts diff --git a/README.md b/README.md index 903c876f9..3e3842ba6 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://Nika-Andriy.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..e796059fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,10 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; - -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 */} - -
-
-
- ); -}; +import { TodoProvider } from './components/TodoContext'; +import { TodoApp } from './components/TodoApp'; + +export const App: React.FC = () => ( + + + +); diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..6c6f31e68 --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useTodo } from './TodoContext'; +import { TodoHeader } from './TodoHeader'; +import { TodoFooter } from './TodoFooter'; +import { TodoList } from './TodoList'; + +export const TodoApp: React.FC = () => { + const { todos } = useTodo(); + + return ( +
+

todos

+ +
+ + + {todos.length > 0 && } + + {todos.length > 0 && } +
+
+ ); +}; diff --git a/src/components/TodoContext.tsx b/src/components/TodoContext.tsx new file mode 100644 index 000000000..9922a436c --- /dev/null +++ b/src/components/TodoContext.tsx @@ -0,0 +1,85 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { Filter } from '../types/Filter'; + +type TodoContextType = { + todos: Todo[]; + setTodos: React.Dispatch>; + title: string; + setTitle: React.Dispatch>; + editTitle: string; + setEditTitle: React.Dispatch>; + errorMessage: string; + setErrorMessage: React.Dispatch>; + editTodoId: number | null; + setEditTodoId: React.Dispatch>; + filter: Filter; + setFilter: React.Dispatch>; + inputRef: React.RefObject; +}; + +export const TodoContext = React.createContext({ + todos: [], + setTodos: () => {}, + title: '', + setTitle: () => {}, + editTitle: '', + setEditTitle: () => {}, + errorMessage: '', + setErrorMessage: () => {}, + editTodoId: null, + setEditTodoId: () => {}, + filter: Filter.All, + setFilter: () => {}, + inputRef: { current: null }, +}); + +export const useTodo = () => { + return useContext(TodoContext); +}; + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState(() => { + const savedTodos = localStorage.getItem('todos'); + + return savedTodos ? JSON.parse(savedTodos) : []; + }); + const [title, setTitle] = useState(''); + const [editTitle, setEditTitle] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [editTodoId, setEditTodoId] = useState(null); + const [filter, setFilter] = useState(Filter.All); + const inputRef = useRef(null); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + + inputRef.current?.focus(); + }, [todos]); + + return ( + + {children} + + ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 000000000..b1b0977a0 --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,66 @@ +import classNames from 'classnames'; +import { Filter } from '../types/Filter'; +import { useTodo } from './TodoContext'; + +export const TodoFooter: React.FC = () => { + const { todos, filter, setFilter, setTodos } = useTodo(); + const activeTodos = todos.filter(todo => !todo.completed).length; + const completedTodos = todos.filter(todo => todo.completed); + + const handleDeleteCompleted = () => { + setTodos(current => current.filter(t => !t.completed)); + }; + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 000000000..36f5a98c0 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,73 @@ +import classNames from 'classnames'; +import { useTodo } from './TodoContext'; +import { Todo } from '../types/Todo'; + +export const TodoHeader: React.FC = () => { + const { todos, inputRef, title, setTitle, setTodos, setErrorMessage } = + useTodo(); + const areAllTodosCompleted = + todos.length > 0 ? todos.every(todo => todo.completed) : false; + + const handleChange: React.ChangeEventHandler = event => { + setErrorMessage(''); + setTitle(event.target.value); + }; + + const handleSubmit: React.FormEventHandler = event => { + event.preventDefault(); + + if (!title.trim()) { + setErrorMessage('Input cant be empty'); + + return; + } + + const maxTodoId = Math.max(0, ...todos.map(todo => todo.id)); + const newTodo: Todo = { + id: maxTodoId + 1, + title: title.trim(), + completed: false, + }; + + setTodos(current => [...current, newTodo]); + setTitle(''); + }; + + const handleChangeAll = () => { + setTodos(current => { + const shouldCompleteAll = current.some(todo => !todo.completed); + + return current.map(todo => ({ + ...todo, + completed: shouldCompleteAll, + })); + }); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..78ecf19b9 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,131 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React from 'react'; +import classNames from 'classnames'; +import { useTodo } from './TodoContext'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { setTodos, editTodoId, setEditTodoId, editTitle, setEditTitle } = + useTodo(); + + const handleChange: React.ChangeEventHandler = event => { + setEditTitle(event.target.value); + }; + + const handleDelete = (id: number) => { + setTodos(current => current.filter(t => t.id !== id)); + }; + + const saveEditedTodo = () => { + const preparedTitle = editTitle.trim(); + + setTodos(current => { + if (!preparedTitle) { + return current.filter(t => t.id !== editTodoId); + } + + return current.map(t => { + if (t.id === editTodoId) { + return { ...t, title: preparedTitle }; + } + + return t; + }); + }); + + setEditTodoId(null); + }; + + const handleSubmit: React.FormEventHandler = event => { + event.preventDefault(); + saveEditedTodo(); + }; + + const handleBlur = () => { + saveEditedTodo(); + }; + + const handleCancel = () => { + setEditTodoId(null); + }; + + const handleClick = (id: number) => { + setTodos(current => + current.map(t => { + if (t.id === id) { + return { + ...t, + completed: !t.completed, + }; + } + + return t; + }), + ); + }; + + return ( +
+ + + {editTodoId === todo.id ? ( +
+ { + if (event.key === 'Escape') { + event.preventDefault(); + handleCancel(); + } + }} + autoFocus + /> +
+ ) : ( + <> + { + setEditTodoId(todo.id); + setEditTitle(todo.title); + }} + > + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..d39d44fb8 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,33 @@ +import { Filter } from '../types/Filter'; +import { useTodo } from './TodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, filter } = useTodo(); + + const getVisibleTodos = () => { + switch (filter) { + case Filter.All: + return todos; + + case Filter.Active: + return todos.filter(todo => !todo.completed); + + case Filter.Completed: + return todos.filter(todo => todo.completed); + + default: + return todos; + } + }; + + const visibleTodos = getVisibleTodos(); + + return ( +
+ {visibleTodos.map(todo => { + return ; + })} +
+ ); +}; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..174408fd6 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..d94ea1bff --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export type Todo = { + id: number; + title: string; + completed: boolean; +}; From 9085aa0158743ef0cac6acf1e6158349ad21aaa5 Mon Sep 17 00:00:00 2001 From: Andriy Date: Tue, 26 May 2026 12:36:34 +0300 Subject: [PATCH 2/3] fix issues --- src/components/TodoFooter.tsx | 65 ++++++++++++++++++----------------- src/components/TodoItem.tsx | 24 +++++++------ src/styles/todo-list.scss | 1 + src/styles/todoapp.scss | 1 + 4 files changed, 49 insertions(+), 42 deletions(-) diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx index b1b0977a0..da472fb85 100644 --- a/src/components/TodoFooter.tsx +++ b/src/components/TodoFooter.tsx @@ -11,6 +11,24 @@ export const TodoFooter: React.FC = () => { setTodos(current => current.filter(t => !t.completed)); }; + const filterring = [ + { + title: 'All', + type: Filter.All, + dataCy: 'FilterLinkAll', + }, + { + title: 'Active', + type: Filter.Active, + dataCy: 'FilterLinkActive', + }, + { + title: 'Completed', + type: Filter.Completed, + dataCy: 'FilterLinkCompleted', + }, + ]; + return (