From cb90c4ac0f491f5d5cfaea501bd70045b3076d76 Mon Sep 17 00:00:00 2001 From: Dmytro Khromiak Date: Sun, 24 May 2026 15:09:46 +0300 Subject: [PATCH] implement solution --- README.md | 2 +- src/App.tsx | 174 +++--------------- src/components/Context/TodoContext.tsx | 66 +++++++ src/components/Context/TodoReducer.tsx | 73 ++++++++ src/components/Filter/Filter.tsx | 38 ++++ .../Filter}/filters.scss | 0 src/components/Footer/Footer.tsx | 36 ++++ src/components/Header/Header.tsx | 61 ++++++ src/components/TodoItem/TodoItem.tsx | 118 ++++++++++++ .../TodoList/TodoList.scss} | 7 + src/components/TodoList/TodoList.tsx | 32 ++++ src/styles/index.scss | 7 +- src/styles/todoapp.scss | 6 + src/types/type.ts | 5 + 14 files changed, 474 insertions(+), 151 deletions(-) create mode 100644 src/components/Context/TodoContext.tsx create mode 100644 src/components/Context/TodoReducer.tsx create mode 100644 src/components/Filter/Filter.tsx rename src/{styles => components/Filter}/filters.scss (100%) create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx rename src/{styles/todo-list.scss => components/TodoList/TodoList.scss} (94%) create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/types/type.ts diff --git a/README.md b/README.md index 903c876f9..17b898fd4 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://whomngmnt.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..02ad7070f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,37 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import { useContext } from 'react'; +import { Footer } from './components/Footer/Footer'; +import { Header } from './components/Header/Header'; +import { TodoContext, TodoProvider } from './components/Context/TodoContext'; +import { TodoList } from './components/TodoList/TodoList'; + +const TodoApp = () => { + const context = useContext(TodoContext); + + if (!context) { + return null; + } + + const hasTodos = context.state.todos.length > 0; -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 */} - -
+
+ {hasTodos && ( + <> + +
+ + )}
); }; + +export const App = () => ( + + + +); diff --git a/src/components/Context/TodoContext.tsx b/src/components/Context/TodoContext.tsx new file mode 100644 index 000000000..1da4fce3e --- /dev/null +++ b/src/components/Context/TodoContext.tsx @@ -0,0 +1,66 @@ +import { + createContext, + Dispatch, + ReactNode, + useEffect, + useReducer, +} from 'react'; +import { Todo } from '../../types/type'; +import { Action, todoReducer } from './TodoReducer'; + +export type FilterStatus = 'all' | 'active' | 'completed'; + +export type TodoState = { + todos: Todo[]; + filter: FilterStatus; +}; + +type TodoContextValue = { + state: TodoState; + dispatch: Dispatch; +}; + +const STORAGE_KEY = 'todos'; + +const getInitialState = (): TodoState => { + const savedTodos = localStorage.getItem(STORAGE_KEY); + + if (!savedTodos) { + return { + todos: [], + filter: 'all', + }; + } + + try { + return { + todos: JSON.parse(savedTodos) as Todo[], + filter: 'all', + }; + } catch { + return { + todos: [], + filter: 'all', + }; + } +}; + +export const TodoContext = createContext(null); + +type Props = { + children: ReactNode; +}; + +export const TodoProvider = ({ children }: Props) => { + const [state, dispatch] = useReducer(todoReducer, undefined, getInitialState); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.todos)); + }, [state.todos]); + + return ( + + {children} + + ); +}; diff --git a/src/components/Context/TodoReducer.tsx b/src/components/Context/TodoReducer.tsx new file mode 100644 index 000000000..d2a005970 --- /dev/null +++ b/src/components/Context/TodoReducer.tsx @@ -0,0 +1,73 @@ +import type { FilterStatus, TodoState } from './TodoContext'; + +type UpdateTodoPayload = { + id: number; + title?: string; + completed?: boolean; +}; + +export type Action = + | { type: 'add'; payload: string } + | { type: 'delete'; payload: number } + | { type: 'update'; payload: UpdateTodoPayload } + | { type: 'setFilter'; payload: FilterStatus } + | { type: 'toggleAll' } + | { type: 'clearCompleted' }; + +export const todoReducer = (state: TodoState, action: Action): TodoState => { + switch (action.type) { + case 'add': + return { + ...state, + todos: [ + ...state.todos, + { + id: +new Date(), + title: action.payload, + completed: false, + }, + ], + }; + + case 'delete': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload), + }; + + case 'update': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id ? { ...todo, ...action.payload } : todo, + ), + }; + + case 'setFilter': + return { + ...state, + filter: action.payload, + }; + + case 'toggleAll': { + const shouldCompleteAll = state.todos.some(todo => !todo.completed); + + return { + ...state, + todos: state.todos.map(todo => ({ + ...todo, + completed: shouldCompleteAll, + })), + }; + } + + case 'clearCompleted': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed), + }; + + default: + return state; + } +}; diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx new file mode 100644 index 000000000..de0aed49c --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -0,0 +1,38 @@ +import { useContext } from 'react'; +import { FilterStatus, TodoContext } from '../Context/TodoContext'; + +const filters: { label: string; value: FilterStatus; dataCy: string }[] = [ + { label: 'All', value: 'all', dataCy: 'FilterLinkAll' }, + { label: 'Active', value: 'active', dataCy: 'FilterLinkActive' }, + { label: 'Completed', value: 'completed', dataCy: 'FilterLinkCompleted' }, +]; + +export const Filter = () => { + const context = useContext(TodoContext); + + if (!context) { + return null; + } + + const { filter } = context.state; + const { dispatch } = context; + + return ( + + ); +}; diff --git a/src/styles/filters.scss b/src/components/Filter/filters.scss similarity index 100% rename from src/styles/filters.scss rename to src/components/Filter/filters.scss diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..7d68123e5 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,36 @@ +import { useContext } from 'react'; +import { TodoContext } from '../Context/TodoContext'; +import { Filter } from '../Filter/Filter'; + +export const Footer = () => { + const context = useContext(TodoContext); + + if (!context) { + return null; + } + + const { todos } = context.state; + const { dispatch } = context; + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const hasCompletedTodos = todos.some(todo => todo.completed); + + return ( +
+ + {activeTodosCount} {activeTodosCount === 1 ? 'item' : 'items'} left + + + + + +
+ ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..d439d5013 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,61 @@ +import { FormEvent, useContext, useEffect, useRef, useState } from 'react'; +import { TodoContext } from '../Context/TodoContext'; + +export const Header = () => { + const context = useContext(TodoContext); + const [title, setTitle] = useState(''); + const inputRef = useRef(null); + const todos = context?.state.todos ?? []; + + const areAllCompleted = + todos.length > 0 && todos.every(todo => todo.completed); + + useEffect(() => { + inputRef.current?.focus(); + }, [todos.length]); + + if (!context) { + return null; + } + + const { dispatch } = context; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + dispatch({ type: 'add', payload: trimmedTitle }); + setTitle(''); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..774aded3c --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,118 @@ +import { KeyboardEvent, useContext, useState } from 'react'; +import { Todo } from '../../types/type'; +import { TodoContext } from '../Context/TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem = ({ todo }: Props) => { + const context = useContext(TodoContext); + const [isEditing, setIsEditing] = useState(false); + const [draftTitle, setDraftTitle] = useState(todo.title); + + if (!context) { + return null; + } + + const { dispatch } = context; + + const saveTitle = () => { + const trimmedTitle = draftTitle.trim(); + + if (!trimmedTitle) { + dispatch({ type: 'delete', payload: todo.id }); + setIsEditing(false); + + return; + } + + dispatch({ + type: 'update', + payload: { + id: todo.id, + title: trimmedTitle, + }, + }); + setIsEditing(false); + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + saveTitle(); + } + + if (event.key === 'Escape') { + setDraftTitle(todo.title); + setIsEditing(false); + } + }; + + return ( +
+
+ { + dispatch({ + type: 'update', + payload: { + id: todo.id, + completed: !todo.completed, + }, + }); + }} + /> +
+ + {isEditing ? ( +
{ + event.preventDefault(); + saveTitle(); + }} + > + setDraftTitle(event.target.value)} + onBlur={saveTitle} + onKeyUp={handleKeyUp} + autoFocus + /> +
+ ) : ( + <> + { + setDraftTitle(todo.title); + setIsEditing(true); + }} + > + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/styles/todo-list.scss b/src/components/TodoList/TodoList.scss similarity index 94% rename from src/styles/todo-list.scss rename to src/components/TodoList/TodoList.scss index 4576af434..2b88ef969 100644 --- a/src/styles/todo-list.scss +++ b/src/components/TodoList/TodoList.scss @@ -14,6 +14,9 @@ } &__status-label { + display: flex; + align-items: center; + width: 45px; cursor: pointer; background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); background-repeat: no-repeat; @@ -25,6 +28,10 @@ } &__status { + width: 45px; + height: 100%; + margin: 0; + cursor: pointer; opacity: 0; } diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..baee38c00 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,32 @@ +import { useContext } from 'react'; +import { TodoContext } from '../Context/TodoContext'; +import { TodoItem } from '../TodoItem/TodoItem'; + +export const TodoList = () => { + const context = useContext(TodoContext); + + if (!context) { + return null; + } + + const { todos, filter } = context.state; + const visibleTodos = todos.filter(todo => { + if (filter === 'active') { + return !todo.completed; + } + + if (filter === 'completed') { + return todo.completed; + } + + return true; + }); + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..5e1329aa6 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,3 +1,7 @@ +@import './todoapp'; +@import '../components/TodoList/TodoList'; +@import '../components/Filter/filters'; + iframe { display: none; } @@ -20,6 +24,3 @@ body { pointer-events: none; } -@import './todoapp'; -@import './todo-list'; -@import './filters'; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..5a2bac337 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -25,6 +25,10 @@ &__header { position: relative; + + form { + margin: 0; + } } &__toggle-all { @@ -56,6 +60,7 @@ } &__new-todo { + box-sizing: border-box; width: 100%; padding: 16px 16px 16px 60px; @@ -68,6 +73,7 @@ -moz-osx-font-smoothing: grayscale; border: none; + outline: none; background: rgba(0, 0, 0, 0.01); box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); diff --git a/src/types/type.ts b/src/types/type.ts new file mode 100644 index 000000000..d94ea1bff --- /dev/null +++ b/src/types/type.ts @@ -0,0 +1,5 @@ +export type Todo = { + id: number; + title: string; + completed: boolean; +};