From b288d03872f42c4dc6a8174f27c10d8fc4355f59 Mon Sep 17 00:00:00 2001 From: Daria Date: Wed, 4 Mar 2026 15:10:25 +0100 Subject: [PATCH] solution --- src/App.tsx | 160 ++---------------- src/components/AddBar/AddBar.tsx | 64 +++++++ .../ErrorMessage/ErrorNotification.tsx | 31 ++++ src/components/Filter/Filter.tsx | 27 +++ src/components/Footer/Footer.tsx | 33 ++++ src/components/TodoItem/TodoItem.tsx | 129 ++++++++++++++ src/components/TodoList/TodoList.tsx | 14 ++ src/context/ErrorContext.tsx | 63 +++++++ src/context/FilterContext.tsx | 66 ++++++++ src/context/GlobalProvider.tsx | 109 ++++++++++++ src/index.tsx | 15 +- src/styles/index.scss | 4 + src/styles/todoapp.scss | 1 + src/types/Todo.ts | 5 + src/types/errorField.ts | 7 + src/types/filterItem.ts | 7 + src/types/sortField.ts | 5 + src/utils/filterItems.ts | 11 ++ 18 files changed, 608 insertions(+), 143 deletions(-) create mode 100644 src/components/AddBar/AddBar.tsx create mode 100644 src/components/ErrorMessage/ErrorNotification.tsx create mode 100644 src/components/Filter/Filter.tsx create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/context/ErrorContext.tsx create mode 100644 src/context/FilterContext.tsx create mode 100644 src/context/GlobalProvider.tsx create mode 100644 src/types/Todo.ts create mode 100644 src/types/errorField.ts create mode 100644 src/types/filterItem.ts create mode 100644 src/types/sortField.ts create mode 100644 src/utils/filterItems.ts diff --git a/src/App.tsx b/src/App.tsx index a399287bd..8a3d0b1d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,33 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { AddBar } from './components/AddBar/AddBar'; +import { ErrorNotification } from './components/ErrorMessage/ErrorNotification'; +import { useError } from './context/ErrorContext'; +import { Footer } from './components/Footer/Footer'; +import { TodoList } from './components/TodoList/TodoList'; +import { useTodos } from './context/GlobalProvider'; export const App: React.FC = () => { + const todos = useTodos(); + const { errorMessage, errorVisible, hideError } = useError(); + 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.length > 0 &&
}
+ +
); }; diff --git a/src/components/AddBar/AddBar.tsx b/src/components/AddBar/AddBar.tsx new file mode 100644 index 000000000..e353c8ec2 --- /dev/null +++ b/src/components/AddBar/AddBar.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; +import { Todo } from '../../types/Todo'; +import { ErrorField } from '../../types/errorField'; +import { useError } from '../../context/ErrorContext'; +import { useDispatch, useTodos } from '../../context/GlobalProvider'; + +export const AddBar: React.FC = () => { + const todos = useTodos(); + const dispatch = useDispatch(); + const { errorMessage, showError } = useError(); + const [query, setQuery] = useState(''); + const todoField = useRef(null); + const isActive = todos.every(todo => todo.completed); + const isVisible = todos.length !== 0; + + useEffect(() => { + todoField.current?.focus(); + }, [todos, errorMessage]); + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (query.trim() === '') { + showError(ErrorField.emptyTitle); + + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: query.trim(), + completed: false, + }; + + dispatch({ type: 'add', payload: newTodo }); + setQuery(''); + }; + + return ( +
+ {isVisible && ( +
+ ); +}; diff --git a/src/components/ErrorMessage/ErrorNotification.tsx b/src/components/ErrorMessage/ErrorNotification.tsx new file mode 100644 index 000000000..118bc426a --- /dev/null +++ b/src/components/ErrorMessage/ErrorNotification.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import cn from 'classnames'; + +type Props = { + message: string; + isVisible: boolean; + onClose: () => void; +}; + +export const ErrorNotification: React.FC = ({ + message, + isVisible, + onClose, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx new file mode 100644 index 000000000..e060de57f --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -0,0 +1,27 @@ +import cn from 'classnames'; +import React from 'react'; +import { SortType } from '../../types/sortField'; +import { filterItems } from '../../utils/filterItems'; +import { useFilter } from '../../context/FilterContext'; + +export const Filter: React.FC = () => { + const { field: sortField, onFilter: handleFilter } = useFilter(); + + return ( + + ); +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..cb0c1b18b --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,33 @@ +import React, { useMemo } from 'react'; +import { Filter } from '../Filter/Filter'; +import { useDispatch, useTodos } from '../../context/GlobalProvider'; + +export const Footer: React.FC = () => { + const todos = useTodos(); + const dispatch = useDispatch(); + const count = useMemo(() => { + return todos.filter(todo => !todo.completed).length; + }, [todos]); + + const isDisabledFooter = !todos.some(todo => todo.completed === true); + + return ( +
+ + {count} items left + + + + + +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..754388760 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,129 @@ +import React, { useEffect } from 'react'; +import { Todo } from '../../types/Todo'; +import cn from 'classnames'; +import { useDispatch } from '../../context/GlobalProvider'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const dispatch = useDispatch(); + const inputRef = React.useRef(null); + const submittedRef = React.useRef(false); + const [isEditing, setIsEditing] = React.useState(false); + const [editedTitle, setEditedTitle] = React.useState(todo.title); + + useEffect(() => { + setEditedTitle(todo.title); + }, [todo.title]); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + } else { + submittedRef.current = false; + } + }, [isEditing]); + + const startEditing = () => { + setIsEditing(true); + }; + + const finishEditing = () => { + setEditedTitle(todo.title); + setIsEditing(false); + }; + + const submitEdit = async (e?: React.FormEvent) => { + e?.preventDefault(); + + if (submittedRef.current) { + return; + } + + submittedRef.current = true; + + const trimmed = editedTitle.trim(); + + if (!trimmed) { + dispatch({ type: 'delete', payload: todo.id }); + } + + if (trimmed === todo.title) { + submittedRef.current = false; + finishEditing(); + + return; + } + + dispatch({ type: 'edit', payload: { ...todo, title: trimmed } }); + submittedRef.current = false; + finishEditing(); + }; + + const handleBlur = () => { + if (!submittedRef.current) { + submitEdit(); + } + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + finishEditing(); + } + + if (e.key === 'Enter') { + submitEdit(); + } + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {isEditing ? ( +
+ setEditedTitle(event.target.value)} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + <> + + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..239d84c41 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,14 @@ +import { useFilter } from '../../context/FilterContext'; +import { TodoItem } from '../TodoItem/TodoItem'; + +export const TodoList: React.FC = () => { + const { filteredTodos } = useFilter(); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/ErrorContext.tsx b/src/context/ErrorContext.tsx new file mode 100644 index 000000000..e3f95abdb --- /dev/null +++ b/src/context/ErrorContext.tsx @@ -0,0 +1,63 @@ +import React, { + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react'; + +interface Props { + children: React.ReactNode; +} + +interface ErrorState { + errorMessage: string; + errorVisible: boolean; + showError: (message: string) => void; + hideError: () => void; +} + +export const ErrorContext = React.createContext({ + errorMessage: '', + errorVisible: false, + showError: () => {}, + hideError: () => {}, +}); + +export const ErrorProvider: React.FC = ({ children }) => { + const [errorMessage, setErrorMessage] = useState(''); + const [errorVisible, setErrorVisible] = useState(false); + const timeoutRef = useRef(); + + const showError = useCallback((message: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + setErrorMessage(message); + setErrorVisible(true); + timeoutRef.current = setTimeout(() => { + setErrorVisible(false); + }, 3000); + }, []); + + const hideError = () => { + setErrorMessage(''); + setErrorVisible(false); + }; + + const value = useMemo(() => { + return { + errorMessage, + errorVisible, + showError, + hideError, + }; + }, [errorMessage, errorVisible, showError]); + + return ( + {children} + ); +}; + +export const useError = () => useContext(ErrorContext); diff --git a/src/context/FilterContext.tsx b/src/context/FilterContext.tsx new file mode 100644 index 000000000..9a876374a --- /dev/null +++ b/src/context/FilterContext.tsx @@ -0,0 +1,66 @@ +import { createContext, useMemo, useState } from 'react'; +import { SortType } from '../types/sortField'; +import React from 'react'; +import { useTodos } from './GlobalProvider'; +import { Todo } from '../types/Todo'; + +type Props = { + children: React.ReactNode; +}; + +type FilterState = { + filteredTodos: Todo[]; + field: SortType; + onFilter: (field: SortType) => void; +}; + +const FilterContext = createContext({ + filteredTodos: [], + field: SortType.default, + onFilter: () => {}, +}); + +export const FilterProvider: React.FC = ({ children }) => { + const todos = useTodos(); + const [sortField, setSortField] = useState(SortType.default); + + const handleFilter = (field: SortType) => { + setSortField(field); + }; + + const filteredState = useMemo(() => { + const copyTodos = [...todos]; + + switch (sortField) { + case SortType.active: + return copyTodos.filter(todo => !todo.completed); + case SortType.completed: + return copyTodos.filter(todo => todo.completed); + case SortType.default: + default: + return copyTodos; + } + }, [todos, sortField]); + + const value = useMemo(() => { + return { + filteredTodos: filteredState, + field: sortField, + onFilter: handleFilter, + }; + }, [filteredState, sortField]); + + return ( + {children} + ); +}; + +export const useFilter = () => { + const context = React.useContext(FilterContext); + + if (context === undefined) { + throw new Error('useFilter must be used within a FilterProvider'); + } + + return context; +}; diff --git a/src/context/GlobalProvider.tsx b/src/context/GlobalProvider.tsx new file mode 100644 index 000000000..1b7cbb954 --- /dev/null +++ b/src/context/GlobalProvider.tsx @@ -0,0 +1,109 @@ +import React, { useContext, useEffect } from 'react'; +import { Todo } from '../types/Todo'; + +type Action = + | { type: 'add'; payload: Todo } + | { type: 'delete'; payload: number } + | { type: 'update'; payload: Todo } + | { type: 'updateAll' } + | { type: 'deleteCompleted' } + | { type: 'edit'; payload: Todo }; + +type GlobalState = Todo[]; + +function reducer(todos: GlobalState, action: Action): GlobalState { + switch (action.type) { + case 'add': { + return [...todos, action.payload]; + } + + case 'update': { + return todos.map(todo => { + if (todo.id === action.payload.id) { + return { ...todo, completed: !todo.completed }; + } + + return todo; + }); + } + + case 'updateAll': { + const shouldComplete = todos.some(todo => !todo.completed); + + return todos.map(todo => ({ ...todo, completed: shouldComplete })); + } + + case 'delete': { + return todos.filter(todo => todo.id !== action.payload); + } + + case 'deleteCompleted': { + const completedTodosId = todos + .filter(todo => todo.completed) + .map(completedTodo => completedTodo.id); + + return todos.filter(todo => !completedTodosId.includes(todo.id)); + } + + case 'edit': { + return todos.map(todo => { + if (todo.id === action.payload.id) { + return { ...todo, title: action.payload.title }; + } + + return todo; + }); + } + + default: { + return [...todos]; + } + } +} + +const initialTodos: GlobalState = localStorage.getItem('todos') + ? JSON.parse(localStorage.getItem('todos') as string) + : []; + +export const StateContext = React.createContext(initialTodos); +export const DispatchContext = React.createContext>( + () => {}, +); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalStateProvider: React.FC = ({ children }) => { + const [todos, dispatch] = React.useReducer(reducer, initialTodos); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + return ( + + {children} + + ); +}; + +export const useTodos = () => { + const context = useContext(StateContext); + + if (!context) { + throw new Error('NO TODOS'); + } + + return context; +}; + +export const useDispatch = () => { + const context = useContext(DispatchContext); + + if (!context) { + throw new Error('NO DISPATCH'); + } + + return context; +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..cae0699d7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,22 @@ import { createRoot } from 'react-dom/client'; +import 'bulma/css/bulma.css'; +import '@fortawesome/fontawesome-free/css/all.css'; import './styles/index.scss'; import { App } from './App'; +import { ErrorProvider } from './context/ErrorContext'; +import { GlobalStateProvider } from './context/GlobalProvider'; +import { FilterProvider } from './context/FilterContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + + + + + , +); diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..a28d49c91 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,3 +1,7 @@ +html { + background-color: #fff; +} + iframe { display: none; } diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..cf79aa03b 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -58,6 +58,7 @@ &__new-todo { width: 100%; padding: 16px 16px 16px 60px; + box-sizing: border-box; font-size: 24px; line-height: 1.4em; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..f9e06b381 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; +} diff --git a/src/types/errorField.ts b/src/types/errorField.ts new file mode 100644 index 000000000..432e5d924 --- /dev/null +++ b/src/types/errorField.ts @@ -0,0 +1,7 @@ +export enum ErrorField { + emptyTitle = 'Title should not be empty', + loadError = 'Unable to load todos', + addError = 'Unable to add a todo', + deleteError = 'Unable to delete a todo', + updateError = 'Unable to update a todo', +} diff --git a/src/types/filterItem.ts b/src/types/filterItem.ts new file mode 100644 index 000000000..83cf3fd6c --- /dev/null +++ b/src/types/filterItem.ts @@ -0,0 +1,7 @@ +import { SortType } from './sortField'; + +export type FilterItem = { + field: SortType; + label: string; + dataCy: string; +}; diff --git a/src/types/sortField.ts b/src/types/sortField.ts new file mode 100644 index 000000000..96923ad69 --- /dev/null +++ b/src/types/sortField.ts @@ -0,0 +1,5 @@ +export enum SortType { + default = 'all', + active = 'active', + completed = 'completed', +} diff --git a/src/utils/filterItems.ts b/src/utils/filterItems.ts new file mode 100644 index 000000000..fc954c395 --- /dev/null +++ b/src/utils/filterItems.ts @@ -0,0 +1,11 @@ +import { SortType } from '../types/sortField'; + +export const filterItems = [ + { field: SortType.default, label: 'All', dataCy: 'FilterLinkAll' }, + { field: SortType.active, label: 'Active', dataCy: 'FilterLinkActive' }, + { + field: SortType.completed, + label: 'Completed', + dataCy: 'FilterLinkCompleted', + }, +];