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 */}
-
-
- {/* Add a todo on form submit */}
-
-
-
-
-
- {/* Hide the footer if there are no todos */}
-
-
- 3 items left
-
-
- {/* Active link should have the 'selected' class */}
-
-
- All
-
-
-
- Active
-
-
-
- Completed
-
-
-
- {/* this button should be disabled if there are no completed todos */}
-
- Clear completed
-
-
+ {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 && (
+ dispatch({ type: 'updateAll' })}
+ />
+ )}
+
+
+
+ );
+};
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 (
+
+
+ {message}
+
+ );
+};
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 (
+
+ {filterItems.map(filterItem => (
+ handleFilter(filterItem.field)}
+ >
+ {filterItem.label}
+
+ ))}
+
+ );
+};
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
+
+
+
+
+ dispatch({ type: 'deleteCompleted' })}
+ >
+ Clear completed
+
+
+ );
+};
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 */}
+
+ dispatch({ type: 'update', payload: todo })}
+ />
+
+
+ {isEditing ? (
+
+ ) : (
+ <>
+
+ {todo.title}
+
+
+ dispatch({ type: 'delete', payload: todo.id })}
+ >
+ ×
+
+ >
+ )}
+
+ );
+};
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',
+ },
+];