From e3831590a0fac33583adf7d0436905013877e32e Mon Sep 17 00:00:00 2001 From: DevT Date: Sun, 15 Mar 2026 20:56:41 +0200 Subject: [PATCH 1/2] ToDo App task --- README.md | 2 +- src/App.tsx | 163 ++--------------------- src/components/TodoFooter/TodoFooter.tsx | 61 +++++++++ src/components/TodoFooter/index.tsx | 1 + src/components/TodoHeader/TodoHeader.tsx | 50 +++++++ src/components/TodoHeader/index.tsx | 1 + src/components/TodoList/TodoItem.tsx | 111 +++++++++++++++ src/components/TodoList/TodoList.tsx | 32 +++++ src/components/TodoList/index.ts | 1 + src/contexts/TodoContext.tsx | 118 ++++++++++++++++ src/index.tsx | 7 +- src/services/todosService.ts | 28 ++++ src/styles/index.scss | 1 + src/types/TodoFilterStatus.ts | 8 ++ src/types/todo.ts | 5 + 15 files changed, 438 insertions(+), 151 deletions(-) create mode 100644 src/components/TodoFooter/TodoFooter.tsx create mode 100644 src/components/TodoFooter/index.tsx create mode 100644 src/components/TodoHeader/TodoHeader.tsx create mode 100644 src/components/TodoHeader/index.tsx create mode 100644 src/components/TodoList/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/contexts/TodoContext.tsx create mode 100644 src/services/todosService.ts create mode 100644 src/types/TodoFilterStatus.ts create mode 100644 src/types/todo.ts diff --git a/README.md b/README.md index 903c876f9..ac6c571f4 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://devTym.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..55cebcd7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,22 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoList } from './components/TodoList'; +import { TodoFooter } from './components/TodoFooter'; +import { TodoProvider } from './contexts/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 */} - -
+ +
+

todos

+ +
+ + + +
-
+ ); }; diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..113158f57 --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,61 @@ +/* eslint-disable prettier/prettier */ +import React, { useContext } from 'react'; +import classNames from 'classnames'; +import { TodoContext } from '../../contexts/TodoContext'; +import { + TODO_FILTER_STATUS, + TodoFilterStatus, +} from '../../types/TodoFilterStatus'; + +export const TodoFooter: React.FC = () => { + const { todos, clearCompleted, filterStatus, setFilterStatus } = + useContext(TodoContext); + + const itemsLeft = todos.filter(todo => !todo.completed).length; + + const handleFilterClick = + (status: TodoFilterStatus) => + (event: React.MouseEvent) => { + event.preventDefault(); + setFilterStatus(status); + }; + + return ( + todos.length > 0 && ( + // {/* Hide the footer if there are no todos */} +
+ + {itemsLeft} 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/TodoFooter/index.tsx b/src/components/TodoFooter/index.tsx new file mode 100644 index 000000000..544d07114 --- /dev/null +++ b/src/components/TodoFooter/index.tsx @@ -0,0 +1 @@ +export * from './TodoFooter'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..24ed29ee1 --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,50 @@ +import React, { useContext, useState } from 'react'; +import { TodoContext } from '../../contexts/TodoContext'; +import classNames from 'classnames'; + +export const TodoHeader: React.FC = () => { + const [title, setTitle] = useState(''); + const { todos, addTodo, toggleAll, newTitleFieldRef } = + useContext(TodoContext); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (title.trim().length > 0) { + addTodo(title.trim()); + } + + setTitle(''); + }; + + const itemsLeftCount = todos.filter(todo => !todo.completed).length; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoHeader/index.tsx b/src/components/TodoHeader/index.tsx new file mode 100644 index 000000000..c4db4bc40 --- /dev/null +++ b/src/components/TodoHeader/index.tsx @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/components/TodoList/TodoItem.tsx b/src/components/TodoList/TodoItem.tsx new file mode 100644 index 000000000..a034e8890 --- /dev/null +++ b/src/components/TodoList/TodoItem.tsx @@ -0,0 +1,111 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { Todo } from '../../types/todo'; +import classNames from 'classnames'; +import { TodoContext } from '../../contexts/TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { removeTodo, updateTodo, newTitleFieldRef } = useContext(TodoContext); + const [title, setTitle] = useState(todo.title); + const [isEditing, setIsEditing] = useState(false); + const editTitleFieldRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editTitleFieldRef.current?.focus(); + } else { + newTitleFieldRef.current?.focus(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditing]); + + const saveEditedTitle = () => { + const newTitle = title.trim(); + + setIsEditing(false); + + if (!newTitle) { + removeTodo(todo.id); + + return; + } + + if (newTitle === todo.title) { + return; + } + + updateTodo(todo.id, { title: newTitle }); + }; + + const handleEditTodoTitle = (event: React.FormEvent) => { + event.preventDefault(); + saveEditedTitle(); + }; + + const handleTitleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setTitle(todo.title); + setIsEditing(false); + } + }; + + return ( +
+ + + {!isEditing ? ( + <> + setIsEditing(true)} + > + {todo.title} + + {/* Remove button appears only on hover */} + + + ) : ( + // {/* This form is shown instead of the title and remove button */} +
+ setTitle(event.currentTarget.value)} + onKeyDown={handleTitleKeyDown} + onBlur={saveEditedTitle} + /> +
+ )} +
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..affd57f8c --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,32 @@ +import React, { useContext } from 'react'; +import { TodoItem } from './TodoItem'; +import { TodoContext } from '../../contexts/TodoContext'; +import { TODO_FILTER_STATUS } from '../../types/TodoFilterStatus'; + +export const TodoList: React.FC = () => { + const { todos, filterStatus } = useContext(TodoContext); + + const visibleTodos = todos.filter(todo => { + switch (filterStatus) { + case TODO_FILTER_STATUS.ACTIVE: + return !todo.completed; + + case TODO_FILTER_STATUS.COMPLETED: + return todo.completed; + + default: + return true; + } + }); + + return ( + todos.length > 0 && ( +
+ {/* This is a completed todo */} + {visibleTodos.map(todo => ( + + ))} +
+ ) + ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/contexts/TodoContext.tsx b/src/contexts/TodoContext.tsx new file mode 100644 index 000000000..106f9cbcc --- /dev/null +++ b/src/contexts/TodoContext.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/todo'; +import { todosService } from '../services/todosService'; +import { + TODO_FILTER_STATUS, + TodoFilterStatus, +} from '../types/TodoFilterStatus'; + +type TodoContextType = { + todos: Todo[]; + addTodo: (title: string) => void; + updateTodo: (todoId: number, data: Partial>) => void; + removeTodo: (id: number) => void; + toggleAll: () => void; + clearCompleted: () => void; + filterStatus: TodoFilterStatus; + setFilterStatus: (filter: TodoFilterStatus) => void; + newTitleFieldRef: React.RefObject; +}; + +export const TodoContext = React.createContext({ + todos: [], + addTodo: () => {}, + updateTodo: () => {}, + removeTodo: () => {}, + toggleAll: () => {}, + clearCompleted: () => {}, + filterStatus: TODO_FILTER_STATUS.ALL, + setFilterStatus: () => {}, + newTitleFieldRef: { current: null }, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState(() => { + return todosService.getTodos(); + }); + const [filterStatus, setFilterStatus] = useState( + TODO_FILTER_STATUS.ALL, + ); + const newTitleFieldRef = useRef(null); + + useEffect(() => { + todosService.setTodos(todos); + }, [todos]); + + useEffect(() => { + newTitleFieldRef.current?.focus(); + }, [todos, filterStatus]); + + const addTodo = (title: string) => { + setTodos(prev => { + const maxId = prev.reduce((acc, todo) => { + return acc > todo.id ? acc : todo.id; + }, 0); + + return [ + ...prev, + { + id: maxId + 1, + title, + completed: false, + }, + ]; + }); + }; + + const updateTodo = ( + updatedTodoId: number, + data: Partial>, + ) => { + setTodos(prev => + prev.map(todo => + todo.id === updatedTodoId ? { ...todo, ...data } : todo, + ), + ); + }; + + const removeTodo = (id: number) => { + setTodos(prev => prev.filter(todo => todo.id !== id)); + }; + + const toggleAll = () => { + const todosActive = todos.filter(todo => !todo.completed); + const newStatus = todosActive.length > 0; + + setTodos(prev => + prev.map(todo => { + return { ...todo, completed: newStatus }; + }), + ); + }; + + const clearCompleted = () => { + setTodos(prev => prev.filter(todo => !todo.completed)); + }; + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..37414c7f0 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 './contexts/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/services/todosService.ts b/src/services/todosService.ts new file mode 100644 index 000000000..a46fd36b8 --- /dev/null +++ b/src/services/todosService.ts @@ -0,0 +1,28 @@ +import { Todo } from '../types/todo'; + +const TODOS_KEY = 'todos'; + +export const todosService = { + getTodos(): Todo[] { + const data = localStorage.getItem(TODOS_KEY); + + if (!data) { + return []; + } + + try { + return JSON.parse(data) as Todo[]; + } catch { + // log error + return []; + } + }, + + setTodos(todos: Todo[]) { + try { + localStorage.setItem(TODOS_KEY, JSON.stringify(todos)); + } catch { + // log error + } + }, +}; diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..8f4712eae 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -23,3 +23,4 @@ body { @import './todoapp'; @import './todo-list'; @import './filters'; +@import 'bulma/css/bulma.css'; diff --git a/src/types/TodoFilterStatus.ts b/src/types/TodoFilterStatus.ts new file mode 100644 index 000000000..8c17994cd --- /dev/null +++ b/src/types/TodoFilterStatus.ts @@ -0,0 +1,8 @@ +export const TODO_FILTER_STATUS = { + ALL: 'All', + ACTIVE: 'Active', + COMPLETED: 'Completed', +} as const; + +export type TodoFilterStatus = + (typeof TODO_FILTER_STATUS)[keyof typeof TODO_FILTER_STATUS]; 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 5dd951a3a2917408c2221141b2d9b2917e40a5d0 Mon Sep 17 00:00:00 2001 From: DevT Date: Mon, 16 Mar 2026 15:07:27 +0200 Subject: [PATCH 2/2] Fix: add empty todo validation --- src/App.tsx | 2 ++ .../ErrorNotification/ErrorNotification.tsx | 22 +++++++++++++++++++ src/components/ErrorNotification/index.tsx | 1 + src/components/TodoHeader/TodoHeader.tsx | 6 +---- src/contexts/TodoContext.tsx | 19 +++++++++++++++- 5 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 src/components/ErrorNotification/ErrorNotification.tsx create mode 100644 src/components/ErrorNotification/index.tsx diff --git a/src/App.tsx b/src/App.tsx index 55cebcd7e..31f7da40e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { TodoHeader } from './components/TodoHeader'; import { TodoList } from './components/TodoList'; import { TodoFooter } from './components/TodoFooter'; import { TodoProvider } from './contexts/TodoContext'; +import { ErrorNotification } from './components/ErrorNotification'; export const App: React.FC = () => { return ( @@ -16,6 +17,7 @@ export const App: React.FC = () => {
+ ); diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 000000000..0c282929e --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,22 @@ +import React, { useContext } from 'react'; +import { TodoContext } from '../../contexts/TodoContext'; + +export const ErrorNotification: React.FC = () => { + const { errorMessage, setErrorMessage } = useContext(TodoContext); + + return ( + errorMessage && ( +
+
+ ) + ); +}; diff --git a/src/components/ErrorNotification/index.tsx b/src/components/ErrorNotification/index.tsx new file mode 100644 index 000000000..8cb478792 --- /dev/null +++ b/src/components/ErrorNotification/index.tsx @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx index 24ed29ee1..3cf8f71d9 100644 --- a/src/components/TodoHeader/TodoHeader.tsx +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -9,11 +9,7 @@ export const TodoHeader: React.FC = () => { const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); - - if (title.trim().length > 0) { - addTodo(title.trim()); - } - + addTodo(title); setTitle(''); }; diff --git a/src/contexts/TodoContext.tsx b/src/contexts/TodoContext.tsx index 106f9cbcc..56bea9b84 100644 --- a/src/contexts/TodoContext.tsx +++ b/src/contexts/TodoContext.tsx @@ -16,6 +16,8 @@ type TodoContextType = { filterStatus: TodoFilterStatus; setFilterStatus: (filter: TodoFilterStatus) => void; newTitleFieldRef: React.RefObject; + errorMessage: string | null; + setErrorMessage: (message: string | null) => void; }; export const TodoContext = React.createContext({ @@ -28,6 +30,8 @@ export const TodoContext = React.createContext({ filterStatus: TODO_FILTER_STATUS.ALL, setFilterStatus: () => {}, newTitleFieldRef: { current: null }, + errorMessage: null, + setErrorMessage: () => {}, }); type Props = { @@ -41,6 +45,7 @@ export const TodoProvider: React.FC = ({ children }) => { const [filterStatus, setFilterStatus] = useState( TODO_FILTER_STATUS.ALL, ); + const [errorMessage, setErrorMessage] = useState(null); const newTitleFieldRef = useRef(null); useEffect(() => { @@ -52,6 +57,16 @@ export const TodoProvider: React.FC = ({ children }) => { }, [todos, filterStatus]); const addTodo = (title: string) => { + const normalizedTitle = title.trim(); + + if (!normalizedTitle) { + setErrorMessage('Title should not be empty'); + + return; + } + + setErrorMessage(null); + setTodos(prev => { const maxId = prev.reduce((acc, todo) => { return acc > todo.id ? acc : todo.id; @@ -61,7 +76,7 @@ export const TodoProvider: React.FC = ({ children }) => { ...prev, { id: maxId + 1, - title, + title: normalizedTitle, completed: false, }, ]; @@ -110,6 +125,8 @@ export const TodoProvider: React.FC = ({ children }) => { filterStatus, setFilterStatus, newTitleFieldRef, + errorMessage, + setErrorMessage, }} > {children}