diff --git a/README.md b/README.md index 903c876f9..36ae67fa2 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://shtoikoihor.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..e207af351 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,9 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; - -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 */} - -
-
-
- ); -}; +import { TodoProvider } from './context/TodoContext'; +import { TodoApp } from './components/TodoApp'; + +export const App = () => ( + + + +); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..d1363cab5 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useTodos } from '../context/TodoContext'; +import { FilterType } from '../types/Todo'; + +export const Footer: React.FC = () => { + const { todos, filter, setFilter, clearCompleted } = useTodos(); + + return ( + + ); +}; diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..0d6e9bb49 --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,33 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { useTodos } from '../context/TodoContext'; +import { TodoList } from './TodoList'; +import { Footer } from './Footer'; +import { TodoForm } from './TodoForm'; + +export const TodoApp: React.FC = () => { + const { todos, toggleAll } = useTodos(); + + return ( +
+

Todo list

+ +
+
+ {!!todos.length && ( +
+ + {!!todos.length && } + {!!todos.length &&
} +
+
+ ); +}; diff --git a/src/components/TodoForm.tsx b/src/components/TodoForm.tsx new file mode 100644 index 000000000..4b645fef9 --- /dev/null +++ b/src/components/TodoForm.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useState } from 'react'; +import { useTodos } from '../context/TodoContext'; + +export const TodoForm: React.FC = () => { + const [value, setValue] = useState(''); + const { addTodo, inputRef } = useTodos(); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!value.trim()) { + return; + } + + addTodo(value.trim()); + setValue(''); + inputRef.current?.focus(); + } + + return ( +
+ setValue(e.target.value)} + ref={inputRef} + /> +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..2a7c262f7 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,95 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import { useTodos } from '../context/TodoContext'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem = ({ todo }: Props) => { + const { deleteTodo, toggleTodo, renameTodo } = useTodos(); + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(todo.title); + + //#region functions + function saveEdit(e?: React.FormEvent) { + e?.preventDefault(); + if (editValue.trim()) { + renameTodo(editValue.trim(), todo.id); + } else { + deleteTodo(todo.id); + } + + setIsEditing(false); + } + + function handleKeyUp(event: React.KeyboardEvent) { + if (event.key === 'Escape') { + setEditValue(todo.title); + setIsEditing(false); + } + } + + //#endregion + + //#region hooks + + const editRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editRef.current?.focus(); + } + }, [isEditing]); + + //#endregion + + return ( +
+ + + {isEditing ? ( +
+ setEditValue(e.target.value)} + onBlur={saveEdit} + onKeyUp={handleKeyUp} + ref={editRef} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..4d4053051 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useTodos } from '../context/TodoContext'; +import { FilterType } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, filter } = useTodos(); + + function getFilteredTodos() { + switch (filter) { + case FilterType.All: + return todos; + case FilterType.Active: + return todos.filter(todo => !todo.completed); + case FilterType.Completed: + return todos.filter(todo => todo.completed); + } + } + + const filteredTodos = getFilteredTodos(); + + return ( +
+ {filteredTodos?.map(todo => )} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..f4264cfce --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,113 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { FilterType, Todo } from '../types/Todo'; + +type TodoContextType = { + todos: Todo[]; + filter: FilterType; + addTodo: (value: string) => void; + deleteTodo: (id: number) => void; + toggleTodo: (id: number) => void; + renameTodo: (value: string, id: number) => void; + clearCompleted: () => void; + toggleAll: (completed: boolean) => void; + setFilter: (filter: FilterType) => void; + inputRef: React.RefObject; +}; + +const TodoContext = createContext(null); + +//#region hooks +export function useTodos() { + const context = useContext(TodoContext); + + if (!context) { + throw new Error('useTodos must be used within a TodoProvider'); + } + + return context; +} +//#endregion + +export const TodoProvider = ({ children }: { children: React.ReactNode }) => { + const [todos, setTodos] = useState(() => { + const saved = localStorage.getItem('todos'); + + return saved ? JSON.parse(saved) : []; + }); + const [filter, setFilter] = useState(FilterType.All); + const inputRef = useRef(null); + + //#region function + function addTodo(title: string) { + const newTodo: Todo = { + id: Date.now(), + title: title, + completed: false, + }; + + setTodos([...todos, newTodo]); + } + + function deleteTodo(id: number) { + const deletedTodos = todos.filter(todo => todo.id !== id); + + setTodos(deletedTodos); + setTimeout(() => inputRef.current?.focus(), 0); + } + + function toggleTodo(id: number) { + setTodos( + todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + } + + function renameTodo(value: string, id: number) { + setTodos( + todos.map(todo => (todo.id === id ? { ...todo, title: value } : todo)), + ); + } + + function clearCompleted() { + setTodos(todos.filter(todo => !todo.completed)); + setTimeout(() => inputRef.current?.focus(), 0); + } + + function toggleAll(completed: boolean) { + setTodos(todos.map(todo => ({ ...todo, completed: completed }))); + } + //#endregion + + //#region useEffects + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + //#endregion + + return ( + + {children} + + ); +}; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..89fb06e0f 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -70,6 +70,7 @@ border: none; background: rgba(0, 0, 0, 0.01); box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); + box-sizing: border-box; &::placeholder { font-style: italic; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..00544c672 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,11 @@ +export type Todo = { + id: number; + title: string; + completed: boolean; +}; + +export enum FilterType { + All = 'All', + Completed = 'Completed', + Active = 'Active', +}