From 7ef01a5c29d16361434de9d6d5b887aa13990b43 Mon Sep 17 00:00:00 2001 From: Bohdan Dymydiuk Date: Tue, 10 Dec 2024 16:13:33 +0200 Subject: [PATCH 1/3] add task solution --- src/App.tsx | 156 ++--------------------- src/components/TodoFooter/TodoFooter.tsx | 83 ++++++++++++ src/components/TodoFooter/index.ts | 1 + src/components/TodoHeader/TodoHeader.tsx | 65 ++++++++++ src/components/TodoHeader/index.ts | 1 + src/components/TodoList/TodoList.tsx | 150 ++++++++++++++++++++++ src/components/TodoList/index.ts | 1 + src/context/TodosContext.tsx | 151 ++++++++++++++++++++++ src/context/index.ts | 1 + src/enums/Filter.ts | 9 ++ src/index.tsx | 14 +- src/localStorage/index.ts | 1 + src/localStorage/localStorage.ts | 1 + src/styles/todo.scss | 1 + src/styles/todoapp.scss | 1 + src/types/Todo.ts | 5 + 16 files changed, 491 insertions(+), 150 deletions(-) create mode 100644 src/components/TodoFooter/TodoFooter.tsx create mode 100644 src/components/TodoFooter/index.ts create mode 100644 src/components/TodoHeader/TodoHeader.tsx create mode 100644 src/components/TodoHeader/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/context/TodosContext.tsx create mode 100644 src/context/index.ts create mode 100644 src/enums/Filter.ts create mode 100644 src/localStorage/index.ts create mode 100644 src/localStorage/localStorage.ts create mode 100644 src/types/Todo.ts diff --git a/src/App.tsx b/src/App.tsx index a399287bd..4ac424509 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,20 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext } from 'react'; +import { TodoList } from './components/TodoList'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoFooter } from './components/TodoFooter'; +import { TodosContext } from './context'; 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 */} - - - {/* this button should be disabled if there are no completed todos */} - -
+ + + {todos.length > 0 && }
); diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..db887cb0f --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,83 @@ +import React, { useContext } from 'react'; +import { TodosContext } from '../../context'; +import { Filter } from '../../enums/Filter'; +import classNames from 'classnames'; + +export const TodoFooter: React.FC = () => { + // #regions vars from contexts + + const { todos, activeTodos, filter, setFilter, deleteHandler } = + useContext(TodosContext); + + // #endregion + // #region handlers + + const deleteCompleted = () => { + const idsToDelete: number[] = []; + + todos.forEach(todo => { + const { completed, id } = todo; + + if (completed) { + idsToDelete.push(id); + } + }); + + deleteHandler(idsToDelete); + }; + + // #endregion + + return ( + + ); +}; diff --git a/src/components/TodoFooter/index.ts b/src/components/TodoFooter/index.ts new file mode 100644 index 000000000..544d07114 --- /dev/null +++ b/src/components/TodoFooter/index.ts @@ -0,0 +1 @@ +export * from './TodoFooter'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..f05e8cb51 --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,65 @@ +import React, { useContext } from 'react'; +import { TodosContext } from '../../context'; +import classNames from 'classnames'; + +export const TodoHeader: React.FC = () => { + // #region vars from contexts + + const { + todos, + activeTodos, + value, + headerInputRef, + setValue, + onSubmit, + toogleHandler, + } = useContext(TodosContext); + + // #endregion + // #region handlers + + const toogleAll = () => { + if (activeTodos === 0) { + todos.forEach(todo => toogleHandler(todo.id)); + + return; + } + + todos.forEach(todo => { + const { completed, id } = todo; + + if (!completed) { + toogleHandler(id); + } + }); + }; + + // #endregion + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoHeader/index.ts b/src/components/TodoHeader/index.ts new file mode 100644 index 000000000..c4db4bc40 --- /dev/null +++ b/src/components/TodoHeader/index.ts @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..3f523c554 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,150 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { TodosContext } from '../../context'; +import classNames from 'classnames'; +import { Filter, Names } from '../../enums/Filter'; +import { myLocalStorage } from '../../localStorage'; + +export const TodoList: React.FC = () => { + // #regions vars from contexts + + const { todos, filter, setTodos, toogleHandler, deleteHandler } = + useContext(TodosContext); + + // #endregion + // #region states + + const [editableTodoById, setEditableTodoById] = useState(0); + const [editableTitle, setEditableTitle] = useState(''); + + // #endregion + // #region refs + + const todoInputRef = useRef(null); + + // #endregion + // #region variables + + const filteredTodos = todos.filter(todo => { + switch (filter) { + case Filter.active: + return !todo.completed; + case Filter.completed: + return todo.completed; + default: + return todo; + } + }); + + // #endregion + // #region handlers + + const editHandler = (id: number, value: string) => { + setEditableTodoById(id); + setEditableTitle(value); + }; + + const onSubmit = (event: React.FormEvent, id: number) => { + event.preventDefault(); + const trimmedTitle = editableTitle.trim(); + + if (trimmedTitle.length > 0) { + const updatedTodos = todos.map(todo => { + if (todo.id === id) { + return { ...todo, title: trimmedTitle }; + } + + return todo; + }); + + setTodos(updatedTodos); + myLocalStorage.setItem(Names.todos, JSON.stringify(updatedTodos)); + } else { + deleteHandler([id]); + } + + setEditableTodoById(0); + setEditableTitle(''); + }; + + const onKeyUpHandler = (key: React.KeyboardEvent) => { + if (key.code === 'Escape') { + setEditableTodoById(0); + setEditableTitle(''); + } + }; + + // #endregion + // #region useEffects + + useEffect(() => { + todoInputRef.current?.focus(); + }, [editableTodoById]); + + // #endregion + + return ( +
onKeyUpHandler(event)} + > + {filteredTodos.map(todo => { + const { id, title } = todo; + const isTodoChecked = todo.completed; + + return ( +
+ + + {editableTodoById === id ? ( +
onSubmit(event, id)}> + onSubmit(event, id)} + data-cy="TodoTitleField" + type="text" + className="todo__title-field" + placeholder="Empty todo will be deleted" + value={editableTitle} + onChange={event => setEditableTitle(event.target.value)} + /> +
+ ) : ( + editHandler(id, title)} + > + {title || 'Todo is being saved now'} + + )} + + {editableTodoById !== id && ( + + )} +
+ ); + })} +
+ ); +}; 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/TodosContext.tsx b/src/context/TodosContext.tsx new file mode 100644 index 000000000..b8a36e739 --- /dev/null +++ b/src/context/TodosContext.tsx @@ -0,0 +1,151 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { + createContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Todo } from '../types/Todo'; +import { myLocalStorage } from '../localStorage'; +import { Filter, Names } from '../enums/Filter'; + +// #region context + +interface TodosContextType { + todos: Todo[]; + activeTodos: number; + value: string; + filter: string; + headerInputRef: React.MutableRefObject; + setTodos: (todos: Todo[]) => void; + setValue: (value: string) => void; + onSubmit: (event: React.FormEvent) => void; + setFilter: (value: Filter) => void; + toogleHandler: (id: number) => void; + deleteHandler: (ids: number[]) => void; +} + +export const TodosContext = createContext({ + todos: [], + activeTodos: 0, + value: '', + filter: '', + headerInputRef: { + current: null, + }, + setTodos: () => {}, + setValue: () => {}, + onSubmit: () => {}, + setFilter: () => {}, + toogleHandler: () => {}, + deleteHandler: () => {}, +}); + +// #endregion + +interface Props { + children: React.ReactNode; +} + +export const TodosProvider: React.FC = ({ children }) => { + // #region states + + const [todos, setTodos] = useState([]); + const [value, setValue] = useState(''); + const [filter, setFilter] = useState(Filter.all); + + // #endregion + // #region refs + + const headerInputRef = useRef(null); + + // #endregion + // #region handlers + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const todoId = +new Date(); + const todo = { + id: todoId, + title: value.trim(), + completed: false, + }; + + myLocalStorage.setItem(Names.todos, JSON.stringify([...todos, todo])); + setTodos(currentTodos => [...currentTodos, todo]); + + setValue(''); + }; + + const toogleHandler = (id: number) => { + const updatedTodos = todos.map(todo => { + if (todo.id === id) { + const { completed } = todo; + + return { ...todo, completed: !completed }; + } + + return todo; + }); + + setTodos(updatedTodos); + myLocalStorage.setItem(Names.todos, JSON.stringify(updatedTodos)); + }; + + const deleteHandler = (ids: number[]) => { + const updatedTodos = todos.filter(todo => !ids.includes(todo.id)); + + setTodos(updatedTodos); + myLocalStorage.setItem(Names.todos, JSON.stringify(updatedTodos)); + headerInputRef.current?.focus(); + }; + + // #endregion + // #region variables + + const activeTodos = todos.filter(_todo => !_todo.completed).length; + + // #endregion + // #region useEffects + + useEffect(() => { + const getTodos = myLocalStorage.getItem(Names.todos); + + if (!getTodos) { + myLocalStorage.setItem(Names.todos, JSON.stringify([])); + } else { + setTodos(JSON.parse(getTodos as string)); + } + + headerInputRef.current?.focus(); + }, []); + + // #endregion + // #region contextValue + + const contextValue = useMemo( + () => ({ + todos, + activeTodos, + value, + filter, + headerInputRef, + setTodos, + setValue, + onSubmit, + setFilter, + toogleHandler, + deleteHandler, + }), + [todos, value, filter], + ); + + // #endregion + + return ( + + {children} + + ); +}; diff --git a/src/context/index.ts b/src/context/index.ts new file mode 100644 index 000000000..b9ab89200 --- /dev/null +++ b/src/context/index.ts @@ -0,0 +1 @@ +export * from './TodosContext'; diff --git a/src/enums/Filter.ts b/src/enums/Filter.ts new file mode 100644 index 000000000..eef71c709 --- /dev/null +++ b/src/enums/Filter.ts @@ -0,0 +1,9 @@ +export enum Filter { + all = 'All', + active = 'Active', + completed = 'Completed', +} + +export enum Names { + todos = 'todos', +} diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..a9b8be212 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/filter.scss'; +import './styles/index.scss'; +import './styles/todo.scss'; +import './styles/todoapp.scss'; import { App } from './App'; +import { TodosProvider } from './context'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/localStorage/index.ts b/src/localStorage/index.ts new file mode 100644 index 000000000..3fcbe130e --- /dev/null +++ b/src/localStorage/index.ts @@ -0,0 +1 @@ +export * from './localStorage'; diff --git a/src/localStorage/localStorage.ts b/src/localStorage/localStorage.ts new file mode 100644 index 000000000..92aeed0d3 --- /dev/null +++ b/src/localStorage/localStorage.ts @@ -0,0 +1 @@ +export const myLocalStorage = window.localStorage; diff --git a/src/styles/todo.scss b/src/styles/todo.scss index 4576af434..fd5f6aa10 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -73,6 +73,7 @@ &__title-field { width: 100%; padding: 11px 14px; + box-sizing: border-box; font-size: inherit; line-height: inherit; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..cf79aa03b 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -58,6 +58,7 @@ &__new-todo { width: 100%; padding: 16px 16px 16px 60px; + box-sizing: border-box; font-size: 24px; line-height: 1.4em; 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; +} From 0c7835aa0005f4580b39ff494f9f58deae1a9a61 Mon Sep 17 00:00:00 2001 From: Bohdan Dymydiuk Date: Tue, 10 Dec 2024 18:40:47 +0200 Subject: [PATCH 2/3] add some fixes --- src/components/TodoHeader/TodoHeader.tsx | 23 ++++++++++++----------- src/context/TodosContext.tsx | 11 +++++++---- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx index f05e8cb51..d27f14147 100644 --- a/src/components/TodoHeader/TodoHeader.tsx +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -1,6 +1,8 @@ import React, { useContext } from 'react'; import { TodosContext } from '../../context'; import classNames from 'classnames'; +import { myLocalStorage } from '../../localStorage'; +import { Names } from '../../enums/Filter'; export const TodoHeader: React.FC = () => { // #region vars from contexts @@ -10,28 +12,27 @@ export const TodoHeader: React.FC = () => { activeTodos, value, headerInputRef, + setTodos, setValue, onSubmit, - toogleHandler, } = useContext(TodosContext); // #endregion // #region handlers const toogleAll = () => { - if (activeTodos === 0) { - todos.forEach(todo => toogleHandler(todo.id)); + const updatedTodos = todos.map(todo => { + const { completed } = todo; - return; - } - - todos.forEach(todo => { - const { completed, id } = todo; - - if (!completed) { - toogleHandler(id); + if (activeTodos === 0 && completed) { + return { ...todo, completed: !completed }; } + + return { ...todo, completed: true }; }); + + setTodos(updatedTodos); + myLocalStorage.setItem(Names.todos, JSON.stringify(updatedTodos)); }; // #endregion diff --git a/src/context/TodosContext.tsx b/src/context/TodosContext.tsx index b8a36e739..37179a877 100644 --- a/src/context/TodosContext.tsx +++ b/src/context/TodosContext.tsx @@ -1,6 +1,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ import React, { createContext, + memo, useEffect, useMemo, useRef, @@ -48,7 +49,7 @@ interface Props { children: React.ReactNode; } -export const TodosProvider: React.FC = ({ children }) => { +export const TodosProvider: React.FC = memo(({ children }) => { // #region states const [todos, setTodos] = useState([]); @@ -80,9 +81,9 @@ export const TodosProvider: React.FC = ({ children }) => { const toogleHandler = (id: number) => { const updatedTodos = todos.map(todo => { - if (todo.id === id) { - const { completed } = todo; + const { completed } = todo; + if (todo.id === id) { return { ...todo, completed: !completed }; } @@ -148,4 +149,6 @@ export const TodosProvider: React.FC = ({ children }) => { {children} ); -}; +}); + +TodosProvider.displayName = 'TodosProvider'; From 42ba2ccf42e55241f7138d1dab1882fed4106b22 Mon Sep 17 00:00:00 2001 From: Bohdan Dymydiuk Date: Wed, 11 Dec 2024 11:01:17 +0200 Subject: [PATCH 3/3] add some changes --- src/components/TodoFooter/TodoFooter.tsx | 54 ++++----------- src/components/TodoHeader/TodoHeader.tsx | 9 +-- src/components/TodoItem/TodoItem.tsx | 86 ++++++++++++++++++++++++ src/components/TodoItem/index.ts | 0 src/components/TodoList/TodoList.tsx | 85 +++++------------------ src/context/TodosContext.tsx | 30 +-------- src/enums/{Filter.ts => Enums.ts} | 0 7 files changed, 119 insertions(+), 145 deletions(-) create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts rename src/enums/{Filter.ts => Enums.ts} (100%) diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx index db887cb0f..461abd951 100644 --- a/src/components/TodoFooter/TodoFooter.tsx +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -1,17 +1,12 @@ import React, { useContext } from 'react'; import { TodosContext } from '../../context'; -import { Filter } from '../../enums/Filter'; +import { Filter } from '../../enums/Enums'; import classNames from 'classnames'; export const TodoFooter: React.FC = () => { - // #regions vars from contexts - const { todos, activeTodos, filter, setFilter, deleteHandler } = useContext(TodosContext); - // #endregion - // #region handlers - const deleteCompleted = () => { const idsToDelete: number[] = []; @@ -26,8 +21,6 @@ export const TodoFooter: React.FC = () => { deleteHandler(idsToDelete); }; - // #endregion - return (