diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..c2a0082f8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,156 +1,35 @@
+//#region lint exception
+/* eslint-disable react-hooks/rules-of-hooks */
+/* eslint-disable jsx-a11y/label-has-associated-control */
/* eslint-disable jsx-a11y/control-has-associated-label */
-import React from 'react';
+//#endregion
+import React, { useMemo, useState } from 'react';
+import { FilterStatus } from './types/FilterStatus';
+import { getFilteredTodos } from './utils/getFilteredTodos';
+import { Header } from './components/Header';
+import { TodoList } from './components/TodoList/TodoList';
+import { Footer } from './components/Footer/Footer';
+import { useGlobalState } from './context/Store';
export const App: React.FC = () => {
+ const [filter, setFilter] = useState(FilterStatus.All);
+ const todos = useGlobalState();
+
+ const filteredTodos = useMemo(
+ () => getFilteredTodos(todos, filter),
+ [filter, todos],
+ );
+
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 &&
}
);
diff --git a/src/api/todos.ts b/src/api/todos.ts
new file mode 100644
index 000000000..8b332f4fa
--- /dev/null
+++ b/src/api/todos.ts
@@ -0,0 +1,20 @@
+import { Todo } from '../types/Todo';
+import { client } from '../utils/fetchClient';
+
+export const USER_ID = 968;
+
+export const getTodos = () => {
+ return client.get(`/todos?userId=${USER_ID}`);
+};
+
+export const postTodo = (todo: Omit) => {
+ return client.post(`/todos`, todo);
+};
+
+export const deleteTodo = (todoId: number) => {
+ return client.delete(`/todos/${todoId}`);
+};
+
+export const patchTodo = (id: number, todoData: Partial) => {
+ return client.patch(`/todos/${id}`, todoData);
+};
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
new file mode 100644
index 000000000..619b9ccd0
--- /dev/null
+++ b/src/components/Footer/Footer.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { FilterStatus } from '../../types/FilterStatus';
+import cn from 'classnames';
+import { useDispatch, useGlobalState } from '../../context/Store';
+
+type Props = {
+ filter: FilterStatus;
+ onFilter: (filter: FilterStatus) => void;
+};
+
+export const Footer: React.FC = ({ filter, onFilter }) => {
+ const todos = useGlobalState();
+ const dispatch = useDispatch();
+
+ const activeTodos = todos.filter(todo => !todo.completed).length;
+
+ const haveCompletedTodos = todos.some(todo => todo.completed);
+
+ const handleClearCompleted = () =>
+ todos
+ .filter(todo => todo.completed)
+ .forEach(todo => dispatch({ type: 'delete', payload: todo.id }));
+
+ return (
+
+ );
+};
diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts
new file mode 100644
index 000000000..ddcc5a9cd
--- /dev/null
+++ b/src/components/Footer/index.ts
@@ -0,0 +1 @@
+export * from './Footer';
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
new file mode 100644
index 000000000..09dece5b7
--- /dev/null
+++ b/src/components/Header/Header.tsx
@@ -0,0 +1,85 @@
+import React, { FC, useEffect, useRef, useState } from 'react';
+import cn from 'classnames';
+
+import { useDispatch, useGlobalState } from '../../context/Store';
+
+export const Header: FC = () => {
+ const [title, setTitle] = useState('');
+ const [isSubmitting, setIsSubmiting] = useState(false);
+
+ const todos = useGlobalState();
+ const dispatch = useDispatch();
+
+ const areAllTodosCompleted = todos.every(todo => todo.completed);
+
+ const handleToggleAll = () => {
+ const haveActive = todos.some(todo => !todo.completed);
+ const todosToUpdate = haveActive
+ ? todos.filter(todo => !todo.completed)
+ : todos;
+
+ todosToUpdate.forEach(todo =>
+ dispatch({ type: 'update', payload: { ...todo, completed: haveActive } }),
+ );
+ };
+
+ const handleSubmit = (
+ event: // eslint-disable-next-line @typescript-eslint/indent
+ React.FormEvent | React.FocusEvent,
+ ) => {
+ event.preventDefault();
+
+ const newTitle = title.trim();
+
+ if (!newTitle) {
+ setTitle('');
+
+ return;
+ }
+
+ try {
+ setIsSubmiting(true);
+ dispatch({ type: 'add', payload: newTitle });
+ setTitle('');
+ } catch (error) {
+ throw error;
+ } finally {
+ setIsSubmiting(false);
+ }
+ };
+
+ const titleField = useRef(null);
+
+ useEffect(() => {
+ titleField.current?.focus();
+ }, [todos, isSubmitting]);
+
+ return (
+
+ {!!todos.length && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts
new file mode 100644
index 000000000..266dec8a1
--- /dev/null
+++ b/src/components/Header/index.ts
@@ -0,0 +1 @@
+export * from './Header';
diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx
new file mode 100644
index 000000000..d1b3600e9
--- /dev/null
+++ b/src/components/TodoItem/TodoItem.tsx
@@ -0,0 +1,126 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import React, { useEffect, useRef, useState } from 'react';
+import cn from 'classnames';
+import { Todo } from '../../types/Todo';
+import { useDispatch } from '../../context/Store';
+
+type Props = {
+ todo: Todo;
+};
+
+export const TodoItem: React.FC = ({ todo }) => {
+ const { id, title, completed } = todo;
+
+ const dispatch = useDispatch();
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [editedTitle, setEditedTitle] = useState(title);
+
+ const handleDelete = () => dispatch({ type: 'delete', payload: id });
+
+ const handleSubmit = (
+ event: // eslint-disable-next-line @typescript-eslint/indent
+ React.FormEvent | React.FocusEvent,
+ ) => {
+ event.preventDefault();
+
+ const validEditedTitle = editedTitle.trim();
+
+ if (validEditedTitle === title) {
+ setIsEditing(false);
+
+ return;
+ }
+
+ if (!validEditedTitle) {
+ handleDelete();
+
+ return;
+ }
+
+ try {
+ dispatch({
+ type: 'update',
+ payload: { ...todo, title: validEditedTitle },
+ });
+ setIsEditing(false);
+ } catch (error) {
+ setIsEditing(true);
+
+ throw error;
+ }
+ };
+
+ const handleEsc = (event: React.KeyboardEvent) => {
+ event.preventDefault();
+
+ if (event.key === 'Escape') {
+ setIsEditing(false);
+ setEditedTitle(title);
+ }
+ };
+
+ const toggleCompletedStatus = () => {
+ dispatch({
+ type: 'update',
+ payload: { ...todo, completed: !completed },
+ });
+ };
+
+ const editField = useRef(null);
+
+ useEffect(() => {
+ if (isEditing) {
+ editField.current?.focus();
+ }
+ }, [isEditing]);
+
+ return (
+
+
+
+
+
+ {isEditing ? (
+
+ ) : (
+ <>
+ setIsEditing(true)}
+ >
+ {title}
+
+
+
+ ×
+
+ >
+ )}
+
+ );
+};
diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts
new file mode 100644
index 000000000..21f4abac3
--- /dev/null
+++ b/src/components/TodoItem/index.ts
@@ -0,0 +1 @@
+export * from './TodoItem';
diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx
new file mode 100644
index 000000000..09c40ce0c
--- /dev/null
+++ b/src/components/TodoList/TodoList.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Todo } from '../../types/Todo';
+import { TodoItem } from '../TodoItem/TodoItem';
+
+type Props = {
+ filteredTodos: Todo[];
+};
+
+export const TodoList: React.FC = ({ filteredTodos }) => (
+
+ {filteredTodos.map((todo: Todo) => (
+
+ ))}
+
+);
diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts
new file mode 100644
index 000000000..f239f4345
--- /dev/null
+++ b/src/components/TodoList/index.ts
@@ -0,0 +1 @@
+export * from './TodoList';
diff --git a/src/context/Store.tsx b/src/context/Store.tsx
new file mode 100644
index 000000000..000d10a2d
--- /dev/null
+++ b/src/context/Store.tsx
@@ -0,0 +1,74 @@
+import {
+ createContext,
+ Dispatch,
+ FC,
+ ReactNode,
+ useContext,
+ useReducer,
+} from 'react';
+import { Todo } from '../types/Todo';
+import { getLocalStorage } from '../utils/getLocalStorageData';
+
+type Action =
+ | { type: 'add'; payload: string }
+ | { type: 'update'; payload: Todo }
+ | { type: 'delete'; payload: number };
+
+function reducer(todos: Todo[], action: Action) {
+ let newTodos: Todo[] = [];
+
+ switch (action.type) {
+ case 'add':
+ newTodos = [
+ ...todos,
+ {
+ id: +new Date(),
+ title: action.payload,
+ completed: false,
+ },
+ ];
+ break;
+
+ case 'update':
+ newTodos = todos.map(todo =>
+ todo.id === action.payload.id ? action.payload : todo,
+ );
+ break;
+
+ case 'delete':
+ newTodos = todos.filter(todo => todo.id !== action.payload);
+ break;
+
+ default:
+ return todos;
+ }
+
+ localStorage.setItem('todos', JSON.stringify(newTodos));
+
+ return newTodos;
+}
+
+const initialTodos: Todo[] = getLocalStorage('todos', []);
+
+const defaultDispatch: Dispatch = () => {};
+
+export const TodosContext = createContext(initialTodos);
+export const DispatchContext = createContext(defaultDispatch);
+
+type Props = {
+ children: ReactNode;
+};
+
+export const GlobalStateProvider: FC = ({ children }) => {
+ const [todos, dispatch] = useReducer(reducer, initialTodos);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useGlobalState = () => useContext(TodosContext);
+
+export const useDispatch = () => useContext(DispatchContext);
diff --git a/src/index.tsx b/src/index.tsx
index a9689cb38..7beac4659 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,11 +1,14 @@
import { createRoot } from 'react-dom/client';
-import './styles/index.css';
-import './styles/todo-list.css';
-import './styles/filters.css';
+import 'bulma/css/bulma.css';
+import '@fortawesome/fontawesome-free/css/all.css';
+import './styles/index.scss';
import { App } from './App';
+import { GlobalStateProvider } from './context/Store';
-const container = document.getElementById('root') as HTMLDivElement;
-
-createRoot(container).render( );
+createRoot(document.getElementById('root') as HTMLDivElement).render(
+
+
+ ,
+);
diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts
new file mode 100644
index 000000000..7ca17f289
--- /dev/null
+++ b/src/types/FilterStatus.ts
@@ -0,0 +1,5 @@
+export enum FilterStatus {
+ All = 'All',
+ Active = 'Active',
+ Completed = 'Completed',
+}
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/utils/fetchClient.ts b/src/utils/fetchClient.ts
new file mode 100644
index 000000000..33b2e0bd2
--- /dev/null
+++ b/src/utils/fetchClient.ts
@@ -0,0 +1,46 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+const BASE_URL = 'https://mate.academy/students-api';
+
+// returns a promise resolved after a given delay
+function wait(delay: number) {
+ return new Promise(resolve => {
+ setTimeout(resolve, delay);
+ });
+}
+
+// To have autocompletion and avoid mistypes
+type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
+
+function request(
+ url: string,
+ method: RequestMethod = 'GET',
+ data: any = null, // we can send any data to the server
+): Promise {
+ const options: RequestInit = { method };
+
+ if (data) {
+ // We add body and Content-Type only for the requests with data
+ options.body = JSON.stringify(data);
+ options.headers = {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ };
+ }
+
+ // DON'T change the delay it is required for tests
+ return wait(100)
+ .then(() => fetch(BASE_URL + url, options))
+ .then(response => {
+ if (!response.ok) {
+ throw new Error();
+ }
+
+ return response.json();
+ });
+}
+
+export const client = {
+ get: (url: string) => request(url),
+ post: (url: string, data: any) => request(url, 'POST', data),
+ patch: (url: string, data: any) => request(url, 'PATCH', data),
+ delete: (url: string) => request(url, 'DELETE'),
+};
diff --git a/src/utils/getFilteredTodos.ts b/src/utils/getFilteredTodos.ts
new file mode 100644
index 000000000..2a28f3ceb
--- /dev/null
+++ b/src/utils/getFilteredTodos.ts
@@ -0,0 +1,17 @@
+import { FilterStatus } from '../types/FilterStatus';
+import { Todo } from '../types/Todo';
+
+export function getFilteredTodos(todos: Todo[], filter: FilterStatus) {
+ return todos.filter(todo => {
+ switch (filter) {
+ case FilterStatus.Active:
+ return !todo.completed;
+
+ case FilterStatus.Completed:
+ return todo.completed;
+
+ default:
+ return todos;
+ }
+ });
+}
diff --git a/src/utils/getLocalStorageData.ts b/src/utils/getLocalStorageData.ts
new file mode 100644
index 000000000..e1555f4f9
--- /dev/null
+++ b/src/utils/getLocalStorageData.ts
@@ -0,0 +1,17 @@
+export function getLocalStorage(key: string, value: T) {
+ const data = localStorage.getItem(key);
+
+ if (!data) {
+ localStorage.setItem(key, JSON.stringify(value));
+
+ return value;
+ }
+
+ try {
+ return JSON.parse(data);
+ } catch {
+ localStorage.removeItem(key);
+
+ return value;
+ }
+}