From b2617a5099c8958d47e9a30dc54329b233313742 Mon Sep 17 00:00:00 2001 From: Rostyslav Date: Sat, 12 Oct 2024 14:48:44 +0300 Subject: [PATCH 1/2] Solution --- README.md | 2 +- src/App.tsx | 151 ++------------------------- src/components/Footer/Footer.tsx | 62 +++++++++++ src/components/Footer/index.ts | 1 + src/components/Header/Header.tsx | 86 +++++++++++++++ src/components/Header/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 138 ++++++++++++++++++++++++ src/components/TodoItem/index.ts | 1 + src/components/TodoList/TodoList.tsx | 23 ++++ src/components/TodoList/index.ts | 1 + src/index.tsx | 13 ++- src/store/store.tsx | 71 +++++++++++++ src/styles/todo.scss | 1 + src/styles/todoapp.scss | 1 + src/types/Action.ts | 9 ++ src/types/Filter.ts | 5 + src/types/State.ts | 7 ++ src/types/Todo.ts | 5 + src/utils/getFilteredTodos.ts | 12 +++ 19 files changed, 441 insertions(+), 149 deletions(-) create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.ts create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.ts create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/store/store.tsx create mode 100644 src/types/Action.ts create mode 100644 src/types/Filter.ts create mode 100644 src/types/State.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/getFilteredTodos.ts diff --git a/README.md b/README.md index 903c876f9..3a3295307 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://RostyslavSharuiev.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..537260000 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,19 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; + 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 */} - -
+
+ +
); diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..7db68d0c5 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,62 @@ +import { FC, useContext } from 'react'; +import cn from 'classnames'; + +import { Filter } from '../../types/Filter'; + +import { DispatchContext, StateContext } from '../../store/store'; + +export const Footer: FC = () => { + const dispatch = useContext(DispatchContext); + const { todos, filter } = useContext(StateContext); + + const completedTodosCount = todos.filter(todo => todo.completed).length; + const uncompletedTodos = todos.filter(todo => !todo.completed); + + const handleClearCompleted = () => { + dispatch({ type: 'changeTodos', payload: uncompletedTodos }); + }; + + const message = `${uncompletedTodos.length} ${uncompletedTodos.length === 1 ? 'item' : 'items'} left`; + + if (!todos.length) { + return null; + } + + return ( + + ); +}; 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..7ee36ee56 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,86 @@ +import { + FC, + FormEvent, + ChangeEvent, + useRef, + useState, + useEffect, + useContext, +} from 'react'; +import cn from 'classnames'; + +import { Todo } from '../../types/Todo'; + +import { DispatchContext, StateContext } from '../../store/store'; + +export const Header: FC = () => { + const [title, setTitle] = useState(''); + const inputRef = useRef(null); + + const { todos } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const isAllTodosCompleted = todos.every(todo => todo.completed); + + const handleInputChange = (event: ChangeEvent) => { + setTitle(event.target.value.trimStart()); + }; + + const handleFormSubmit = (event: FormEvent) => { + event.preventDefault(); + + const formattedTitle = title.trim(); + + if (formattedTitle.length) { + const newTodo: Todo = { + title: formattedTitle, + id: +new Date(), + completed: false, + }; + + dispatch({ type: 'addTodo', payload: newTodo }); + setTitle(''); + } + }; + + const handleToggleAllTodos = () => { + const updatedTodos = todos.map(todo => ({ + ...todo, + completed: !isAllTodosCompleted, + })); + + dispatch({ + type: 'changeTodos', + payload: updatedTodos, + }); + }; + + useEffect(() => { + inputRef.current?.focus(); + }, [todos.length]); + + return ( +
+ {!!todos.length && ( +
+ ); +}; 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/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..af7178311 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,138 @@ +import { + ChangeEvent, + FC, + FormEvent, + useContext, + useEffect, + useState, +} from 'react'; +import cn from 'classnames'; + +import { Todo } from '../../types/Todo'; + +import { DispatchContext } from '../../store/store'; + +interface Props { + todo: Todo; +} + +export const TodoItem: FC = ({ todo }) => { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(todo.title); + + const dispatch = useContext(DispatchContext); + + const handleCompletedTodo = () => { + dispatch({ + type: 'changeTodo', + payload: { + ...todo, + completed: !todo.completed, + }, + }); + }; + + const handleDeleteTodo = () => { + dispatch({ type: 'removeTodo', payload: todo.id }); + }; + + const handleTitleChange = (event: ChangeEvent) => { + setTitle(event.target.value.trimStart()); + }; + + const handleTitleSave = () => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + handleDeleteTodo(); + + return; + } + + dispatch({ + type: 'changeTodo', + payload: { + ...todo, + title: trimmedTitle, + }, + }); + + setIsEditing(false); + }; + + const handleFormSubmit = (event: FormEvent) => { + event.preventDefault(); + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setTitle(todo.title); + } else if (event.key === 'Enter') { + handleTitleSave(); + } + }; + + useEffect(() => { + if (isEditing) { + document.addEventListener('keyup', handleKeyUp); + } + // else { + // document.addEventListener('keyup', handleKeyUp); + // } + + return () => { + document.removeEventListener('keyup', handleKeyUp); + }; + }, [isEditing, handleKeyUp]); + + return ( +
setIsEditing(true)} + > + + + {!isEditing ? ( + <> + + {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..86b9a3d2f --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,23 @@ +import { FC, useContext, useMemo } from 'react'; + +import { StateContext } from '../../store/store'; + +import { getFilteredTodos } from '../../utils/getFilteredTodos'; + +import { TodoItem } from '../TodoItem'; + +export const TodoList: FC = () => { + const { todos, filter } = useContext(StateContext); + + const filteredTodos = useMemo(() => { + return getFilteredTodos(todos, filter); + }, [todos, filter]); + + 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/index.tsx b/src/index.tsx index a9689cb38..8fbd54d9b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,16 @@ 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-list.scss'; +// import './styles/filters.scss'; import { App } from './App'; +import { GlobalStateProvider } from './store/store'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/store/store.tsx b/src/store/store.tsx new file mode 100644 index 000000000..54f1e80db --- /dev/null +++ b/src/store/store.tsx @@ -0,0 +1,71 @@ +import { + createContext, + Dispatch, + FC, + ReactNode, + useEffect, + useReducer, +} from 'react'; + +import { Filter } from '../types/Filter'; +import { Action } from '../types/Action'; +import { State } from '../types/State'; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'addTodo': + return { + ...state, + todos: [...state.todos, action.payload], + }; + + case 'removeTodo': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload), + }; + + case 'changeTodo': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id ? action.payload : todo, + ), + }; + + case 'changeTodos': + return { ...state, todos: action.payload }; + + case 'setFilter': + return { ...state, filter: action.payload }; + + default: + return state; + } +}; + +const initialState: State = { + todos: JSON.parse(localStorage.getItem('todos') || '[]'), + filter: Filter.ALL, +}; + +export const StateContext = createContext(initialState); +export const DispatchContext = createContext>(() => {}); + +interface Props { + children: ReactNode; +} + +export const GlobalStateProvider: FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(state.todos)); + }, [state.todos]); + + return ( + + {children} + + ); +}; 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/Action.ts b/src/types/Action.ts new file mode 100644 index 000000000..97fff369a --- /dev/null +++ b/src/types/Action.ts @@ -0,0 +1,9 @@ +import { Filter } from './Filter'; +import { Todo } from './Todo'; + +export type Action = + | { type: 'addTodo'; payload: Todo } + | { type: 'removeTodo'; payload: number } + | { type: 'changeTodo'; payload: Todo } + | { type: 'changeTodos'; payload: Todo[] } + | { type: 'setFilter'; payload: Filter }; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..a1a2d1c3c --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + ALL = 'All', + ACTIVE = 'Active', + COMPLETED = 'Completed', +} diff --git a/src/types/State.ts b/src/types/State.ts new file mode 100644 index 000000000..2990f7672 --- /dev/null +++ b/src/types/State.ts @@ -0,0 +1,7 @@ +import { Filter } from './Filter'; +import { Todo } from './Todo'; + +export type State = { + todos: Todo[]; + filter: Filter; +}; 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/utils/getFilteredTodos.ts b/src/utils/getFilteredTodos.ts new file mode 100644 index 000000000..e58e3ea26 --- /dev/null +++ b/src/utils/getFilteredTodos.ts @@ -0,0 +1,12 @@ +import { Filter } from '../types/Filter'; +import { Todo } from '../types/Todo'; + +export const getFilteredTodos = (todos: Todo[], filter: Filter) => { + const filterCallbacks = { + [Filter.ALL]: () => true, + [Filter.ACTIVE]: (todo: Todo) => !todo.completed, + [Filter.COMPLETED]: (todo: Todo) => todo.completed, + }; + + return todos.filter(filterCallbacks[filter]); +}; From e59577bd63e759d9c2c71695f04c28fd5a858ac2 Mon Sep 17 00:00:00 2001 From: Rostyslav Date: Mon, 14 Oct 2024 13:31:14 +0300 Subject: [PATCH 2/2] Fixes --- src/components/Footer/Footer.tsx | 26 ++++++++++++++++------ src/components/TodoItem/TodoItem.tsx | 32 +++++++++++++++------------- src/index.tsx | 2 -- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index 7db68d0c5..d31618f50 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -1,4 +1,4 @@ -import { FC, useContext } from 'react'; +import { FC, useContext, useMemo } from 'react'; import cn from 'classnames'; import { Filter } from '../../types/Filter'; @@ -9,14 +9,25 @@ export const Footer: FC = () => { const dispatch = useContext(DispatchContext); const { todos, filter } = useContext(StateContext); - const completedTodosCount = todos.filter(todo => todo.completed).length; - const uncompletedTodos = todos.filter(todo => !todo.completed); + const completedTodosCount = useMemo( + () => todos.filter(todo => todo.completed).length, + [todos], + ); + + const uncompletedTodos = useMemo( + () => todos.filter(todo => !todo.completed), + [todos], + ); + + const message = `${uncompletedTodos.length} ${uncompletedTodos.length === 1 ? 'item' : 'items'} left`; const handleClearCompleted = () => { dispatch({ type: 'changeTodos', payload: uncompletedTodos }); }; - const message = `${uncompletedTodos.length} ${uncompletedTodos.length === 1 ? 'item' : 'items'} left`; + const handleChooseFilter = (filterTitle: Filter) => { + dispatch({ type: 'setFilter', payload: filterTitle }); + }; if (!todos.length) { return null; @@ -38,9 +49,7 @@ export const Footer: FC = () => { selected: filter === filterTitle, })} data-cy={`FilterLink${filterTitle}`} - onClick={() => - dispatch({ type: 'setFilter', payload: filterTitle }) - } + onClick={() => handleChooseFilter(filterTitle)} > {filterTitle} @@ -54,6 +63,9 @@ export const Footer: FC = () => { data-cy="ClearCompletedButton" onClick={handleClearCompleted} disabled={!completedTodosCount} + style={{ + visibility: completedTodosCount ? 'visible' : 'hidden', + }} > Clear completed diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index af7178311..61c046708 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -2,6 +2,7 @@ import { ChangeEvent, FC, FormEvent, + useCallback, useContext, useEffect, useState, @@ -32,15 +33,15 @@ export const TodoItem: FC = ({ todo }) => { }); }; - const handleDeleteTodo = () => { + const handleDeleteTodo = useCallback(() => { dispatch({ type: 'removeTodo', payload: todo.id }); - }; + }, [dispatch, todo.id]); const handleTitleChange = (event: ChangeEvent) => { setTitle(event.target.value.trimStart()); }; - const handleTitleSave = () => { + const handleTitleSave = useCallback(() => { const trimmedTitle = title.trim(); if (!trimmedTitle) { @@ -58,28 +59,28 @@ export const TodoItem: FC = ({ todo }) => { }); setIsEditing(false); - }; + }, [title, todo, dispatch, handleDeleteTodo]); const handleFormSubmit = (event: FormEvent) => { event.preventDefault(); }; - const handleKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsEditing(false); - setTitle(todo.title); - } else if (event.key === 'Enter') { - handleTitleSave(); - } - }; + const handleKeyUp = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setTitle(todo.title); + } else if (event.key === 'Enter') { + handleTitleSave(); + } + }, + [todo.title, handleTitleSave], + ); useEffect(() => { if (isEditing) { document.addEventListener('keyup', handleKeyUp); } - // else { - // document.addEventListener('keyup', handleKeyUp); - // } return () => { document.removeEventListener('keyup', handleKeyUp); @@ -95,6 +96,7 @@ export const TodoItem: FC = ({ todo }) => { onDoubleClick={() => setIsEditing(true)} >