From 05b9ce77c4705adb1784a2867df5fe7d352d19f9 Mon Sep 17 00:00:00 2001 From: naviailpach Date: Tue, 19 May 2026 10:48:38 +0300 Subject: [PATCH] solution --- README.md | 2 +- package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 176 +++++------------------ src/Components/TodoFooter/TodoFooter.tsx | 80 +++++++++++ src/Components/TodoHeader/TodoHeader.tsx | 62 ++++++++ src/Components/TodoItem/TodoItem.tsx | 144 +++++++++++++++++++ src/Components/TodoList/TodoList.tsx | 17 +++ src/TodoContext.tsx | 123 ++++++++++++++++ src/Utils/Filter.ts | 5 + src/index.tsx | 7 +- src/styles/todo-list.scss | 2 + src/styles/todoapp.scss | 2 + src/types/todo.ts | 6 + 14 files changed, 488 insertions(+), 149 deletions(-) create mode 100644 src/Components/TodoFooter/TodoFooter.tsx create mode 100644 src/Components/TodoHeader/TodoHeader.tsx create mode 100644 src/Components/TodoItem/TodoItem.tsx create mode 100644 src/Components/TodoList/TodoList.tsx create mode 100644 src/TodoContext.tsx create mode 100644 src/Utils/Filter.ts create mode 100644 src/types/todo.ts diff --git a/README.md b/README.md index 903c876f9..bfd9fc275 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://naviailpach.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..3e8b4a3df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,48 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useState } from 'react'; +import { TodoHeader } from './Components/TodoHeader/TodoHeader'; +import { TodoList } from './Components/TodoList/TodoList'; +import { TodoFooter } from './Components/TodoFooter/TodoFooter'; +import { TodoContext } from './TodoContext'; +import { Filter } from './Utils/Filter'; 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 */} - -
+ const { todos } = useContext(TodoContext); - {/* This todo is an active todo */} -
- + const [filter, setFilter] = useState(Filter.All); - - Not Completed Todo - + const filteredTodos = todos.filter(todo => { + switch (filter) { + case 'active': + return !todo.completed; - -
+ case 'completed': + return todo.completed; - {/* This todo is being edited */} -
- + default: + return true; + } + }); - {/* This form is shown instead of the title and remove button */} -
- -
-
+ const handleFilterChange = ( + event: React.MouseEvent, + value: Filter, + ) => { + event.preventDefault(); + setFilter(value); + }; - {/* 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 */} - + {todos.length > 0 && } - {/* this button should be disabled if there are no completed todos */} - -
+ {todos.length > 0 && ( + + )}
); diff --git a/src/Components/TodoFooter/TodoFooter.tsx b/src/Components/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..45959b8c3 --- /dev/null +++ b/src/Components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,80 @@ +import classNames from 'classnames'; + +import React, { useContext } from 'react'; +import { TodoContext } from '../../TodoContext'; +import { Filter } from '../../Utils/Filter'; + +type Props = { + filter: Filter; + onFilterChange: ( + event: React.MouseEvent, + value: Filter, + ) => void; +}; + +export const TodoFooter: React.FC = ({ filter, onFilterChange }) => { + const { todos, dispatch, mainInputRef } = useContext(TodoContext); + + const focusMain = () => { + mainInputRef.current?.focus(); + }; + + const handleClearCompleted = () => { + dispatch({ + type: 'clearCompleted', + }); + focusMain(); + }; + + const filterLinks = [ + { label: 'All', value: Filter.All, href: '#/', dataCy: 'FilterLinkAll' }, + + { + label: 'Active', + value: Filter.Active, + href: '#/active', + dataCy: 'FilterLinkActive', + }, + + { + label: 'Completed', + value: Filter.Completed, + href: '#/completed', + dataCy: 'FilterLinkCompleted', + }, + ]; + + return ( +
+ + {todos.filter(todo => !todo.completed).length} items left + + + + + +
+ ); +}; diff --git a/src/Components/TodoHeader/TodoHeader.tsx b/src/Components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..f139ddeb6 --- /dev/null +++ b/src/Components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,62 @@ +import classNames from 'classnames'; + +import React, { useContext, useEffect, useState } from 'react'; +import { TodoContext } from '../../TodoContext'; + +export const TodoHeader: React.FC = () => { + const { todos, dispatch, mainInputRef } = useContext(TodoContext); + + const [query, setQuery] = useState(''); + + useEffect(() => { + mainInputRef.current?.focus(); + }, [mainInputRef]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmed = query.trim(); + + if (!trimmed) { + return; + } + + dispatch({ + type: 'addTodo', + payload: trimmed, + }); + + setQuery(''); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/Components/TodoItem/TodoItem.tsx b/src/Components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..241bc43b0 --- /dev/null +++ b/src/Components/TodoItem/TodoItem.tsx @@ -0,0 +1,144 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import classNames from 'classnames'; + +import React, { useContext, useEffect, useState } from 'react'; +import { Todo } from '../../types/todo'; +import { TodoContext } from '../../TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { dispatch, mainInputRef } = useContext(TodoContext); + + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(todo.title); + + const focusMain = () => { + mainInputRef.current?.focus(); + }; + + const handleEdit = (event?: React.FormEvent) => { + event?.preventDefault(); + + const trimmed = editTitle.trim(); + + if (!trimmed) { + dispatch({ + type: 'deleteTodo', + payload: todo.id, + }); + setIsEditing(false); + focusMain(); + + return; + } + + if (trimmed === todo.title) { + setIsEditing(false); + focusMain(); + + return; + } + + dispatch({ + type: 'updateTodo', + payload: { + id: todo.id, + title: trimmed, + }, + }); + + setIsEditing(false); + focusMain(); + }; + + const doubleClick = () => { + setIsEditing(true); + setEditTitle(todo.title); + }; + + const handleRemove = () => { + dispatch({ + type: 'deleteTodo', + payload: todo.id, + }); + focusMain(); + }; + + const editInputRef = React.useRef(null); + + useEffect(() => { + if (isEditing) { + editInputRef.current?.focus(); + } + }, [isEditing]); + + return ( +
+ + + {isEditing ? ( +
+ { + setEditTitle(event.target.value); + }} + onBlur={() => handleEdit()} + onKeyUp={event => { + if (event.key === 'Escape') { + setEditTitle(todo.title); + setIsEditing(false); + } + }} + /> +
+ ) : ( + + {todo.title} + + )} + + {!isEditing && ( + + )} +
+ ); +}; diff --git a/src/Components/TodoList/TodoList.tsx b/src/Components/TodoList/TodoList.tsx new file mode 100644 index 000000000..3fb1779b0 --- /dev/null +++ b/src/Components/TodoList/TodoList.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { Todo } from '../../types/todo'; + +type Props = { + todos: Todo[]; +}; + +export const TodoList: React.FC = ({ todos }) => { + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/TodoContext.tsx b/src/TodoContext.tsx new file mode 100644 index 000000000..4a5f41531 --- /dev/null +++ b/src/TodoContext.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useMemo, useReducer, useRef } from 'react'; +import { Todo } from './types/todo'; + +export const TodoContext = React.createContext({ + todos: [] as Todo[], + dispatch: (() => {}) as React.Dispatch, + mainInputRef: { current: null } as React.RefObject, +}); + +type Action = + | { type: 'addTodo'; payload: string } + | { type: 'deleteTodo'; payload: number } + | { type: 'toggleTodo'; payload: number } + | { type: 'toggleAll' } + | { type: 'clearCompleted' } + | { type: 'updateTodo'; payload: { id: number; title: string } }; + +interface State { + todos: Todo[]; +} + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'addTodo': + return { + ...state, + todos: [ + ...state.todos, + { + id: +new Date(), + userId: 1, + title: action.payload, + completed: false, + }, + ], + }; + + case 'deleteTodo': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload), + }; + + case 'toggleTodo': + return { + ...state, + todos: state.todos.map(todo => { + if (todo.id !== action.payload) { + return todo; + } + + return { + ...todo, + completed: !todo.completed, + }; + }), + }; + + case 'toggleAll': { + const allCompleted = state.todos.every(todo => todo.completed); + + return { + ...state, + todos: state.todos.map(todo => ({ + ...todo, + completed: !allCompleted, + })), + }; + } + + case 'clearCompleted': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed), + }; + + case 'updateTodo': + return { + ...state, + todos: state.todos.map(todo => { + if (todo.id !== action.payload.id) { + return todo; + } + + return { + ...todo, + title: action.payload.title, + }; + }), + }; + + default: + return state; + } +} + +const initialState: State = { + todos: JSON.parse(localStorage.getItem('todos') || '[]'), +}; + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [{ todos }, dispatch] = useReducer(reducer, initialState); + const mainInputRef = useRef(null); + + const value = useMemo( + () => ({ + todos, + dispatch, + mainInputRef, + }), + [todos], + ); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + return {children}; +}; diff --git a/src/Utils/Filter.ts b/src/Utils/Filter.ts new file mode 100644 index 000000000..174408fd6 --- /dev/null +++ b/src/Utils/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..774909bfd 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 './TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/todo-list.scss b/src/styles/todo-list.scss index 4576af434..b9c7965d0 100644 --- a/src/styles/todo-list.scss +++ b/src/styles/todo-list.scss @@ -71,6 +71,8 @@ } &__title-field { + box-sizing: border-box; + width: 100%; padding: 11px 14px; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..8f70a9790 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -56,6 +56,8 @@ } &__new-todo { + box-sizing: border-box; + width: 100%; padding: 16px 16px 16px 60px; diff --git a/src/types/todo.ts b/src/types/todo.ts new file mode 100644 index 000000000..3f52a5fdd --- /dev/null +++ b/src/types/todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +}