From 5019abd26bb276db8afb73911ba6d257eac4d137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssia?= Date: Wed, 3 Jun 2026 03:39:54 -0300 Subject: [PATCH 1/2] Solution --- README.md | 2 +- package-lock.json | 9 +- package.json | 2 +- src/App.tsx | 163 +++------------------------ src/components/Filter/Filter.tsx | 49 ++++++++ src/components/Filter/index.ts | 1 + src/components/Footer/Footer.tsx | 35 ++++++ src/components/Footer/index.ts | 1 + src/components/Header/Header.tsx | 29 +++++ src/components/Header/index.ts | 1 + src/components/NewTodo/NewTodo.tsx | 51 +++++++++ src/components/NewTodo/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 86 ++++++++++++++ src/components/TodoItem/index.ts | 1 + src/components/TodoList/TodoList.tsx | 26 +++++ src/components/TodoList/index.ts | 1 + src/context/TodoContext.tsx | 122 ++++++++++++++++++++ tsconfig.json | 8 +- 18 files changed, 431 insertions(+), 157 deletions(-) create mode 100644 src/components/Filter/Filter.tsx create mode 100644 src/components/Filter/index.ts create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.ts create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.ts create mode 100644 src/components/NewTodo/NewTodo.tsx create mode 100644 src/components/NewTodo/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 diff --git a/README.md b/README.md index 903c876f9..b67946aff 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,4 @@ Implement a simple [TODO app](https://mate-academy.github.io/react_todo-app/) th - Implement a solution following the [React task guidelines](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). - Open another terminal and run tests with `npm test` to ensure your solution is correct. -- Replace `` with your GitHub username in the [DEMO LINK](https://.github.io/react_todo-app/) and add it to the PR description. +- Replace `` with your GitHub username in the [DEMO LINK](https://cassiaqueiroz.github.io/react_todo-app/) and add it to the PR description. 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..ef24d9897 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,22 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { TodoProvider } from './context/TodoContext'; 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 */} - -
+ +
+

todos

+ +
+
+ +
+
-
+ ); }; diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx new file mode 100644 index 000000000..5a93dfb03 --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { FilterType, useTodo } from '../../context/TodoContext'; + +export const Filter: React.FC = () => { + const { filter, setFilter } = useTodo(); + const onClick = (newFilter: FilterType) => { + setFilter(newFilter); + }; + + return ( + + ); +}; diff --git a/src/components/Filter/index.ts b/src/components/Filter/index.ts new file mode 100644 index 000000000..0eea77907 --- /dev/null +++ b/src/components/Filter/index.ts @@ -0,0 +1 @@ +export * from './Filter'; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..63a51faff --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Filter } from '../Filter'; +import { useTodo } from '../../context/TodoContext'; + +export const Footer: React.FC = () => { + const { todos, clearCompleted } = useTodo(); + + if (todos.length === 0) { + return null; + } + + const onClick = () => { + clearCompleted(); + }; + + return ( +
+ + {todos.filter(todo => !todo.completed).length} items left + + + + + +
+ ); +}; 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..1048299d2 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,29 @@ +import { useTodo } from '../../context/TodoContext'; +import { NewTodo } from '../NewTodo'; + +export const Header: React.FC = () => { + const { todos, toggleAll } = useTodo(); + + const onClick = () => { + toggleAll(); + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 000000000..64f7c8742 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; \ No newline at end of file diff --git a/src/components/NewTodo/NewTodo.tsx b/src/components/NewTodo/NewTodo.tsx new file mode 100644 index 000000000..8ba0380cf --- /dev/null +++ b/src/components/NewTodo/NewTodo.tsx @@ -0,0 +1,51 @@ +import React, { useContext, useState } from 'react'; +import { TodoContext } from '../../context/TodoContext'; + +export const NewTodo: React.FC = () => { + const todoContext = useContext(TodoContext); + + if (!todoContext) { + throw new Error('TodoContext must be used within a TodoProvider'); + } + + const { addTodo } = todoContext; + const [newTodoTitle, setNewTodoTitle] = useState(''); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (newTodoTitle.trim()) { + addTodo(newTodoTitle.trim()); + setNewTodoTitle(''); + } + }; + + const inputRef = React.useRef(null); + const { shouldFocusInput, setShouldFocusInput } = todoContext; + + React.useEffect(() => { + inputRef.current?.focus(); + }, []); + + React.useEffect(() => { + if (shouldFocusInput) { + inputRef.current?.focus(); + setShouldFocusInput(false); + } + }, [shouldFocusInput, setShouldFocusInput]); + + return ( +
+ setNewTodoTitle(event.target.value)} + /> +
+ ); +}; diff --git a/src/components/NewTodo/index.ts b/src/components/NewTodo/index.ts new file mode 100644 index 000000000..245c377cc --- /dev/null +++ b/src/components/NewTodo/index.ts @@ -0,0 +1 @@ +export * from './NewTodo'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..a6de2d335 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,86 @@ +import React, { useState, useEffect } from 'react'; +import { Todo, useTodo } from '../../context/TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { toggleTodo, deleteTodo, updateTodoTitle } = useTodo(); + const [isEditing, setIsEditing] = useState(false); + const [newTodoTitle, setNewTodoTitle] = useState(todo.title); + + useEffect(() => { + setNewTodoTitle(todo.title); + }, [todo.title]); + const handleSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + + if (newTodoTitle.trim()) { + updateTodoTitle(todo.id, newTodoTitle.trim()); + setIsEditing(false); + } else { + deleteTodo(todo.id); + } + }; + + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + } + }, [isEditing]); + + return ( +
+ + + {isEditing ? ( +
+ setNewTodoTitle(event.target.value)} + onBlur={handleSubmit} + onKeyUp={event => event.key === 'Escape' && setIsEditing(false)} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 000000000..d1a41bf4a --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; \ No newline at end of file diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..035aed599 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useTodo } from '../../context/TodoContext'; +import { TodoItem } from '../TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, filter } = useTodo(); + + const filteredTodos = todos.filter(todo => { + switch (filter) { + case 'active': + return !todo.completed; + case 'completed': + return todo.completed; + default: + return true; + } + }); + + return ( +
+ {filteredTodos.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..eae1537f9 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,122 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +export type Todo = { + id: number; + title: string; + completed: boolean; +}; + +export type FilterType = 'all' | 'active' | 'completed'; + +export type TodoContextType = { + todos: Todo[]; + filter: FilterType; + addTodo: (title: string) => void; + deleteTodo: (id: number, onDelete?: () => void) => void; + toggleTodo: (id: number) => void; + toggleAll: () => void; + updateTodoTitle: (id: number, title: string) => void; + clearCompleted: () => void; + setFilter: (filter: FilterType) => void; + shouldFocusInput: boolean; + setShouldFocusInput: (shouldFocus: boolean) => void; +}; + +export const TodoContext = createContext( + undefined, +); + +export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [todos, setTodos] = useState(() => { + // busca do localStorage + try { + const saved = localStorage.getItem('todos'); + + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + + const [filter, setFilter] = useState('all'); + // salva no localStorage quando muda + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const addTodo = (title: string) => { + const newTodo: Todo = { + id: +new Date(), + title, + completed: false, + }; + + setTodos([...todos, newTodo]); + }; + + const [shouldFocusInput, setShouldFocusInput] = useState(false); + + const deleteTodo = (id: number, onDelete?: () => void) => { + setTodos(todos.filter(todo => todo.id !== id)); + if (onDelete) { + onDelete(); + } + + setShouldFocusInput(true); + }; + + const toggleTodo = (id: number) => { + setTodos( + todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const toggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + + setTodos(todos.map(todo => ({ ...todo, completed: !allCompleted }))); + }; + + const updateTodoTitle = (id: number, title: string) => { + setTodos(todos.map(todo => (todo.id === id ? { ...todo, title } : todo))); + }; + + const clearCompleted = () => { + setTodos(todos.filter(todo => !todo.completed)); + setShouldFocusInput(true); + }; + + return ( + + {children} + + ); +}; + +export const useTodo = () => { + const context = useContext(TodoContext); + + if (!context) { + throw new Error('useTodo must be used within a TodoProvider'); + } + + return context; +}; diff --git a/tsconfig.json b/tsconfig.json index cfb168bb2..c592c63ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,11 @@ "src" ], "compilerOptions": { + "jsx": "react-jsx", "sourceMap": false, - "types": ["node", "cypress"] + "types": [ + "node", + "cypress" + ] } -} +} \ No newline at end of file From d241d2f7a6dbb8a785c0d5e2ed3375d955d79721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssia?= Date: Wed, 3 Jun 2026 17:20:15 -0300 Subject: [PATCH 2/2] Corrections --- package-lock.json | 14 +++++++++++++- package.json | 1 + src/components/Filter/Filter.tsx | 22 ++++++++++++---------- src/components/Footer/Footer.tsx | 2 +- src/components/Header/Header.tsx | 11 ++++++----- src/components/Header/index.ts | 2 +- src/components/NewTodo/NewTodo.tsx | 18 +++++------------- src/components/TodoItem/TodoItem.tsx | 3 ++- src/components/TodoItem/index.ts | 2 +- src/components/TodoList/TodoList.tsx | 5 +++-- src/context/TodoContext.tsx | 17 +++++++++-------- src/utils/filters.ts | 5 +++++ 12 files changed, 59 insertions(+), 43 deletions(-) create mode 100644 src/utils/filters.ts diff --git a/package-lock.json b/package-lock.json index a7f06fc95..bad30ef45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", + "@types/classnames": "^2.3.0", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -2146,6 +2147,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/classnames": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.0.tgz", + "integrity": "sha512-3GsbOoDYteFShlrBTKzI2Eii4vPg/jAf7LXRIn0WQePKlmhpkV0KoTMuawA7gZJkrbPrZGwv9IEAfIWaOaQK8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "classnames": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -3235,7 +3246,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/package.json b/package.json index 446974833..8167a8600 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", + "@types/classnames": "^2.3.0", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx index 5a93dfb03..3136df68b 100644 --- a/src/components/Filter/Filter.tsx +++ b/src/components/Filter/Filter.tsx @@ -1,21 +1,21 @@ import React from 'react'; -import { FilterType, useTodo } from '../../context/TodoContext'; +import cn from 'classnames'; +import { FILTERS } from '../../utils/filters'; + +import { useTodo } from '../../context/TodoContext'; export const Filter: React.FC = () => { const { filter, setFilter } = useTodo(); - const onClick = (newFilter: FilterType) => { - setFilter(newFilter); - }; return (