diff --git a/src/App.tsx b/src/App.tsx index a399287bd..51c5f2086 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,119 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useState } from 'react'; + +import { Filter } from './types/Filter'; +import { TodosContext } from './TodoContext/TodoContext'; +import { Header } from './components/Header/header'; +import { Footer } from './components/Footer/footer'; +import { TodoList } from './components/TodoList/todoList'; export const App: React.FC = () => { + const [filterStatus, setFilterStatus] = useState(Filter.All); + const [editTodo, setEditingTodo] = useState(null); + const [editingValue, setEditingValue] = useState(''); + const { todos, setTodos } = useContext(TodosContext); + + const handleDeleteTodo = (todoId: number) => { + const updateTodos = todos.filter(todo => todo.id !== todoId); + + setTodos(updateTodos); + }; + + const updateTodoStatus = (todoId: number) => { + const updatedTodos = todos.map(todo => { + if (todo.id === todoId) { + return { ...todo, completed: !todo.completed }; + } + + return todo; + }); + + setTodos(updatedTodos); + }; + + const handleUpdateAll = () => { + const areSomeIncomplete = todos.some(todo => !todo.completed); + + if (areSomeIncomplete) { + setTodos( + todos.map(todo => ({ + ...todo, + completed: true, + })), + ); + } else { + setTodos( + todos.map(todo => ({ + ...todo, + completed: false, + })), + ); + } + }; + + const handleRanameTodo = (todoId: number) => { + const todo = todos.find(t => t.id === todoId); + + if (todo) { + setEditingTodo(todoId); + setEditingValue(todo.title); + } + }; + + const handleTodoTitleBlur = () => { + if (editingValue.trim() === '') { + handleDeleteTodo(editTodo as number); + } else { + const updatedTodos = todos.map(todo => { + if (todo.id === editTodo) { + return { ...todo, title: editingValue.trim() }; + } + + return todo; + }); + + setTodos(updatedTodos); + } + + setEditingTodo(null); + setEditingValue(''); + }; + + const handleTodoTitleKeyUp = ( + event: React.KeyboardEvent, + ) => { + if (event.key === 'Escape') { + setEditingTodo(null); + setEditingValue(''); + } else if (event.key === 'Enter') { + handleTodoTitleBlur(); + } + }; + 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/TodoContext/TodoContext.tsx b/src/TodoContext/TodoContext.tsx new file mode 100644 index 000000000..526b88b38 --- /dev/null +++ b/src/TodoContext/TodoContext.tsx @@ -0,0 +1,33 @@ +import React, { useMemo } from 'react'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { Todo } from '../types/Todos'; + +interface TodosContextType { + todos: Todo[]; + setTodos: (todos: Todo[]) => void; +} + +export const TodosContext = React.createContext({ + todos: [], + setTodos: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useLocalStorage('todos', []); + + const value = useMemo( + () => ({ + todos, + setTodos, + }), + [todos, setTodos], + ); + + return ( + {children} + ); +}; diff --git a/src/components/Footer/footer.tsx b/src/components/Footer/footer.tsx new file mode 100644 index 000000000..affa1de7d --- /dev/null +++ b/src/components/Footer/footer.tsx @@ -0,0 +1,53 @@ +import classNames from 'classnames'; +import { Filter } from '../../types/Filter'; +import { TodosContext } from '../../TodoContext/TodoContext'; +import { useContext } from 'react'; + +type Props = { + filterStatus: Filter; + setFilterStatus: (status: Filter) => void; +}; + +export const Footer: React.FC = ({ filterStatus, setFilterStatus }) => { + const { todos, setTodos } = useContext(TodosContext); + + const handleCompletedDelete = () => { + const notCompletedTodos = todos.filter(todo => !todo.completed); + + setTodos(notCompletedTodos); + }; + + return ( +
+ + {todos.filter(todo => !todo.completed).length} items left + + + + + +
+ ); +}; diff --git a/src/components/Header/header.tsx b/src/components/Header/header.tsx new file mode 100644 index 000000000..cc3543ea7 --- /dev/null +++ b/src/components/Header/header.tsx @@ -0,0 +1,71 @@ +import React, { useContext, useEffect, useRef } from 'react'; +import { Todo } from '../../types/Todos'; +import classNames from 'classnames'; +import { TodosContext } from '../../TodoContext/TodoContext'; + +type Props = { + updateAll: () => void; +}; + +export const Header: React.FC = ({ updateAll }) => { + const inputField = useRef(null); + const { todos, setTodos } = useContext(TodosContext); + + useEffect(() => { + if (inputField.current) { + inputField.current.focus(); + } + }, [todos]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const inputValue = inputField.current?.value.trim(); + + if (!inputValue) { + return; + } + + if (inputField.current) { + inputField.current.value = ''; + } + + if (inputValue) { + const newTodo: Todo = { + id: +new Date(), + title: inputValue, + userId: 1139, + completed: false, + }; + + setTodos([...todos, newTodo]); + } + }; + + const allTodosCompleted = + todos.length > 0 && todos.every(todo => todo.completed); + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/TodoItem/todoItem.tsx b/src/components/TodoItem/todoItem.tsx new file mode 100644 index 000000000..9f81acbd7 --- /dev/null +++ b/src/components/TodoItem/todoItem.tsx @@ -0,0 +1,108 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ + +import cn from 'classnames'; +import React, { useContext } from 'react'; +import { TodosContext } from '../../TodoContext/TodoContext'; + +type Props = { + todoId: number; + isEditing: boolean; + editingValue: string; + setEditingValue: (value: string) => void; + onTodoStatusChange: (id: number) => void; + onTodoDelete: (id: number) => void; + onTodoDoubleClick: (id: number) => void; + onEditTitleBlur: () => void; + onEditTitleKeyUp: (event: React.KeyboardEvent) => void; + editingField: React.RefObject; +}; + +export const TodoItem: React.FC = ({ + todoId, + isEditing, + editingValue, + setEditingValue, + onTodoStatusChange, + onTodoDelete, + onTodoDoubleClick, + onEditTitleBlur, + onEditTitleKeyUp, + editingField, +}) => { + const { todos } = useContext(TodosContext); + const todo = todos.find(t => t.id === todoId); + + if (!todo) { + return null; + } + + const { id, title, completed } = todo; + + const handleEditTitle = (event: React.ChangeEvent) => { + setEditingValue(event.target.value); + }; + + return ( +
onTodoDoubleClick(id)} + > + {isEditing ? ( + <> + + + + ) : ( + <> + + + {title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoList/todoList.tsx b/src/components/TodoList/todoList.tsx new file mode 100644 index 000000000..2df7cf6b5 --- /dev/null +++ b/src/components/TodoList/todoList.tsx @@ -0,0 +1,69 @@ +import { useContext, useEffect, useRef } from 'react'; +import { Filter } from '../../types/Filter'; +import { TodosContext } from '../../TodoContext/TodoContext'; +import { TodoItem } from '../TodoItem/todoItem'; + +type Props = { + filterStatus: Filter; + editTodoId: number | null; + editingValue: string; + setEditingValue: (value: string) => void; + onTodoStatusChange: (id: number) => void; + onTodoDelete: (id: number) => void; + onTodoDoubleClick: (id: number) => void; + onEditTitleBlur: () => void; + onEditTitleKeyUp: (event: React.KeyboardEvent) => void; +}; + +export const TodoList: React.FC = ({ + filterStatus, + editTodoId, + editingValue, + setEditingValue, + onTodoStatusChange, + onTodoDelete, + onTodoDoubleClick, + onEditTitleBlur, + onEditTitleKeyUp, +}) => { + const { todos } = useContext(TodosContext); + const editingField = useRef(null); + + useEffect(() => { + if (editingField.current) { + editingField.current.focus(); + } + }, [editTodoId]); + + const filteredTodos = todos.filter(todo => { + switch (filterStatus) { + case Filter.Active: + return !todo.completed; + case Filter.Completed: + return todo.completed; + case Filter.All: + default: + return true; + } + }); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..d4069dfe6 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +export function useLocalStorage( + key: string, + startValue: T, +): [T, (v: T) => void] { + const [value, setValue] = useState(() => { + const data = localStorage.getItem(key); + + if (data === null) { + return startValue; + } + + try { + return JSON.parse(data); + } catch (e) { + localStorage.removeItem(key); + + return startValue; + } + }); + + const save = (newValue: T) => { + localStorage.setItem(key, JSON.stringify(newValue)); + setValue(newValue); + }; + + return [value, save]; +} diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..a8d19277d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,14 @@ import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import './styles/index.scss'; import { App } from './App'; +import { TodosProvider } from './TodoContext/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..924fac8f2 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,3 +1,9 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + .todoapp { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; 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/Todos.ts b/src/types/Todos.ts new file mode 100644 index 000000000..3f52a5fdd --- /dev/null +++ b/src/types/Todos.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +}