From c6edefc1a1884dc9902d24459c711850a87caa19 Mon Sep 17 00:00:00 2001 From: uselessmorning Date: Sun, 31 May 2026 20:43:31 +0200 Subject: [PATCH 1/3] solution --- README.md | 2 +- package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 163 ++---------------------------------- src/components/Footer.tsx | 56 +++++++++++++ src/components/TodoApp.tsx | 37 ++++++++ src/components/TodoForm.tsx | 36 ++++++++ src/components/TodoItem.tsx | 95 +++++++++++++++++++++ src/components/TodoList.tsx | 28 +++++++ src/context/TodoContext.tsx | 113 +++++++++++++++++++++++++ src/types/Todo.ts | 11 +++ 11 files changed, 391 insertions(+), 161 deletions(-) create mode 100644 src/components/Footer.tsx create mode 100644 src/components/TodoApp.tsx create mode 100644 src/components/TodoForm.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/context/TodoContext.tsx create mode 100644 src/types/Todo.ts diff --git a/README.md b/README.md index 903c876f9..36ae67fa2 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://shtoikoihor.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..ec87fc834 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 './context/TodoContext'; +import { TodoApp } from './components/TodoApp'; + +export const App = () => ( + + + +); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..376683c7a --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useTodos } from '../context/TodoContext'; +import { FilterType } from '../types/Todo'; + +export const Footer: React.FC = () => { + const { todos, filter, setFilter, clearCompleted } = useTodos(); + + return ( + + ); +}; diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..a0989af92 --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,37 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { useTodos } from '../context/TodoContext'; +import { TodoList } from './TodoList'; +import { Footer } from './Footer'; +import { TodoForm } from './TodoForm'; + +export const TodoApp: React.FC = () => { + const { todos, toggleAll } = useTodos(); + + return ( +
+

Todo list

+ +
+
+ {/* this button should have `active` class only if all todos are completed */} + + {todos.length > 0 && ( +
+ + {todos.length > 0 && } + {/* Hide the footer if there are no todos */} + {todos.length > 0 &&
} +
+
+ ); +}; diff --git a/src/components/TodoForm.tsx b/src/components/TodoForm.tsx new file mode 100644 index 000000000..4b645fef9 --- /dev/null +++ b/src/components/TodoForm.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useState } from 'react'; +import { useTodos } from '../context/TodoContext'; + +export const TodoForm: React.FC = () => { + const [value, setValue] = useState(''); + const { addTodo, inputRef } = useTodos(); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!value.trim()) { + return; + } + + addTodo(value.trim()); + setValue(''); + inputRef.current?.focus(); + } + + return ( +
+ setValue(e.target.value)} + ref={inputRef} + /> +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..2a7c262f7 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,95 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import { useTodos } from '../context/TodoContext'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem = ({ todo }: Props) => { + const { deleteTodo, toggleTodo, renameTodo } = useTodos(); + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(todo.title); + + //#region functions + function saveEdit(e?: React.FormEvent) { + e?.preventDefault(); + if (editValue.trim()) { + renameTodo(editValue.trim(), todo.id); + } else { + deleteTodo(todo.id); + } + + setIsEditing(false); + } + + function handleKeyUp(event: React.KeyboardEvent) { + if (event.key === 'Escape') { + setEditValue(todo.title); + setIsEditing(false); + } + } + + //#endregion + + //#region hooks + + const editRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editRef.current?.focus(); + } + }, [isEditing]); + + //#endregion + + return ( +
+ + + {isEditing ? ( +
+ setEditValue(e.target.value)} + onBlur={saveEdit} + onKeyUp={handleKeyUp} + ref={editRef} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..f428ad2c3 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useTodos } from '../context/TodoContext'; +import { FilterType } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, filter } = useTodos(); + + function getFilteredTodos() { + switch (filter) { + case FilterType.All: + return todos; + case FilterType.Active: + return todos.filter(todo => !todo.completed); + case FilterType.Completed: + return todos.filter(todo => todo.completed); + } + } + + const filteredTodos = getFilteredTodos(); + + return ( +
+ {/* This is a completed todo */} + {filteredTodos?.map(todo => )} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..949c93416 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,113 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { FilterType, Todo } from '../types/Todo'; + +type TodoContextType = { + todos: Todo[]; + filter: FilterType; + addTodo: (value: string) => void; + deleteTodo: (id: number) => void; + toggleTodo: (id: number) => void; + renameTodo: (value: string, id: number) => void; + clearCompleted: () => void; + toggleAll: (completed: boolean) => void; + setFilter: (filter: FilterType) => void; + inputRef: React.RefObject; +}; + +const TodoContext = createContext(null); + +//#region hooks +export function useTodos() { + const context = useContext(TodoContext); + + if (context === null) { + throw new Error('useTodos must be used within a TodoProvider'); + } + + return context; +} +//#endregion + +export const TodoProvider = ({ children }: { children: React.ReactNode }) => { + const [todos, setTodos] = useState(() => { + const saved = localStorage.getItem('todos'); + + return saved ? JSON.parse(saved) : []; + }); + const [filter, setFilter] = useState(FilterType.All); + const inputRef = useRef(null); + + //#region function + function addTodo(title: string) { + const newTodo: Todo = { + id: Date.now(), + title: title, + completed: false, + }; + + setTodos([...todos, newTodo]); + } + + function deleteTodo(id: number) { + const deletedTodos = todos.filter(todo => todo.id !== id); + + setTodos(deletedTodos); + setTimeout(() => inputRef.current?.focus(), 0); + } + + function toggleTodo(id: number) { + setTodos( + todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + } + + function renameTodo(value: string, id: number) { + setTodos( + todos.map(todo => (todo.id === id ? { ...todo, title: value } : todo)), + ); + } + + function clearCompleted() { + setTodos(todos.filter(todo => !todo.completed)); + setTimeout(() => inputRef.current?.focus(), 0); + } + + function toggleAll(completed: boolean) { + setTodos(todos.map(todo => ({ ...todo, completed: completed }))); + } + //#endregion + + //#region useEffects + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + //#endregion + + return ( + + {children} + + ); +}; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..00544c672 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,11 @@ +export type Todo = { + id: number; + title: string; + completed: boolean; +}; + +export enum FilterType { + All = 'All', + Completed = 'Completed', + Active = 'Active', +} From 84841725f17d11b02b276cf89e7e0a3c8aebda53 Mon Sep 17 00:00:00 2001 From: uselessmorning Date: Mon, 1 Jun 2026 18:38:57 +0200 Subject: [PATCH 2/3] changes were made based on feedback --- src/App.tsx | 1 - src/components/Footer.tsx | 2 -- src/components/TodoApp.tsx | 10 +++------- src/components/TodoList.tsx | 1 - src/context/TodoContext.tsx | 2 +- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ec87fc834..e207af351 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; import { TodoProvider } from './context/TodoContext'; import { TodoApp } from './components/TodoApp'; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 376683c7a..d1363cab5 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -11,7 +11,6 @@ export const Footer: React.FC = () => { {`${todos.filter(todo => !todo.completed).length} items left`} - {/* Active link should have the 'selected' class */} - {/* this button should be disabled if there are no completed todos */}