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..bad30ef45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,10 @@ }, "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/classnames": "^2.3.0", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -1170,10 +1171,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", @@ -2145,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", @@ -3234,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 91d7489b9..8167a8600 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ }, "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/classnames": "^2.3.0", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", 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..3136df68b --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import cn from 'classnames'; +import { FILTERS } from '../../utils/filters'; + +import { useTodo } from '../../context/TodoContext'; + +export const Filter: React.FC = () => { + const { filter, setFilter } = useTodo(); + + 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..c17663df8 --- /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..66d2884d4 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,30 @@ +import cn from 'classnames'; +import { useTodo } from '../../context/TodoContext'; +import { NewTodo } from '../NewTodo'; + +export const Header: React.FC = () => { + const { todos, toggleAll } = useTodo(); + + const handleToggleAll = () => { + 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..266dec8a1 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/NewTodo/NewTodo.tsx b/src/components/NewTodo/NewTodo.tsx new file mode 100644 index 000000000..3dde1c8f4 --- /dev/null +++ b/src/components/NewTodo/NewTodo.tsx @@ -0,0 +1,43 @@ +import React, { useState } from 'react'; +import { useTodo } from '../../context/TodoContext'; + +export const NewTodo: React.FC = () => { + const { addTodo, shouldFocusInput, setShouldFocusInput } = useTodo(); + const [newTodoTitle, setNewTodoTitle] = useState(''); + + const inputRef = React.useRef(null); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (newTodoTitle.trim()) { + addTodo(newTodoTitle.trim()); + setNewTodoTitle(''); + } + }; + + 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..90483c544 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,87 @@ +import cn from 'classnames'; +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..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..739984ccc --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTodo } from '../../context/TodoContext'; +import { TodoItem } from '../TodoItem'; +import { FILTERS } from '../../utils/filters'; + +export const TodoList: React.FC = () => { + const { todos, filter } = useTodo(); + + const filteredTodos = todos.filter(todo => { + switch (filter) { + case FILTERS.active: + return !todo.completed; + case FILTERS.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..b9c9250ee --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,123 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { FILTERS } from '../utils/filters'; + +export type FilterType = (typeof FILTERS)[keyof typeof FILTERS]; + +let nextId = 1; + +export type Todo = { + id: number; + title: string; + completed: boolean; +}; + +export type TodoContextType = { + todos: Todo[]; + filter: FilterType; + addTodo: (title: string) => void; + deleteTodo: (id: number) => 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: nextId++, + title, + completed: false, + }; + + setTodos([...todos, newTodo]); + }; + + const [shouldFocusInput, setShouldFocusInput] = useState(false); + + const deleteTodo = (id: number) => { + setTodos(todos.filter(todo => todo.id !== id)); + + 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/src/utils/filters.ts b/src/utils/filters.ts new file mode 100644 index 000000000..be85b3560 --- /dev/null +++ b/src/utils/filters.ts @@ -0,0 +1,5 @@ +export const FILTERS = { + all: 'all', + active: 'active', + completed: 'completed', +} as const; 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