From 08f7e3740b111a238f06e07c812fddd1c695cc81 Mon Sep 17 00:00:00 2001 From: Tymur Date: Wed, 13 May 2026 13:22:48 +0300 Subject: [PATCH] add task solution --- README.md | 2 +- src/App.tsx | 157 ------------------ src/app/App.tsx | 23 +++ .../components/TodoFooter/TodoFooter.tsx | 84 ++++++++++ .../todos/components/TodoFooter/index.ts | 1 + .../components/TodoHeader/TodoHeader.tsx | 59 +++++++ .../todos/components/TodoHeader/index.ts | 1 + .../todos/components/TodoList/TodoItem.tsx | 134 +++++++++++++++ .../todos/components/TodoList/TodoList.tsx | 16 ++ .../todos/components/TodoList/index.ts | 1 + src/features/todos/constants/queryTodos.ts | 5 + src/features/todos/index.ts | 11 ++ .../todos/providers/TodosProvider.tsx | 120 +++++++++++++ src/features/todos/types/Todo.ts | 5 + src/features/todos/utils/filterTodos.ts | 16 ++ src/index.tsx | 2 +- 16 files changed, 478 insertions(+), 159 deletions(-) delete mode 100644 src/App.tsx create mode 100644 src/app/App.tsx create mode 100644 src/features/todos/components/TodoFooter/TodoFooter.tsx create mode 100644 src/features/todos/components/TodoFooter/index.ts create mode 100644 src/features/todos/components/TodoHeader/TodoHeader.tsx create mode 100644 src/features/todos/components/TodoHeader/index.ts create mode 100644 src/features/todos/components/TodoList/TodoItem.tsx create mode 100644 src/features/todos/components/TodoList/TodoList.tsx create mode 100644 src/features/todos/components/TodoList/index.ts create mode 100644 src/features/todos/constants/queryTodos.ts create mode 100644 src/features/todos/index.ts create mode 100644 src/features/todos/providers/TodosProvider.tsx create mode 100644 src/features/todos/types/Todo.ts create mode 100644 src/features/todos/utils/filterTodos.ts diff --git a/README.md b/README.md index 903c876f9..2a7156da5 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://timurradkevic.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index a399287bd..000000000 --- a/src/App.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/* 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 */} - -
-
-
- ); -}; diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 000000000..b4fbf6c97 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,23 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { TodoFooter, TodoHeader, TodoList } from '../features/todos'; +import { TodosProvider } from '../features/todos/providers/TodosProvider'; + +export const App: React.FC = () => { + return ( +
+ +

todos

+ +
+ + + + + +
+
+
+ ); +}; diff --git a/src/features/todos/components/TodoFooter/TodoFooter.tsx b/src/features/todos/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..1da845a26 --- /dev/null +++ b/src/features/todos/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,84 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ + +import classNames from 'classnames'; +import { QueryTodos } from '../../constants/queryTodos'; +import { useTodos } from '../../providers/TodosProvider'; + +export const TodoFooter = () => { + const { + query, + todos, + uncompletedTodosLength, + setQuery, + setTodos, + focusHeaderInput, + } = useTodos(); + + if (!todos.length) { + return null; + } + + const handleClearCompletedTodos = () => { + setTodos(prev => prev.filter(todo => !todo.completed)); + }; + + return ( + + ); +}; diff --git a/src/features/todos/components/TodoFooter/index.ts b/src/features/todos/components/TodoFooter/index.ts new file mode 100644 index 000000000..544d07114 --- /dev/null +++ b/src/features/todos/components/TodoFooter/index.ts @@ -0,0 +1 @@ +export * from './TodoFooter'; diff --git a/src/features/todos/components/TodoHeader/TodoHeader.tsx b/src/features/todos/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..368e2e036 --- /dev/null +++ b/src/features/todos/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,59 @@ +import classNames from 'classnames'; +import { useTodos } from '../../providers/TodosProvider'; +import React, { useState } from 'react'; + +export const TodoHeader = () => { + const { todos, completedAllTodos, setTodos, headerInputRef } = useTodos(); + const [title, setTitle] = useState(''); + + const handleCompletedAllTodos = () => { + setTodos(prev => + prev.map(todo => ({ ...todo, completed: !completedAllTodos })), + ); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + return; + } + + setTodos(prev => [ + ...prev, + { id: +new Date(), title: title.trim(), completed: false }, + ]); + setTitle(''); + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length !== 0 && ( +
+ ); +}; diff --git a/src/features/todos/components/TodoHeader/index.ts b/src/features/todos/components/TodoHeader/index.ts new file mode 100644 index 000000000..c4db4bc40 --- /dev/null +++ b/src/features/todos/components/TodoHeader/index.ts @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/features/todos/components/TodoList/TodoItem.tsx b/src/features/todos/components/TodoList/TodoItem.tsx new file mode 100644 index 000000000..681183240 --- /dev/null +++ b/src/features/todos/components/TodoList/TodoItem.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../../types/Todo'; +import classNames from 'classnames'; +import { useTodos } from '../../providers/TodosProvider'; + +type Props = { + todo: Todo; +}; + +/* eslint-disable jsx-a11y/label-has-associated-control */ +export const TodoItem: React.FC = ({ todo }) => { + const { setTodos, focusHeaderInput, selectedTodo, setSelectedTodo } = + useTodos(); + const [title, setTitle] = useState(''); + const titleInputRef = useRef(null); + + const handleDelete = (todoId: number) => { + setTodos(prev => prev.filter(tod => tod.id !== todoId)); + focusHeaderInput(); + }; + + const handleUpdateTitle = (value: Todo, str: string) => { + const updatedTodo = { + id: value.id, + title: str, + completed: value.completed, + }; + + setTodos(prev => { + return prev.map(tod => (tod.id === updatedTodo.id ? updatedTodo : tod)); + }); + }; + + const handleSubmit = (event?: React.FormEvent) => { + if (event) { + event.preventDefault(); + } + + if (selectedTodo) { + const fixedTitle = title.trim(); + + if (!fixedTitle) { + handleDelete(selectedTodo.id); + } + + setSelectedTodo(null); + handleUpdateTitle(selectedTodo, fixedTitle); + } + }; + + useEffect(() => { + const handleEscapeKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setSelectedTodo(null); + } + }; + + window.addEventListener('keyup', handleEscapeKeyUp); + + return () => window.removeEventListener('keyup', handleEscapeKeyUp); + }, [setSelectedTodo]); + + const handleToggleCompleted = (value: Todo) => { + const updatedTodo = { + id: value.id, + title: value.title, + completed: !value.completed, + }; + + setTodos(prev => { + return prev.map(tod => (tod.id === updatedTodo.id ? updatedTodo : tod)); + }); + }; + + useEffect(() => { + if (selectedTodo) { + titleInputRef.current?.focus(); + titleInputRef.current?.select(); + } + }, [selectedTodo]); + + return ( +
{ + setSelectedTodo(todo); + setTitle(todo.title); + }} + > + + + {selectedTodo?.id === todo.id ? ( +
+ setTitle(event.target.value)} + onBlur={handleSubmit} + ref={titleInputRef} + /> +
+ ) : ( + <> + + {todo.title} + + + + )} +
+ ); +}; diff --git a/src/features/todos/components/TodoList/TodoList.tsx b/src/features/todos/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..1b4387ff1 --- /dev/null +++ b/src/features/todos/components/TodoList/TodoList.tsx @@ -0,0 +1,16 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ + +import { useTodos } from '../../providers/TodosProvider'; +import { TodoItem } from './TodoItem'; + +export const TodoList = () => { + const { preparedTodos } = useTodos(); + + return ( +
+ {preparedTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/features/todos/components/TodoList/index.ts b/src/features/todos/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/features/todos/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/features/todos/constants/queryTodos.ts b/src/features/todos/constants/queryTodos.ts new file mode 100644 index 000000000..c9641c6c8 --- /dev/null +++ b/src/features/todos/constants/queryTodos.ts @@ -0,0 +1,5 @@ +export enum QueryTodos { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/features/todos/index.ts b/src/features/todos/index.ts new file mode 100644 index 000000000..afab26285 --- /dev/null +++ b/src/features/todos/index.ts @@ -0,0 +1,11 @@ +export { TodoList } from './components/TodoList'; +export { TodoHeader } from './components/TodoHeader'; +export { TodoFooter } from './components/TodoFooter'; +// export { ErrorNotification } from './components/ErrorNotification'; + +// export { QueryTodos } from './constants/queryTodos'; + +// export type { Todo } from './types/Todo'; + +// export * from './api/todos'; +// export * from './utils/filterTodos'; diff --git a/src/features/todos/providers/TodosProvider.tsx b/src/features/todos/providers/TodosProvider.tsx new file mode 100644 index 000000000..dc6da3942 --- /dev/null +++ b/src/features/todos/providers/TodosProvider.tsx @@ -0,0 +1,120 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { QueryTodos } from '../constants/queryTodos'; +import { filterTodos } from '../utils/filterTodos'; + +function useLocalStorage(key: string, defaultValue: T) { + const [value, setValue] = useState(() => { + const savedValue = localStorage.getItem(key); + + if (savedValue === null) { + return defaultValue; + } + + try { + return JSON.parse(savedValue); + } catch (error) { + // eslint-disable-next-line no-console + console.log(`Error parsing localStorage key "${key}":`, error); + + return defaultValue; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Error saving to localStorage key "${key}":`, error); + } + }, [key, value]); + + return [value, setValue] as const; +} + +type TodosContextType = { + completedAllTodos: boolean; + todos: Todo[]; + preparedTodos: Todo[]; + setTodos: React.Dispatch>; + query: QueryTodos; + setQuery: React.Dispatch>; + uncompletedTodosLength: number; + headerInputRef: React.RefObject; + focusHeaderInput: () => void; + selectedTodo: Todo | null; + setSelectedTodo: React.Dispatch>; +}; + +const TodosContext = React.createContext( + undefined, +); + +type Props = { + children: React.ReactNode; +}; + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useLocalStorage('todos', []); + const [query, setQuery] = useState(QueryTodos.All); + const headerInputRef = useRef(null); + const [selectedTodo, setSelectedTodo] = useState(null); + + const focusHeaderInput = () => { + headerInputRef.current?.focus(); + }; + + const preparedTodos = useMemo(() => { + return filterTodos(todos, query); + }, [todos, query]); + + const completedAllTodos = useMemo( + () => todos.length > 0 && todos.every(todo => todo.completed), + [todos], + ); + + const uncompletedTodosLength = useMemo( + () => todos.filter(todo => !todo.completed).length, + [todos], + ); + + const value = useMemo( + () => ({ + preparedTodos, + completedAllTodos, + todos, + setTodos, + uncompletedTodosLength, + setQuery, + query, + headerInputRef, + focusHeaderInput, + selectedTodo, + setSelectedTodo, + }), + [ + preparedTodos, + completedAllTodos, + todos, + setTodos, + uncompletedTodosLength, + query, + selectedTodo, + ], + ); + + return ( + {children} + ); +}; + +export const useTodos = () => { + const context = useContext(TodosContext); + + if (context === undefined) { + throw new Error('useTodos must be used within a TodosProvider'); + } + + return context; +}; diff --git a/src/features/todos/types/Todo.ts b/src/features/todos/types/Todo.ts new file mode 100644 index 000000000..f9e06b381 --- /dev/null +++ b/src/features/todos/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; +} diff --git a/src/features/todos/utils/filterTodos.ts b/src/features/todos/utils/filterTodos.ts new file mode 100644 index 000000000..17455d32b --- /dev/null +++ b/src/features/todos/utils/filterTodos.ts @@ -0,0 +1,16 @@ +import { QueryTodos } from '../constants/queryTodos'; +import { Todo } from '../types/Todo'; + +export function filterTodos(todos: Todo[], query: QueryTodos): Todo[] { + return todos.filter(todo => { + switch (query) { + case QueryTodos.Active: + return !todo.completed; + case QueryTodos.Completed: + return todo.completed; + case QueryTodos.All: + default: + return true; + } + }); +} diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..9af3fecb5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,7 @@ import { createRoot } from 'react-dom/client'; import './styles/index.scss'; -import { App } from './App'; +import { App } from './app/App'; const container = document.getElementById('root') as HTMLDivElement;