From 0205f45e00d0934a697dacd3b21e32ada8ff78ac Mon Sep 17 00:00:00 2001 From: Ivan Mondok Date: Sun, 24 Nov 2024 19:33:04 +0200 Subject: [PATCH 1/2] solution --- README.md | 2 +- src/App.tsx | 156 +++--------------------------------- src/components/Footer.tsx | 52 ++++++++++++ src/components/Header.tsx | 75 +++++++++++++++++ src/components/Store.tsx | 124 ++++++++++++++++++++++++++++ src/components/TodoItem.tsx | 143 +++++++++++++++++++++++++++++++++ src/components/TodoList.tsx | 29 +++++++ src/index.tsx | 15 +++- src/services.ts | 9 +++ src/styles/index.scss | 4 + src/styles/todoapp.scss | 1 + src/types/Filter.ts | 5 ++ src/types/Todo.ts | 5 ++ src/types/TodoStatus.ts | 5 ++ 14 files changed, 475 insertions(+), 150 deletions(-) create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/Store.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/services.ts create mode 100644 src/types/Filter.ts create mode 100644 src/types/Todo.ts create mode 100644 src/types/TodoStatus.ts diff --git a/README.md b/README.md index 903c876f9..c9153bf66 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://imondok03.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..96f89cac2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,22 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext } from 'react'; +import { StateContext } from './components/Store'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; export const App: React.FC = () => { + const { todos } = useContext(StateContext); + 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 &&
}
); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..9d9421116 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,52 @@ +import { useContext } from 'react'; +import cn from 'classnames'; +import { DispatchContext, StateContext } from './Store'; +import { getActiveTodosArray, getCompletedTodosArray } from '../services'; +import { Filter } from '../types/Filter'; +import React from 'react'; + +export const Footer = () => { + const { todos, filter } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..3ec2ae125 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,75 @@ +import { FormEvent, useContext, useEffect, useRef } from 'react'; +import { DispatchContext, StateContext } from './Store'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; +import { getCompletedTodosArray } from '../services'; +import React from 'react'; + +export const Header = () => { + const { todos, newTodoTitle } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }); + + const handleSetNewTodoTitle = ( + event: React.ChangeEvent, + ) => { + dispatch({ type: 'setNewTodoTitle', payload: event.target.value }); + }; + + const handleAddTodo = (event: FormEvent) => { + event.preventDefault(); + + if (!newTodoTitle.trim()) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: newTodoTitle.trim(), + completed: false, + }; + + dispatch({ type: 'addTodo', payload: newTodo }); + dispatch({ type: 'setNewTodoTitle', payload: '' }); + }; + + const validation = todos.every(todo => todo.completed === true); + + const handleToggleAll = () => { + dispatch({ type: 'setAllCompleted', payload: !validation }); + }; + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/Store.tsx b/src/components/Store.tsx new file mode 100644 index 000000000..75b99611e --- /dev/null +++ b/src/components/Store.tsx @@ -0,0 +1,124 @@ +import { useEffect, useReducer } from 'react'; +import { Filter } from '../types/Filter'; +import { Todo } from '../types/Todo'; +import { TodoStatus } from '../types/TodoStatus'; +import React from 'react'; + +type State = { + todos: Todo[]; + newTodoTitle: string; + status: TodoStatus; + filter: Filter; +}; + +const getTodosFromLocaleStorage = (): Todo[] => { + const todos = localStorage.getItem('todos'); + + return todos ? JSON.parse(todos) : []; +}; + +const initialTodos: Todo[] = getTodosFromLocaleStorage(); + +const initialState: State = { + todos: initialTodos, + newTodoTitle: '', + status: TodoStatus.all, + filter: Filter.All, +}; + +type Action = + | { type: 'addTodo'; payload: Todo } + | { type: 'deleteTodo'; payload: number } + | { type: 'updateTodo'; payload: Todo } + | { type: 'setNewTodoTitle'; payload: string } + | { type: 'setAllCompleted'; payload: boolean } + | { type: 'setStatus'; payload: TodoStatus } + | { type: 'setNewStatus'; payload: Todo } + | { type: 'setFilterByStatus'; payload: Filter } + | { type: 'clearAllCompleted' }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'addTodo': + return { + ...state, + todos: [...state.todos, action.payload], + }; + case 'deleteTodo': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload), + }; + case 'updateTodo': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, title: action.payload.title } + : todo, + ), + }; + case 'setNewTodoTitle': + return { + ...state, + newTodoTitle: action.payload, + }; + case 'setAllCompleted': + return { + ...state, + todos: state.todos.map(todo => ({ + ...todo, + completed: action.payload, + })), + }; + case 'setStatus': + return { + ...state, + status: action.payload, + }; + case 'setNewStatus': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, completed: !todo.completed } + : todo, + ), + }; + case 'setFilterByStatus': + return { + ...state, + filter: action.payload, + }; + case 'clearAllCompleted': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed), + }; + default: + return state; + } +} + +export const StateContext = React.createContext(initialState); +export const DispatchContext = React.createContext>( + () => {}, +); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalStateProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(state.todos)); + }, [state.todos]); + + return ( + + {children} + + ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..1cce6903a --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,143 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { DispatchContext } from './Store'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const dispatch = useContext(DispatchContext); + + const handleChangeStatusTodo = ( + event: React.ChangeEvent, + ) => { + dispatch({ + type: 'setNewStatus', + payload: { ...todo, completed: event.target.checked }, + }); + }; + + const [isEditingTodo, setIsEditingTodo] = useState(false); + const [updatedTitleTodo, setUpdatedTitleTodo] = useState(todo.title); + + const updatedInput = useRef(null); + + useEffect(() => { + if (isEditingTodo && updatedInput.current) { + updatedInput.current?.focus(); + } + }, [isEditingTodo]); + + useEffect(() => setIsEditingTodo(false), [todo]); + + const { id, title, completed } = todo; + + const handleDeleteTodo = (todoId: number) => { + dispatch({ type: 'deleteTodo', payload: todoId }); + }; + + const handleSubmit = () => { + const newTitle = updatedTitleTodo.trim(); + + if (newTitle === title) { + setIsEditingTodo(false); + + return; + } + + if (!newTitle) { + dispatch({ type: 'deleteTodo', payload: todo.id }); + + return; + } + + setUpdatedTitleTodo(newTitle); + + dispatch({ type: 'updateTodo', payload: { ...todo, title: newTitle } }); + }; + + const handleBlur = () => { + handleSubmit(); + }; + + const handleKeyEvent = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setUpdatedTitleTodo(todo.title); + setIsEditingTodo(false); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + setUpdatedTitleTodo(event.target.value); + }; + + const handleDoubleClick = () => { + if (!isEditingTodo) { + setIsEditingTodo(true); + } + }; + + return ( +
+ + + {isEditingTodo ? ( +
{ + event.preventDefault(); + handleSubmit(); + setIsEditingTodo(false); + }} + > + +
+ ) : ( + <> + + {updatedTitleTodo} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..1f59b658a --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,29 @@ +import { useContext, useMemo } from 'react'; +import { TodoItem } from './TodoItem'; +import { StateContext } from './Store'; +import { Filter } from '../types/Filter'; +import React from 'react'; + +export const TodoList = () => { + const { todos, filter } = useContext(StateContext); + + const preparedTodos = useMemo(() => { + if (filter === Filter.Active) { + return todos.filter(todo => !todo.completed); + } + + if (filter === Filter.Completed) { + return todos.filter(todo => todo.completed); + } + + return todos; + }, [todos, filter]); + + return ( +
+ {preparedTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..0be17fefc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,18 @@ 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/todoapp.scss'; +// import './styles/filter.scss'; import { App } from './App'; +import React from 'react'; +import { GlobalStateProvider } from './components/Store'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/services.ts b/src/services.ts new file mode 100644 index 000000000..72f8cfdfe --- /dev/null +++ b/src/services.ts @@ -0,0 +1,9 @@ +import { Todo } from './types/Todo'; + +export const getActiveTodosArray = (todos: Todo[]) => { + return todos.filter((todo: Todo) => !todo.completed); +}; + +export const getCompletedTodosArray = (todos: Todo[]) => { + return todos.filter((todo: Todo) => todo.completed); +}; diff --git a/src/styles/index.scss b/src/styles/index.scss index a34eec7c6..72904c4f1 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,3 +1,7 @@ +* { + box-sizing: border-box; +} + iframe { display: none; } 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/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..66887875b --- /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/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..d94ea1bff --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export type 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..bc1b08497 --- /dev/null +++ b/src/types/TodoStatus.ts @@ -0,0 +1,5 @@ +export enum TodoStatus { + all = 'All', + active = 'Active', + completed = 'Completed', +} From 64c568b87deae78af6a1f5a45e25e65b42730a76 Mon Sep 17 00:00:00 2001 From: Ivan Mondok Date: Sun, 24 Nov 2024 20:14:30 +0200 Subject: [PATCH 2/2] solution --- src/components/Store.tsx | 7 +++---- src/index.tsx | 5 ----- src/types/TodoStatus.ts | 5 ----- 3 files changed, 3 insertions(+), 14 deletions(-) delete mode 100644 src/types/TodoStatus.ts diff --git a/src/components/Store.tsx b/src/components/Store.tsx index 75b99611e..db85b2446 100644 --- a/src/components/Store.tsx +++ b/src/components/Store.tsx @@ -1,13 +1,12 @@ import { useEffect, useReducer } from 'react'; import { Filter } from '../types/Filter'; import { Todo } from '../types/Todo'; -import { TodoStatus } from '../types/TodoStatus'; import React from 'react'; type State = { todos: Todo[]; newTodoTitle: string; - status: TodoStatus; + status: Filter; filter: Filter; }; @@ -22,7 +21,7 @@ const initialTodos: Todo[] = getTodosFromLocaleStorage(); const initialState: State = { todos: initialTodos, newTodoTitle: '', - status: TodoStatus.all, + status: Filter.All, filter: Filter.All, }; @@ -32,7 +31,7 @@ type Action = | { type: 'updateTodo'; payload: Todo } | { type: 'setNewTodoTitle'; payload: string } | { type: 'setAllCompleted'; payload: boolean } - | { type: 'setStatus'; payload: TodoStatus } + | { type: 'setStatus'; payload: Filter } | { type: 'setNewStatus'; payload: Todo } | { type: 'setFilterByStatus'; payload: Filter } | { type: 'clearAllCompleted' }; diff --git a/src/index.tsx b/src/index.tsx index 0be17fefc..4ecbf4d8d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,10 +1,5 @@ import { createRoot } from 'react-dom/client'; - import './styles/index.scss'; -// import './styles/todo.scss'; -// import './styles/todoapp.scss'; -// import './styles/filter.scss'; - import { App } from './App'; import React from 'react'; import { GlobalStateProvider } from './components/Store'; diff --git a/src/types/TodoStatus.ts b/src/types/TodoStatus.ts deleted file mode 100644 index bc1b08497..000000000 --- a/src/types/TodoStatus.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum TodoStatus { - all = 'All', - active = 'Active', - completed = 'Completed', -}