From 235cf32061ff36d44285621f3a8152589f66a5b9 Mon Sep 17 00:00:00 2001 From: Yulii Zilnyk Date: Tue, 8 Oct 2024 13:37:28 +0300 Subject: [PATCH] add task solution --- src/App.tsx | 154 ++----------------- src/components/Footer/Footer.tsx | 50 ++++++ src/components/Header/Header.tsx | 58 +++++++ src/components/TodoItem/TodoItem.tsx | 100 ++++++++++++ src/components/TodoList/TodoList.tsx | 15 ++ src/components/TodosContext/TodosContext.tsx | 111 +++++++++++++ src/hooks/useLocalStorage.ts | 33 ++++ src/index.tsx | 14 +- src/styles/todo.scss | 1 + src/styles/todoapp.scss | 1 + src/types/Todo.ts | 5 + src/types/TodoStatus.ts | 5 + src/utils/countActiveTodo.ts | 5 + src/utils/getFiltredTodos.ts | 15 ++ 14 files changed, 419 insertions(+), 148 deletions(-) create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodosContext/TodosContext.tsx create mode 100644 src/hooks/useLocalStorage.ts create mode 100644 src/types/Todo.ts create mode 100644 src/types/TodoStatus.ts create mode 100644 src/utils/countActiveTodo.ts create mode 100644 src/utils/getFiltredTodos.ts diff --git a/src/App.tsx b/src/App.tsx index a399287bd..c190703e3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,22 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext } from 'react'; +import { Header } from './components/Header/Header'; +import { TodoList } from './components/TodoList/TodoList'; +import { Footer } from './components/Footer/Footer'; +import { TodosContext } from './components/TodosContext/TodosContext'; export const App: React.FC = () => { + const { todos } = useContext(TodosContext); + 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 */} - + {!!todos.length && } - {/* this button should be disabled if there are no completed todos */} - -
+ {!!todos.length &&
}
); diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..25e8b8222 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,50 @@ +import React, { useContext, useMemo } from 'react'; +import classNames from 'classnames'; +import { MethodsContext, TodosContext } from '../TodosContext/TodosContext'; +import { TodoStatus } from '../../types/TodoStatus'; +import { countActiveTodo } from '../../utils/countActiveTodo'; + +export const Footer: React.FC = () => { + const { todos, activeFilter, setActiveFilter } = useContext(TodosContext); + + const { clearCompleted } = useContext(MethodsContext); + + const activeTodoCount = useMemo(() => countActiveTodo(todos), [todos]); + + return ( + + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..faeec1fc5 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,58 @@ +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { MethodsContext, TodosContext } from '../TodosContext/TodosContext'; +import { countActiveTodo } from '../../utils/countActiveTodo'; + +export const Header: React.FC = () => { + const { todos } = useContext(TodosContext); + const { addTodo, toggleAll } = useContext(MethodsContext); + + const [newTitle, setNewTitle] = useState(''); + + const titleInput = useRef(null); + + useEffect(() => { + titleInput.current?.focus(); + }, [todos]); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const trimmed = newTitle.trim(); + + if (trimmed) { + addTodo(trimmed); + setNewTitle(''); + titleInput.current?.focus(); + } + } + + const activeTodoCount = useMemo(() => countActiveTodo(todos), [todos]); + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..1dd9bb583 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,100 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { MethodsContext } from '../TodosContext/TodosContext'; +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { id, title, completed } = todo; + const { deleteTodo, toggleTodo, renameTodo } = useContext(MethodsContext); + + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(title); + + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + function saveChanges() { + const trimmed = newTitle.trim(); + + if (trimmed) { + renameTodo(todo.id, trimmed); + } else { + deleteTodo(todo.id); + } + + setIsEditing(false); + } + + function handleSubmitForm(event: React.FormEvent) { + event.preventDefault(); + + saveChanges(); + } + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + saveChanges(); + } else if (event.key === 'Escape') { + setIsEditing(false); + setNewTitle(title); + } + }; + + return ( +
+ + + {isEditing ? ( +
+ setNewTitle(event.target.value)} + onKeyDown={handleKeyUp} + onBlur={saveChanges} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..fedf104c7 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,15 @@ +import { useContext } from 'react'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { TodosContext } from '../TodosContext/TodosContext'; + +export const TodoList: React.FC = () => { + const { filtredTodos } = useContext(TodosContext); + + return ( +
+ {filtredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodosContext/TodosContext.tsx b/src/components/TodosContext/TodosContext.tsx new file mode 100644 index 000000000..c4495b734 --- /dev/null +++ b/src/components/TodosContext/TodosContext.tsx @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useMemo, useState } from 'react'; +import { TodoStatus } from '../../types/TodoStatus'; +import { Todo } from '../../types/Todo'; +import { getFiltredTodos } from '../../utils/getFiltredTodos'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; + +interface Methods { + addTodo: (title: string) => void; + deleteTodo: (id: number) => void; + toggleTodo: (id: number) => void; + renameTodo: (id: number, title: string) => void; + toggleAll: () => void; + clearCompleted: () => void; +} + +export const MethodsContext = React.createContext({ + addTodo: () => {}, + deleteTodo: () => {}, + toggleTodo: () => {}, + renameTodo: () => {}, + toggleAll: () => {}, + clearCompleted: () => {}, +}); + +type Context = { + todos: Todo[]; + filtredTodos: Todo[]; + setTodos: (newTodos: Todo[]) => void; + activeFilter: TodoStatus; + setActiveFilter: (newActiveFilter: TodoStatus) => void; +}; + +export const TodosContext = React.createContext({ + todos: [], + filtredTodos: [], + setTodos: () => {}, + activeFilter: TodoStatus.All, + setActiveFilter: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useLocalStorage('todos', []); + const [activeFilter, setActiveFilter] = useState(TodoStatus.All); + + const methods = useMemo( + () => ({ + addTodo(title: string) { + const newTodo: Todo = { + id: Date.now(), + title, + completed: false, + }; + + setTodos([...todos, newTodo]); + }, + + deleteTodo(id: number) { + setTodos(todos.filter(todo => todo.id !== id)); + }, + + toggleTodo(id: number) { + setTodos( + todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }, + + renameTodo(id: number, title: string) { + setTodos( + todos.map(todo => (todo.id === id ? { ...todo, title } : todo)), + ); + }, + + toggleAll() { + const allCompleted = todos.every(todo => todo.completed); + + setTodos(todos.map(todo => ({ ...todo, completed: !allCompleted }))); + }, + + clearCompleted() { + setTodos(todos.filter(todo => !todo.completed)); + }, + }), + [todos], + ); + + const filtredTodos = getFiltredTodos(todos, activeFilter); + + const value: Context = useMemo( + () => ({ + todos, + filtredTodos, + setTodos, + activeFilter, + setActiveFilter, + }), + [todos, filtredTodos, setTodos, activeFilter], + ); + + return ( + + {children} + + ); +}; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..04fc40981 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,33 @@ +import { useState } from 'react'; + +export function useLocalStorage( + valueName: string, + initialValue: T, +): [T, (v: T) => void] { + const [value, setValue] = useState(() => { + if (localStorage.getItem(valueName) === null) { + localStorage.setItem(valueName, JSON.stringify(initialValue)); + } + + const data = localStorage.getItem(valueName); + + if (data === null) { + return initialValue; + } + + try { + return JSON.parse(data); + } catch (error) { + localStorage.removeItem(valueName); + + return initialValue; + } + }); + + const changeValue = (newValue: T) => { + localStorage.setItem(valueName, JSON.stringify(newValue)); + setValue(newValue); + }; + + return [value, changeValue]; +} diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..72f012c62 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,17 @@ 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/todoapp.scss'; +import './styles/todo.scss'; +import './styles/filter.scss'; import { App } from './App'; +import { TodosProvider } from './components/TodosContext/TodosContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/todo.scss b/src/styles/todo.scss index 4576af434..cfb34ec2f 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -71,6 +71,7 @@ } &__title-field { + box-sizing: border-box; width: 100%; padding: 11px 14px; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..29383a1e2 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -56,6 +56,7 @@ } &__new-todo { + box-sizing: border-box; width: 100%; padding: 16px 16px 16px 60px; 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; +} diff --git a/src/types/TodoStatus.ts b/src/types/TodoStatus.ts new file mode 100644 index 000000000..207c27648 --- /dev/null +++ b/src/types/TodoStatus.ts @@ -0,0 +1,5 @@ +export enum TodoStatus { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/utils/countActiveTodo.ts b/src/utils/countActiveTodo.ts new file mode 100644 index 000000000..752b45804 --- /dev/null +++ b/src/utils/countActiveTodo.ts @@ -0,0 +1,5 @@ +import { Todo } from '../types/Todo'; + +export const countActiveTodo = (todos: Todo[]) => { + return todos.reduce((count, todo) => count + Number(!todo.completed), 0); +}; diff --git a/src/utils/getFiltredTodos.ts b/src/utils/getFiltredTodos.ts new file mode 100644 index 000000000..d94b36c27 --- /dev/null +++ b/src/utils/getFiltredTodos.ts @@ -0,0 +1,15 @@ +import { Todo } from '../types/Todo'; +import { TodoStatus } from '../types/TodoStatus'; + +export const getFiltredTodos = (todos: Todo[], filter: TodoStatus) => { + switch (filter) { + case TodoStatus.Completed: + return todos.filter(todo => todo.completed); + + case TodoStatus.Active: + return todos.filter(todo => !todo.completed); + + default: + return todos; + } +};