From 42809fa3ee20a4a6f97e50f4b2d8326885427fca Mon Sep 17 00:00:00 2001 From: ericagomes-dev Date: Sat, 4 Jul 2026 16:47:29 -0300 Subject: [PATCH 1/2] Implement Todo App features --- .hintrc | 9 ++ package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 155 +---------------------- src/components/Footer/Footer.tsx | 94 ++++++++++++++ src/components/Footer/index.ts | 1 + src/components/TodoApp/TodoApp.tsx | 24 ++++ src/components/TodoApp/index.ts | 1 + src/components/TodoHeader/TodoHeader.tsx | 77 +++++++++++ src/components/TodoHeader/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 143 +++++++++++++++++++++ src/components/TodoItem/index.ts | 1 + src/components/TodoList/TodoList.tsx | 35 +++++ src/components/TodoList/index.ts | 1 + src/context/TodoContext.tsx | 17 +++ src/context/TodoProvider.tsx | 35 +++++ src/index.tsx | 7 +- src/types/Filter.ts | 1 + src/types/Todo.ts | 5 + 19 files changed, 459 insertions(+), 159 deletions(-) create mode 100644 .hintrc create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.ts create mode 100644 src/components/TodoApp/TodoApp.tsx create mode 100644 src/components/TodoApp/index.ts create mode 100644 src/components/TodoHeader/TodoHeader.tsx create mode 100644 src/components/TodoHeader/index.ts create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/context/TodoContext.tsx create mode 100644 src/context/TodoProvider.tsx create mode 100644 src/types/Filter.ts create mode 100644 src/types/Todo.ts diff --git a/.hintrc b/.hintrc new file mode 100644 index 000000000..d72048f55 --- /dev/null +++ b/.hintrc @@ -0,0 +1,9 @@ +{ + "extends": [ + "development" + ], + "hints": { + "axe/name-role-value": "off", + "axe/forms": "off" + } +} \ No newline at end of file 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..125dba6d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,6 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { TodoApp } from './components/TodoApp'; export const App: React.FC = () => { - 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 */} - -
-
-
- ); + return ; }; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..13289625b --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,94 @@ +/* eslint-disable prettier/prettier */ +import React, { useContext } from 'react'; +import { TodoContext } from '../../context/TodoContext'; + +export const Footer: React.FC = () => { + const { + todos, + setTodos, + filter, + setFilter, + } = useContext(TodoContext); + + const activeTodos = todos.filter(todo => !todo.completed).length; + + const handleClearCompleted = () => { + setTodos(todos.filter(todo => !todo.completed)); + + setTimeout(() => { + ( + document.querySelector( + '[data-cy="NewTodoField"]', + ) as HTMLInputElement | null + )?.focus(); + }, 0); + }; + + 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/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx new file mode 100644 index 000000000..c09c7e52c --- /dev/null +++ b/src/components/TodoApp/TodoApp.tsx @@ -0,0 +1,24 @@ +/* eslint-disable prettier/prettier */ +import React, { useContext } from 'react'; +import { TodoContext } from '../../context/TodoContext'; +import { TodoHeader } from '../TodoHeader'; +import { TodoList } from '../TodoList'; +import { Footer } from '../Footer'; + +export const TodoApp: React.FC = () => { + const { todos } = useContext(TodoContext); + + return ( +
+

todos

+ +
+ + + {todos.length > 0 && } + + {todos.length > 0 &&
} +
+
+ ); +}; diff --git a/src/components/TodoApp/index.ts b/src/components/TodoApp/index.ts new file mode 100644 index 000000000..4ff3f14ce --- /dev/null +++ b/src/components/TodoApp/index.ts @@ -0,0 +1 @@ +export * from './TodoApp'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..ecd3bc377 --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,77 @@ +/* eslint-disable prettier/prettier */ +import React, { useContext, useRef, useState } from 'react'; +import { TodoContext } from '../../context/TodoContext'; + +export const TodoHeader: React.FC = () => { + const { todos, setTodos } = useContext(TodoContext); + + const [title, setTitle] = useState(''); + const inputRef = useRef(null); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + inputRef.current?.focus(); + + return; + } + + setTodos([ + ...todos, + { + id: +new Date(), + title: trimmedTitle, + completed: false, + }, + ]); + + setTitle(''); + inputRef.current?.focus(); + }; + + const handleToggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + + setTodos( + todos.map(todo => ({ + ...todo, + completed: !allCompleted, + })), + ); + + inputRef.current?.focus(); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoHeader/index.ts b/src/components/TodoHeader/index.ts new file mode 100644 index 000000000..c4db4bc40 --- /dev/null +++ b/src/components/TodoHeader/index.ts @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..a35cc406b --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,143 @@ +/* eslint-disable prettier/prettier */ +/* eslint-disable @typescript-eslint/indent */ +/* eslint-disable jsx-a11y/label-has-associated-control */ + +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { TodoContext } from '../../context/TodoContext'; +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { todos, setTodos } = useContext(TodoContext); + + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(todo.title); + + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [isEditing]); + + const focusNewTodoField = () => { + ( + document.querySelector( + '[data-cy="NewTodoField"]', + ) as HTMLInputElement | null + )?.focus(); + }; + + const handleDelete = () => { + setTodos(todos.filter(item => item.id !== todo.id)); + + setTimeout(() => { + focusNewTodoField(); + }); + }; + + const handleToggle = () => { + setTodos( + todos.map(item => + item.id === todo.id + ? { + ...item, + completed: !item.completed, + } + : item, + ), + ); + }; + + const handleSave = () => { + const trimmedTitle = editedTitle.trim(); + + if (!trimmedTitle) { + handleDelete(); + + return; + } + + setTodos( + todos.map(item => + item.id === todo.id + ? { + ...item, + title: trimmedTitle, + } + : item, + ), + ); + + setIsEditing(false); + }; + + const handleKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (event.key === 'Enter') { + handleSave(); + } + + if (event.key === 'Escape') { + setEditedTitle(todo.title); + setIsEditing(false); + } + }; + + return ( +
+ + + {isEditing ? ( + setEditedTitle(event.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + /> + ) : ( + setIsEditing(true)} + > + {todo.title} + + )} + + {!isEditing && ( + + )} +
+ ); +}; 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..df4c0c258 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,35 @@ +/* eslint-disable prettier/prettier */ +import React, { useContext } from 'react'; +import { TodoContext } from '../../context/TodoContext'; +import { TodoItem } from '../TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, filter } = useContext(TodoContext); + + const visibleTodos = todos.filter(todo => { + switch (filter) { + case 'Active': + return !todo.completed; + + case 'Completed': + return todo.completed; + + default: + return true; + } + }); + + return ( +
+ {visibleTodos.map(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/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..179faf281 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,17 @@ +import React, { createContext } from 'react'; +import { Filter } from '../types/Filter'; +import { Todo } from '../types/Todo'; + +type TodoContextType = { + todos: Todo[]; + setTodos: React.Dispatch>; + filter: Filter; + setFilter: React.Dispatch>; +}; + +export const TodoContext = createContext({ + todos: [], + setTodos: () => {}, + filter: 'All', + setFilter: () => {}, +}); diff --git a/src/context/TodoProvider.tsx b/src/context/TodoProvider.tsx new file mode 100644 index 000000000..1c2e3a885 --- /dev/null +++ b/src/context/TodoProvider.tsx @@ -0,0 +1,35 @@ +import React, { useEffect, useState } from 'react'; +import { Filter } from '../types/Filter'; +import { Todo } from '../types/Todo'; +import { TodoContext } from './TodoContext'; + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState(() => { + const savedTodos = localStorage.getItem('todos'); + + return savedTodos ? JSON.parse(savedTodos) : []; + }); + + const [filter, setFilter] = useState('All'); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..d882914c8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,12 @@ import { createRoot } from 'react-dom/client'; import './styles/index.scss'; import { App } from './App'; +import { TodoProvider } from './context/TodoProvider'; 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..5b7f84718 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1 @@ +export type Filter = 'All' | 'Active' | '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; +} From 8f7f4822a2bc695448fb2f166f64d55b51cb703a Mon Sep 17 00:00:00 2001 From: ericagomes-dev Date: Sun, 5 Jul 2026 10:16:49 -0300 Subject: [PATCH 2/2] Implement todo app with filters --- package-lock.json | 3 +- src/components/Footer/Footer.tsx | 54 ++++++++---------------- src/components/TodoHeader/TodoHeader.tsx | 9 ++-- src/components/TodoItem/TodoItem.tsx | 22 +++++----- src/components/TodoList/TodoList.tsx | 15 +++---- src/context/TodoContext.tsx | 4 +- src/context/TodoProvider.tsx | 4 +- src/types/Filter.ts | 8 +++- 8 files changed, 52 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index a7f06fc95..00c8a82f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3235,7 +3235,8 @@ "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" }, "node_modules/clean-stack": { "version": "2.2.0", diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index 13289625b..356d9df71 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -1,14 +1,11 @@ /* eslint-disable prettier/prettier */ import React, { useContext } from 'react'; +import classNames from 'classnames'; import { TodoContext } from '../../context/TodoContext'; +import { FILTERS } from '../../types/Filter'; export const Footer: React.FC = () => { - const { - todos, - setTodos, - filter, - setFilter, - } = useContext(TodoContext); + const { todos, setTodos, filter, setFilter } = useContext(TodoContext); const activeTodos = todos.filter(todo => !todo.completed).length; @@ -25,30 +22,19 @@ export const Footer: React.FC = () => { }; return ( -