diff --git a/package-lock.json b/package-lock.json
index 1f19b4743..a7f06fc95 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.9.12",
+ "@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
@@ -1170,10 +1170,11 @@
}
},
"node_modules/@mate-academy/scripts": {
- "version": "1.9.12",
- "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz",
- "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==",
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz",
+ "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@octokit/rest": "^17.11.2",
"@types/get-port": "^4.2.0",
diff --git a/package.json b/package.json
index 91d7489b9..446974833 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.9.12",
+ "@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..bbc2812ec 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,157 +1,12 @@
-/* eslint-disable jsx-a11y/control-has-associated-label */
import React from 'react';
+import { TodosContextProvider } from './components/TodosProvider';
+import { TodoApp } from './components/TodoApp';
+import './styles/index.scss';
export const App: React.FC = () => {
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
-
-
-
-
+
+
+
);
};
diff --git a/src/api/todos.ts b/src/api/todos.ts
new file mode 100644
index 000000000..e801d6353
--- /dev/null
+++ b/src/api/todos.ts
@@ -0,0 +1,62 @@
+// crud operations with localstorage for todos
+
+import { Todo } from '../components/TodosProvider';
+
+const TODOS_STORAGE_NAME = 'todos';
+
+function saveTodosToStorage(todos: Todo[]) {
+ if (todos.length === 0) {
+ localStorage.setItem(TODOS_STORAGE_NAME, '[]');
+ } else {
+ localStorage.setItem(TODOS_STORAGE_NAME, JSON.stringify(todos));
+ }
+}
+
+export function getTodosFromStorage(): Todo[] {
+ const todosFromStorage = localStorage.getItem(TODOS_STORAGE_NAME);
+
+ if (!todosFromStorage) {
+ localStorage.setItem(TODOS_STORAGE_NAME, '[]');
+ }
+
+ return todosFromStorage ? JSON.parse(todosFromStorage) : [];
+}
+
+export function setTodosInStorage(todos: Todo[]) {
+ saveTodosToStorage(todos);
+}
+
+export function addTodoToStorage(todo: Todo) {
+ const todosFromStorage = getTodosFromStorage();
+
+ todosFromStorage.push(todo);
+
+ saveTodosToStorage(todosFromStorage);
+
+ return todo;
+}
+
+export function deleteTodoFromStorage(todoId: Todo['id']) {
+ const todosFromStorage = getTodosFromStorage();
+
+ const filteredTodosList = todosFromStorage.filter(todo => todo.id !== todoId);
+
+ saveTodosToStorage(filteredTodosList);
+}
+
+export function updateTodoInStorage(
+ todoId: Todo['id'],
+ payload: Partial>,
+) {
+ const todosFromStorage = getTodosFromStorage();
+
+ const updatedTodosList = todosFromStorage.map(todo => {
+ if (todo.id === todoId) {
+ return { ...todo, ...payload };
+ }
+
+ return todo;
+ });
+
+ saveTodosToStorage(updatedTodosList);
+}
diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx
new file mode 100644
index 000000000..c754a0cd8
--- /dev/null
+++ b/src/components/TodoApp.tsx
@@ -0,0 +1,24 @@
+import { useContext } from 'react';
+import { TodoFooter } from './TodoFooter';
+import { TodoHeader } from './TodoHeader';
+import { TodoList } from './TodoList';
+import { TodosContext } from './TodosProvider';
+import '../styles/todoapp.scss';
+
+export const TodoApp = () => {
+ const { todos } = useContext(TodosContext);
+
+ return (
+
+
todos
+
+
+
+
+
+
+ {todos.length !== 0 && }
+
+
+ );
+};
diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx
new file mode 100644
index 000000000..01c4aa823
--- /dev/null
+++ b/src/components/TodoFooter.tsx
@@ -0,0 +1,54 @@
+import { useContext } from 'react';
+import '../styles/filters.scss';
+import { TodoFilterStatus, TodosContext } from './TodosProvider';
+import classNames from 'classnames';
+import { checkSomeTodosCompleted } from '../utils';
+
+export const TodoFooter = () => {
+ const { todos, setTodoFilterStatus, todoFilterStatus, deleteTodo } =
+ useContext(TodosContext);
+
+ function handleClearAllCompletedTodos() {
+ todos.forEach(todo => {
+ if (todo.completed) {
+ deleteTodo(todo.id);
+ }
+ });
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx
new file mode 100644
index 000000000..54e02fa1a
--- /dev/null
+++ b/src/components/TodoHeader.tsx
@@ -0,0 +1,67 @@
+import { useContext, useEffect, useRef, useState } from 'react';
+import { TodosContext } from './TodosProvider';
+import classNames from 'classnames';
+import { checkAllTodosCompleted } from '../utils';
+
+export const TodoHeader: React.FC = () => {
+ const [todoTitle, setTodoTitle] = useState('');
+ const { todos, submitTodo, setTodos } = useContext(TodosContext);
+
+ function handleTodoSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ if (todoTitle.trim() === '') {
+ return;
+ }
+
+ setTodoTitle('');
+ submitTodo({
+ id: +new Date(),
+ title: todoTitle.trim(),
+ completed: false,
+ });
+ }
+
+ function toggleAllTodos() {
+ if (todos.every(todo => todo.completed)) {
+ setTodos(todos.map(todo => ({ ...todo, completed: false })));
+
+ return;
+ }
+
+ setTodos(todos.map(todo => ({ ...todo, completed: true })));
+ }
+
+ const titleInput = useRef(null);
+
+ useEffect(() => {
+ if (titleInput.current) {
+ titleInput.current.focus();
+ }
+ }, [todos]);
+
+ return (
+
+ {todos.length !== 0 && (
+ toggleAllTodos()}
+ data-cy="ToggleAllButton"
+ />
+ )}
+
+
+ );
+};
diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx
new file mode 100644
index 000000000..613d74de4
--- /dev/null
+++ b/src/components/TodoList.tsx
@@ -0,0 +1,118 @@
+/* eslint-disable max-len */
+/* eslint-disable prettier/prettier */
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import { useContext, useState } from 'react';
+import '../styles/todo-list.scss';
+import { Todo, TodoFilterStatus, TodosContext } from './TodosProvider';
+import classNames from 'classnames';
+
+export const TodoList = () => {
+ const { todos, todoFilterStatus, deleteTodo, updateTodo } =
+ useContext(TodosContext);
+ const [selectedTodoId, setSelectedTodoId] = useState(null);
+ const [todoTitleInput, setTodoTitleInput] = useState('');
+
+ const visibleTodos = todos.filter(todo => {
+ switch (todoFilterStatus) {
+ case TodoFilterStatus.All:
+ return true;
+ case TodoFilterStatus.Active:
+ return !todo.completed;
+ case TodoFilterStatus.Completed:
+ return todo.completed;
+ }
+ });
+
+ function handleTitleUpdate(
+ todoId: Todo['id'],
+ event?: React.FormEvent,
+ ) {
+ event?.preventDefault();
+ if (todoTitleInput.trim() === '') {
+ deleteTodo(todoId);
+
+ return;
+ }
+
+ updateTodo(todoId, {
+ title: todoTitleInput.trim(),
+ });
+ setSelectedTodoId(null);
+ }
+
+ function handleTodoSelect(todo: Todo) {
+ setSelectedTodoId(todo.id);
+ setTodoTitleInput(todo.title);
+ }
+
+ function handleEscapeClick(event: React.KeyboardEvent) {
+ if (event.key === 'Escape') {
+ setSelectedTodoId(null);
+ }
+ }
+
+ function handleUnfocusTodoTitle(todoId: Todo['id']) {
+ setSelectedTodoId(null);
+ handleTitleUpdate(todoId);
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/TodosProvider.tsx b/src/components/TodosProvider.tsx
new file mode 100644
index 000000000..b3ce9d685
--- /dev/null
+++ b/src/components/TodosProvider.tsx
@@ -0,0 +1,136 @@
+/* eslint-disable @typescript-eslint/indent */
+import { createContext, useEffect, useReducer, useState } from 'react';
+import {
+ addTodoToStorage,
+ deleteTodoFromStorage,
+ getTodosFromStorage,
+ setTodosInStorage,
+ updateTodoInStorage,
+} from '../api/todos';
+
+export enum TodoFilterStatus {
+ 'All' = 'All',
+ 'Active' = 'Active',
+ 'Completed' = 'Completed',
+}
+
+export type Todo = {
+ id: number;
+ title: string;
+ completed: boolean;
+};
+
+export type TodosContextType = {
+ todos: Todo[];
+ todoFilterStatus: TodoFilterStatus;
+ setTodoFilterStatus: React.Dispatch>;
+ submitTodo: (todo: Todo) => Promise;
+ updateTodo: (
+ todoId: number,
+ payload: Partial>,
+ ) => Promise;
+ deleteTodo: (todoId: number) => Promise;
+ setTodos: (todos: Todo[]) => Promise;
+};
+
+export type Action =
+ | { type: 'add_todo'; payload: Todo }
+ | {
+ type: 'update_todo';
+ todoId: Todo['id'];
+ payload: Partial>;
+ }
+ | { type: 'delete_todo'; todoId: Todo['id'] }
+ | { type: 'set_todos'; payload: Todo[] }
+ | { type: 'toggle_todo'; todoId: Todo['id'] };
+
+type Props = {
+ children: React.ReactNode;
+};
+
+function reducer(state: Todo[], action: Action): Todo[] {
+ switch (action.type) {
+ case 'set_todos':
+ return action.payload;
+ case 'add_todo':
+ return [...state, action.payload];
+ case 'update_todo':
+ return state.map(todo => {
+ if (todo.id === action.todoId) {
+ return { ...todo, ...action.payload };
+ }
+
+ return todo;
+ });
+ case 'delete_todo':
+ return state.filter(todo => todo.id !== action.todoId);
+ default:
+ return state;
+ }
+}
+
+export const TodosContext = createContext({
+ todos: [],
+ todoFilterStatus: TodoFilterStatus.All,
+ setTodoFilterStatus: () => {},
+ deleteTodo: () => new Promise(() => {}),
+ submitTodo: () => new Promise(() => {}),
+ updateTodo: () => new Promise(() => {}),
+ setTodos: () => new Promise(() => {}),
+});
+
+export const TodosContextProvider: React.FC = ({ children }) => {
+ const [state, dispatch] = useReducer(reducer, []);
+ const [todoFilterStatus, setTodoFilterStatus] = useState(
+ TodoFilterStatus.All,
+ );
+
+ async function deleteTodo(todoId: Todo['id']) {
+ deleteTodoFromStorage(todoId);
+
+ dispatch({ type: 'delete_todo', todoId });
+ }
+
+ async function submitTodo(todo: Todo) {
+ addTodoToStorage(todo);
+
+ dispatch({ type: 'add_todo', payload: todo });
+ }
+
+ async function updateTodo(
+ todoId: Todo['id'],
+ payload: Partial>,
+ ) {
+ updateTodoInStorage(todoId, payload);
+
+ dispatch({ type: 'update_todo', todoId, payload });
+ }
+
+ async function setTodos(todos: Todo[]) {
+ setTodosInStorage(todos);
+
+ dispatch({ type: 'set_todos', payload: todos });
+ }
+
+ useEffect(() => {
+ const todos = getTodosFromStorage();
+
+ dispatch({ type: 'set_todos', payload: todos });
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss
index e289a9458..881ca6a43 100644
--- a/src/styles/todoapp.scss
+++ b/src/styles/todoapp.scss
@@ -13,6 +13,10 @@
0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
+ * {
+ box-sizing: border-box;
+ }
+
&__title {
font-size: 100px;
font-weight: 100;
@@ -48,6 +52,10 @@
color: #737373;
}
+ &.hidden {
+ display: none;
+ }
+
&::before {
content: '❯';
transform: translateY(2px) rotate(90deg);
@@ -68,6 +76,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/utils.ts b/src/utils.ts
new file mode 100644
index 000000000..f0f780379
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,17 @@
+import { Todo } from './components/TodosProvider';
+
+export function checkAllTodosCompleted(todos: Todo[]) {
+ if (todos.length === 0) {
+ return false;
+ }
+
+ return todos.every(todo => todo.completed);
+}
+
+export function checkSomeTodosCompleted(todos: Todo[]) {
+ if (todos.length === 0) {
+ return false;
+ }
+
+ return todos.some(todo => todo.completed);
+}