From 3355fef8fe7ee57e460dfe96fd1e742a42ec6391 Mon Sep 17 00:00:00 2001 From: Tanya Baletska Date: Tue, 12 Nov 2024 18:35:04 +0200 Subject: [PATCH] add task solution --- README.md | 2 +- package-lock.json | 12 +-- package.json | 2 +- src/App.tsx | 163 ++---------------------------------- src/FocusContext.tsx | 54 ++++++++++++ src/TodosContext.tsx | 93 ++++++++++++++++++++ src/components/Footer.tsx | 57 +++++++++++++ src/components/Header.tsx | 51 +++++++++++ src/components/TodoApp.tsx | 53 ++++++++++++ src/components/TodoItem.tsx | 94 +++++++++++++++++++++ src/components/TodoList.tsx | 15 ++++ src/index.tsx | 7 +- src/types/Todo.ts | 6 ++ 13 files changed, 444 insertions(+), 165 deletions(-) create mode 100644 src/FocusContext.tsx create mode 100644 src/TodosContext.tsx create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/TodoApp.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/types/Todo.ts diff --git a/README.md b/README.md index 903c876f9..aa71765fd 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://tonni004.github.io/react_todo-app/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index 0adcc869f..a5b49c60a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@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.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -3234,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/package.json b/package.json index e6134ce84..91d7489b9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@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..f25ec0ce3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,10 @@ /* 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 { TodosProvider } from './TodosContext'; +import { TodoApp } from './components/TodoApp'; + +export const App: React.FC = () => ( + + + +); diff --git a/src/FocusContext.tsx b/src/FocusContext.tsx new file mode 100644 index 000000000..91e5a3bb7 --- /dev/null +++ b/src/FocusContext.tsx @@ -0,0 +1,54 @@ +import React, { + createContext, + useContext, + useRef, + useCallback, + useEffect, +} from 'react'; +import { useTodos } from './TodosContext'; + +interface FocusContextType { + focusInput: () => void; + inputRef: React.RefObject; +} + +const FocusContext = createContext(undefined); + +export const FocusProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const inputRef = useRef(null); + const { todos } = useTodos(); + + const focusInput = useCallback(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + useEffect(() => { + if (todos.length) { + const input = document.querySelector( + '[data-cy="NewTodoField"]', + ) as HTMLInputElement; + + input?.focus(); + } + }, [todos.length]); + + return ( + + {children} + + ); +}; + +export const useFocus = () => { + const context = useContext(FocusContext); + + if (!context) { + throw new Error('useFocus must be used within a FocusProvider'); + } + + return context; +}; diff --git a/src/TodosContext.tsx b/src/TodosContext.tsx new file mode 100644 index 000000000..aa7d168e3 --- /dev/null +++ b/src/TodosContext.tsx @@ -0,0 +1,93 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +interface Todo { + id: number; + title: string; + completed: boolean; +} + +interface TodosContextProps { + todos: Todo[]; + addTodo: (title: string) => void; + toggleTodo: (id: number) => void; + deleteTodo: (id: number) => void; + clearCompleted: () => void; + updateTodo: (id: number, title: string) => void; + toggleAllTodos: () => void; +} + +const TodosContext = createContext(undefined); + +export const TodosProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [todos, setTodos] = useState(() => { + const savedTodos = localStorage.getItem('todos'); + + return savedTodos ? JSON.parse(savedTodos) : []; + }); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const addTodo = (title: string) => { + setTodos([...todos, { id: +new Date(), title, completed: false }]); + }; + + const toggleTodo = (id: number) => { + setTodos( + todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const toggleAllTodos = () => { + const allCompleted = todos.every(todo => todo.completed); + + setTodos(todos.map(todo => ({ ...todo, completed: !allCompleted }))); + }; + + const deleteTodo = (id: number) => { + setTodos(todos.filter(todo => todo.id !== id)); + }; + + const clearCompleted = () => { + setTodos(todos.filter(todo => !todo.completed)); + }; + + const updateTodo = (id: number, title: string) => { + setTodos( + todos.map(todo => + todo.id === id ? { ...todo, title: title.trim() || 'Untitled' } : todo, + ), + ); + }; + + return ( + + {children} + + ); +}; + +export const useTodos = () => { + const context = useContext(TodosContext); + + if (!context) { + throw new Error('useTodos must be used within a TodosProvider'); + } + + return context; +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..4c7380f90 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Filter } from './TodoApp'; +import classNames from 'classnames'; + +interface FooterProps { + activeCount: number; + hasCompletedTodos: boolean; + currFilter: Filter; + onFilterChange: (filter: Filter) => void; + onClearCompleted: () => void; + onToggleAll: () => void; +} + +export const Footer: React.FC = ({ + activeCount, + hasCompletedTodos, + currFilter, + onFilterChange, + onClearCompleted, +}) => ( +
+ + {activeCount} items left + + + + + +
+); diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..620ef4da7 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,51 @@ +// Header.tsx +import React, { useEffect } from 'react'; +import { useFocus } from '../FocusContext'; +import { useTodos } from '../TodosContext'; +import classNames from 'classnames'; + +interface HeaderProps { + hasTodos: boolean; +} + +export const Header: React.FC = ({ hasTodos }) => { + const { todos, addTodo, toggleAllTodos } = useTodos(); + const { focusInput, inputRef } = useFocus(); + + useEffect(() => { + focusInput(); + }, [focusInput]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (inputRef.current && inputRef.current.value.trim()) { + addTodo(inputRef.current.value.trim()); + inputRef.current.value = ''; + focusInput(); + } + }; + + return ( +
+ {hasTodos && ( +
+ ); +}; diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..b3cee8c46 --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,53 @@ +import React, { useState, useMemo } from 'react'; +import { Header } from './Header'; +import { TodoList } from './TodoList'; +import { Footer } from './Footer'; +import { useTodos } from '../TodosContext'; +import { FocusProvider } from '../FocusContext'; + +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} + +export const TodoApp: React.FC = () => { + const { todos, toggleAllTodos, clearCompleted } = useTodos(); + const [filter, setFilter] = useState(Filter.All); + + const filteredTodos = useMemo(() => { + switch (filter) { + case Filter.Active: + return todos.filter(todo => !todo.completed); + case Filter.Completed: + return todos.filter(todo => todo.completed); + default: + return todos; + } + }, [todos, filter]); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const hasCompletedTodos = todos.some(todo => todo.completed); + + return ( + +
+

todos

+
+ {todos.length > 0 && ( + <> + +
+ + )} +
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..414230573 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,94 @@ +import React, { useState, useEffect } from 'react'; +import { useTodos } from '../TodosContext'; +import { Todo } from '../types/Todo'; +import { useFocus } from '../FocusContext'; + +interface TodoItemProps { + todo: Todo; +} + +export const TodoItem: React.FC = ({ todo }) => { + const { toggleTodo, deleteTodo, updateTodo } = useTodos(); + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(todo.title); + const { focusInput } = useFocus(); + + const handleEdit = () => setIsEditing(true); + const handleBlur = () => { + if (editText.trim()) { + updateTodo(todo.id, editText.trim()); + } else { + deleteTodo(todo.id); + setTimeout(() => focusInput(), 0); + } + + setIsEditing(false); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleBlur(); + focusInput(); + } + + if (e.key === 'Escape') { + setIsEditing(false); + setEditText(todo.title); + } + }; + + useEffect(() => { + if (!isEditing) { + focusInput(); + } + }, [isEditing, focusInput]); + + return ( +
  • + + {!isEditing ? ( + <> + + {todo.title} + + + + ) : ( + setEditText(e.target.value)} + onKeyUp={handleKeyUp} + autoFocus + /> + )} +
  • + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..c6c7138a8 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { TodoItem } from './TodoItem'; +import { Todo } from '../types/Todo'; + +interface TodoListProps { + todos: Todo[]; +} + +export const TodoList: React.FC = ({ todos }) => ( +
      + {todos.map(todo => ( + + ))} +
    +); diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..e32cc9285 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,9 @@ import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import './styles/index.scss'; +import './styles/todo.scss'; +import './styles/filter.scss'; +import './styles/todoapp.scss'; import { App } from './App'; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..3f52a5fdd --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +}