diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..c43e347f8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,157 +1,85 @@
/* eslint-disable jsx-a11y/control-has-associated-label */
-import React from 'react';
+import React, { useEffect } from 'react';
+import { UserWarning } from './UserWarning';
+import { getTodos } from './api/todos';
+import { Header } from './components/Header';
+import { Footer } from './components/Footer';
+import { Filter } from './types/Filter';
+import { TodoItem } from './components/TodoItem';
+import { focusInput } from './utils/services';
+import { ErrNotification } from './components/ErrNotification';
+import { useTodoContext } from './components/TodoContext';
+import { USER_ID } from './utils/constants';
export const App: React.FC = () => {
+ const {
+ todos,
+ setTodos,
+ isLoading,
+ setIsLoading,
+ activeTodoId,
+ isSubmitting,
+ tempTodo,
+ inputRef,
+ filter,
+ showError,
+ } = useTodoContext();
+
+ useEffect(() => {
+ const fetchTodos = async () => {
+ setIsLoading(true);
+ try {
+ const fetchedTodos = await getTodos(USER_ID);
+
+ setTodos(fetchedTodos);
+ } catch {
+ showError('Unable to load todos');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchTodos();
+ }, []);
+
+ useEffect(() => {
+ focusInput(inputRef);
+ }, [isSubmitting, activeTodoId, inputRef]);
+
+ const filteredTodos = todos.filter(todo => {
+ switch (filter) {
+ case Filter.Active:
+ return !todo.completed;
+ case Filter.Completed:
+ return todo.completed;
+ default:
+ return true;
+ }
+ });
+
+ if (!USER_ID) {
+ return ;
+ }
+
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
-
-
+
+ {!isLoading && (
+ <>
+
+ {filteredTodos.map(todo => (
+
+ ))}
+ {tempTodo && }
+
+ {todos.length > 0 &&
}
+ >
+ )}
+
);
};
diff --git a/src/UserWarning.tsx b/src/UserWarning.tsx
new file mode 100644
index 000000000..fa25838e6
--- /dev/null
+++ b/src/UserWarning.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+export const UserWarning: React.FC = () => (
+
+
+ Please get your userId {' '}
+
+ here
+ {' '}
+ and save it in the app
const USER_ID = ...
+ All requests to the API must be sent with this
+ userId.
+
+
+);
diff --git a/src/api/todos.ts b/src/api/todos.ts
new file mode 100644
index 000000000..2cb0bf6fd
--- /dev/null
+++ b/src/api/todos.ts
@@ -0,0 +1,22 @@
+import { Todo } from '../types/Todo';
+import { client } from '../utils/fetchClient';
+
+// Your userId is 1414
+// Please use it for all your requests to the Students API. For example:
+// https://mate.academy/students-api/todos?userId=1414
+
+export const getTodos = (userid: number) => {
+ return client.get(`/todos?userId=${userid}`);
+};
+
+export function deleteTodo(todoId: number) {
+ return client.delete(`/todos/${todoId}`);
+}
+
+export function createTodo({ title, userId, completed }: Omit) {
+ return client.post('/todos', { title, userId, completed });
+}
+
+export function updateTodo({ id, title, completed, userId }: Todo) {
+ return client.patch(`/todos/${id}`, { title, completed, userId });
+}
diff --git a/src/components/ErrNotification.tsx b/src/components/ErrNotification.tsx
new file mode 100644
index 000000000..afa9402a4
--- /dev/null
+++ b/src/components/ErrNotification.tsx
@@ -0,0 +1,26 @@
+import classNames from 'classnames';
+import { useTodoContext } from './TodoContext';
+
+type Props = {};
+
+export const ErrNotification: React.FC = () => {
+ const { error, setError } = useTodoContext();
+
+ return (
+
+
setError('')}
+ />
+ {error}
+
+ );
+};
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 000000000..a133923e1
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,83 @@
+import classNames from 'classnames';
+import { Filter } from '../types/Filter';
+import { useTodoContext } from './TodoContext';
+import { deleteTodo } from '../api/todos';
+import { focusInput } from '../utils/services';
+import { Todo } from '../types/Todo';
+
+type Props = {};
+
+export const Footer: React.FC = () => {
+ const {
+ todos,
+ setTodos,
+ showError,
+ setActiveTodoId,
+ inputRef,
+ filter,
+ setFilter,
+ } = useTodoContext();
+
+ const hasCompleted = todos.some(todo => todo.completed);
+
+ const handleFilterChange = (newFilter: Filter) => {
+ setFilter(newFilter);
+ };
+
+ const handleClearCompleted = async () => {
+ const completedTodoIds = todos
+ .filter(todo => todo.completed)
+ .map(todo => todo.id);
+
+ Promise.allSettled(
+ completedTodoIds.map(async todoId => {
+ try {
+ await deleteTodo(todoId);
+
+ setTodos((currentTodos: Todo[]) =>
+ currentTodos.filter(todo => todo.id !== todoId),
+ );
+ } catch {
+ showError('Unable to delete a todo');
+ } finally {
+ setActiveTodoId(null);
+ focusInput(inputRef);
+ }
+ }),
+ );
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 000000000..cf9dcfb1d
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,152 @@
+import classNames from 'classnames';
+import { useTodoContext } from './TodoContext';
+import { createTodo, updateTodo } from '../api/todos';
+import { Todo } from '../types/Todo';
+import { focusInput } from '../utils/services';
+import { USER_ID } from '../utils/constants';
+
+type Props = {};
+
+export const Header: React.FC = () => {
+ const {
+ todoTitle,
+ setTodoTitle,
+ todos,
+ setTodos,
+ showError,
+ setActiveTodoList,
+ setError,
+ inputRef,
+ isSubmitting,
+ setIsSubmitting,
+ setActiveTodoId,
+ setTempTodo,
+ } = useTodoContext();
+
+ const isAllCompleted =
+ todos.length > 0 && todos.every(todo => todo.completed);
+
+ const handleToggleAll = async () => {
+ let toggledList = todos.filter(todo => !todo.completed);
+
+ if (isAllCompleted) {
+ toggledList = [...todos];
+ }
+
+ const shouldCompleteAll = !isAllCompleted;
+ const activeTodoIds = toggledList.map(todo => todo.id);
+
+ setActiveTodoList(activeTodoIds);
+
+ await Promise.allSettled(
+ toggledList.map(async todo => {
+ const updatedTodo = {
+ ...todo,
+ completed: shouldCompleteAll,
+ };
+ const { id, title, completed, userId } = updatedTodo;
+
+ try {
+ const updated = await updateTodo({ id, title, completed, userId });
+
+ setTodos(currentTodos =>
+ currentTodos.map(t => (t.id === updated.id ? updated : t)),
+ );
+ } catch {
+ showError(`Unable to update todo with ID ${todo.id}`);
+ }
+ }),
+ );
+
+ setActiveTodoList([]);
+ };
+
+ const resetForm = () => {
+ setTodoTitle('');
+ setTempTodo(null);
+ setError('');
+ };
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ const trimmedTitle = todoTitle.trim();
+
+ setError('');
+ setTodoTitle(trimmedTitle);
+
+ if (!trimmedTitle) {
+ showError('Title should not be empty');
+ focusInput(inputRef);
+
+ return;
+ }
+
+ const newTempTodo: Todo = {
+ id: 0,
+ title: trimmedTitle,
+ userId: USER_ID,
+ completed: false,
+ };
+
+ setTempTodo(newTempTodo);
+ setIsSubmitting(true);
+ setActiveTodoId(newTempTodo.id);
+
+ try {
+ const newTodo = await createTodo({
+ title: trimmedTitle,
+ userId: USER_ID,
+ completed: false,
+ });
+
+ setTodos(prevTodos => [...prevTodos, newTodo]);
+ resetForm();
+ } catch (err) {
+ showError('Unable to add a todo');
+ throw err;
+ } finally {
+ setIsSubmitting(false);
+ setTempTodo(null);
+ setActiveTodoId(null);
+ focusInput(inputRef);
+ }
+ };
+
+ const handleTitleChange = (event: React.ChangeEvent) => {
+ setTodoTitle(event.target.value);
+ setError('');
+ };
+
+ return (
+
+ {todos.length > 0 && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/TodoContext.tsx b/src/components/TodoContext.tsx
new file mode 100644
index 000000000..5c06946e3
--- /dev/null
+++ b/src/components/TodoContext.tsx
@@ -0,0 +1,90 @@
+import React, { useContext, useMemo, useRef, useState } from 'react';
+import { Todo } from '../types/Todo';
+import { Filter } from '../types/Filter';
+
+type TodoContextType = {
+ todos: Todo[];
+ setTodos: React.Dispatch>;
+ isLoading: boolean;
+ setIsLoading: (state: boolean) => void;
+ error: string;
+ setError: (err: string) => void;
+ showError: (message: string) => void;
+ activeTodoId: number | null;
+ setActiveTodoId: (todoId: number | null) => void;
+ inputRef: React.RefObject;
+ activeTodoList: number[];
+ setActiveTodoList: (activeTodoIds: number[]) => void;
+ tempTodo: Todo | null;
+ setTempTodo: (todo: Todo | null) => void;
+ isSubmitting: boolean;
+ setIsSubmitting: (issubmiting: boolean) => void;
+ todoTitle: string;
+ setTodoTitle: (newtitle: string) => void;
+ filter: Filter;
+ setFilter: (filter: Filter) => void;
+};
+
+export const TodoContext = React.createContext(
+ undefined,
+);
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const TodoProvider: React.FC = ({ children }) => {
+ const [todos, setTodos] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [todoTitle, setTodoTitle] = useState('');
+ const [filter, setFilter] = useState(Filter.All);
+ const [activeTodoId, setActiveTodoId] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [tempTodo, setTempTodo] = useState(null);
+ const inputRef = useRef(null);
+ const [activeTodoList, setActiveTodoList] = useState([]);
+
+ const showError = (message: string) => {
+ setError(message);
+ setTimeout(() => setError(''), 3000);
+ };
+
+ const value = useMemo(
+ () => ({
+ todos,
+ setTodos,
+ isLoading,
+ setIsLoading,
+ error,
+ setError,
+ todoTitle,
+ setTodoTitle,
+ activeTodoId,
+ setActiveTodoId,
+ isSubmitting,
+ setIsSubmitting,
+ tempTodo,
+ setTempTodo,
+ inputRef,
+ activeTodoList,
+ setActiveTodoList,
+ showError,
+ filter,
+ setFilter,
+ }),
+ [todos, todoTitle, filter, activeTodoId, activeTodoList, error],
+ );
+
+ return {children} ;
+};
+
+export const useTodoContext = () => {
+ const context = useContext(TodoContext);
+
+ if (!context) {
+ throw new Error('useTodoContext must be used within a TodoProvider');
+ }
+
+ return context;
+};
diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx
new file mode 100644
index 000000000..dd22a7a99
--- /dev/null
+++ b/src/components/TodoItem.tsx
@@ -0,0 +1,207 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import classNames from 'classnames';
+import { Todo } from '../types/Todo';
+import { useEffect, useState } from 'react';
+import { focusInput } from '../utils/services';
+import { deleteTodo, updateTodo } from '../api/todos';
+import { USER_ID } from '../utils/constants';
+import { useTodoContext } from './TodoContext';
+
+type Props = {
+ todo: Todo;
+};
+
+export const TodoItem: React.FC = ({ todo }) => {
+ const {
+ inputRef,
+ setActiveTodoId,
+ setTodos,
+ showError,
+ activeTodoId,
+ activeTodoList,
+ setError,
+ } = useTodoContext();
+
+ const { id, completed, title } = todo;
+ const [isEditing, setIsEditing] = useState(false);
+ const [newTitle, setNewTitle] = useState(todo.title);
+
+ useEffect(() => {
+ focusInput(inputRef);
+ }, [isEditing, inputRef]);
+
+ const handleEdit = () => {
+ setIsEditing(true);
+ };
+
+ const handleChange = (event: React.ChangeEvent) => {
+ setNewTitle(event.target.value);
+ };
+
+ const handleUpdateTodo = async (todoItem: Todo, newTodoTitle: string) => {
+ setActiveTodoId(todoItem.id);
+
+ try {
+ const updatedTodo = await updateTodo({
+ id: todoItem.id,
+ title: newTodoTitle,
+ completed: todoItem.completed,
+ userId: USER_ID,
+ });
+
+ setTodos(prevTodos =>
+ prevTodos.map(tItem =>
+ tItem.id === updatedTodo.id ? updatedTodo : tItem,
+ ),
+ );
+ } catch (err) {
+ showError('Unable to update a todo');
+ throw err;
+ } finally {
+ setActiveTodoId(null);
+ }
+ };
+
+ const handleDeleteTodo = async (todoId: number) => {
+ setActiveTodoId(todoId);
+ try {
+ await deleteTodo(todoId);
+ setTodos(currentTodos => currentTodos.filter(t => t.id !== todoId));
+ } catch (err) {
+ showError('Unable to delete a todo');
+ throw err;
+ } finally {
+ setActiveTodoId(null);
+ focusInput(inputRef);
+ }
+ };
+
+ const handleRenameSubmit = async () => {
+ let errorMsg = false;
+
+ if (newTitle.trim() === '') {
+ try {
+ await handleDeleteTodo(todo.id);
+ } catch (err) {
+ if (err) {
+ errorMsg = true;
+ }
+
+ focusInput(inputRef);
+ }
+ } else if (newTitle.trim() !== todo.title) {
+ try {
+ await handleUpdateTodo(todo, newTitle.trim());
+ } catch (err) {
+ if (err) {
+ errorMsg = true;
+ }
+
+ focusInput(inputRef);
+ }
+ }
+
+ if (errorMsg) {
+ focusInput(inputRef);
+
+ return;
+ }
+
+ setIsEditing(false);
+ };
+
+ const handleKeyUp = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleRenameSubmit();
+ } else if (e.key === 'Escape') {
+ setIsEditing(false);
+ setNewTitle(todo.title);
+ }
+ };
+
+ const handleBlur = () => {
+ handleRenameSubmit();
+ };
+
+ const handleToggle = async (todoItem: Todo) => {
+ setError('');
+ setActiveTodoId(todoItem.id);
+ const updatedtodo = { ...todoItem, completed: !todoItem.completed };
+
+ try {
+ const updated = await updateTodo(updatedtodo);
+
+ setTodos(currentTodos =>
+ currentTodos.map(t => (t.id === updated.id ? updated : t)),
+ );
+ } catch {
+ showError('Unable to update a todo');
+ } finally {
+ setActiveTodoId(null);
+ }
+ };
+
+ return (
+
+
+ handleToggle(todo)}
+ />
+
+ {isEditing ? (
+
+ ) : (
+ <>
+
+ {title}
+
+
handleDeleteTodo(todo.id)}
+ >
+ ×
+
+ >
+ )}
+
+
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index a9689cb38..8caeb506f 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,11 +1,16 @@
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 { TodoProvider } from './components/TodoContext';
const container = document.getElementById('root') as HTMLDivElement;
-createRoot(container).render( );
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/src/types/Filter.ts b/src/types/Filter.ts
new file mode 100644
index 000000000..174408fd6
--- /dev/null
+++ b/src/types/Filter.ts
@@ -0,0 +1,5 @@
+export enum Filter {
+ All = 'all',
+ Active = 'active',
+ Completed = 'completed',
+}
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;
+}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
new file mode 100644
index 000000000..f9fb74ede
--- /dev/null
+++ b/src/utils/constants.ts
@@ -0,0 +1 @@
+export const USER_ID = 1414;
diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts
new file mode 100644
index 000000000..708ac4c17
--- /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/services.ts b/src/utils/services.ts
new file mode 100644
index 000000000..3d3b84909
--- /dev/null
+++ b/src/utils/services.ts
@@ -0,0 +1,7 @@
+import { RefObject } from 'react';
+
+export const focusInput = (inputRef: RefObject) => {
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+};