diff --git a/package-lock.json b/package-lock.json index 0adcc869f..1f19b4743 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1170,9 +1170,9 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, "dependencies": { "@octokit/rest": "^17.11.2", diff --git a/package.json b/package.json index e6134ce84..91d7489b9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.tsx b/src/App.tsx index a399287bd..6481b2da2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,25 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useContext } from 'react'; +import { Header } from './components/Header/Header'; +import { Footer } from './components/Footer/Footer'; +import { TodoList } from './components/TodoList/TodoList'; +import { TodosContext } from './components/TodoAppContext/TodoAppContext'; export const App: React.FC = () => { + const { todos } = useContext(TodosContext); + + // console.log(todos.length); + 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/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..7ae0ac7dd --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,74 @@ +import { useContext } from 'react'; +import { + TodosContext, + TodosDispatchContext, +} from '../TodoAppContext/TodoAppContext'; +import classNames from 'classnames'; +import { TodoStatus } from '../../types/TodoStatus'; + +export const Footer: React.FC = () => { + const { todos, filterStatus } = useContext(TodosContext); + const dispatch = useContext(TodosDispatchContext); + const uncomletedTodos = todos.filter(todo => !todo.completed); + + return ( + + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..11c03e3fc --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,73 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { + TodosContext, + TodosDispatchContext, +} from '../TodoAppContext/TodoAppContext'; +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; + +export const Header: React.FC = () => { + const [title, setTitle] = useState(''); + const dispatch = useContext(TodosDispatchContext); + const { todos } = useContext(TodosContext); + const [isSubmitting, setIsSubmitting] = useState(false); + const completedTodosLength = todos.filter(el => el.completed).length; + const field = useRef(null); + + useEffect(() => { + field.current?.focus(); + }, [isSubmitting, todos]); + + const handleAdd = (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + if (!title.trim()) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: title.trim(), + completed: false, + }; + + dispatch({ + type: 'addTodo', + payload: newTodo, + }); + setTitle(''); + setIsSubmitting(false); + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length !== 0 && ( +
+ ); +}; diff --git a/src/components/TodoAppContext/TodoAppContext.tsx b/src/components/TodoAppContext/TodoAppContext.tsx new file mode 100644 index 000000000..d85d4b660 --- /dev/null +++ b/src/components/TodoAppContext/TodoAppContext.tsx @@ -0,0 +1,103 @@ +import { createContext, useEffect, useReducer } from 'react'; +import { TodoStatus } from '../../types/TodoStatus'; +import { Todo } from '../../types/Todo'; + +type Props = { + children: React.ReactNode; +}; + +export type Action = + | { type: 'addTodo'; payload: Todo } + | { type: 'deleteTodo'; payload: number } + | { type: 'deleteCompletedTodos' } + | { type: 'filterBy'; payload: TodoStatus } + | { type: 'updateStatusTodo'; payload: Todo } + | { type: 'toggleAllStatuses' } + | { type: 'editTitle'; payload: number; title: string }; + +const data = localStorage.getItem('todos'); +const initialTodos: Todo[] = JSON.parse(data as string) || []; + +const initialState = { + todos: initialTodos, + filterStatus: TodoStatus.All, +}; + +type RootState = typeof initialState; + +export const TodosContext = createContext(initialState); + +export const TodosDispatchContext = createContext>( + () => {}, +); + +const reducer = (state: RootState, action: Action) => { + switch (action.type) { + case 'addTodo': + return { ...state, todos: [...state.todos, action.payload] }; + case 'filterBy': + return { ...state, filterStatus: action.payload }; + case 'deleteTodo': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload) || [], + }; + case 'deleteCompletedTodos': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed) || [], + }; + case 'updateStatusTodo': + return { + ...state, + todos: + state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, completed: !action.payload.completed } + : todo, + ) || [], + }; + case 'toggleAllStatuses': + const complTodosLength = + state.todos.filter(el => el.completed).length || 0; + const completedToValue = + state.todos.length === complTodosLength ? false : true; + + return { + ...state, + todos: + state.todos.map(todo => { + return { ...todo, completed: completedToValue }; + }) || [], + }; + case 'editTitle': + return { + ...state, + todos: + state.todos.map(todo => + todo.id === action.payload + ? { ...todo, title: action.title } + : todo, + ) || [], + }; + default: { + throw Error('Unknown action'); + } + } +}; + +export const TodosProvider: React.FC = ({ children }) => { + const [todoState, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todoState.todos)); + }, [todoState]); + + return ( + + + {children} + + + ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..f623c48c8 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,100 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; +import { useContext, useEffect, useRef, useState } from 'react'; +import { TodosDispatchContext } from '../TodoAppContext/TodoAppContext'; +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const dispatch = useContext(TodosDispatchContext); + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(todo.title); + const field = useRef(null); + + useEffect(() => { + field.current?.focus(); + }, [isEditing]); + + const handleEditClose = (e: React.FormEvent) => { + e.preventDefault(); + + if (!title.trim()) { + dispatch({ type: 'deleteTodo', payload: todo.id }); + setIsEditing(false); + + return; + } + + if (todo.title === title) { + setIsEditing(false); + + return; + } + + dispatch({ type: 'editTitle', payload: todo.id, title: title.trim() }); + setTitle(title.trim()); + setIsEditing(false); + }; + + const handleKeyChange = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditing(false); + } + }; + + return ( +
+ + + {!isEditing ? ( + <> + setIsEditing(true)} + > + {todo.title} + + + + ) : ( +
+ setTitle(e.target.value)} + onBlur={handleEditClose} + onKeyUp={handleKeyChange} + /> +
+ )} +
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..21179236f --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,32 @@ +import { useContext } from 'react'; +import { TodosContext } from '../TodoAppContext/TodoAppContext'; +import { TodoStatus } from '../../types/TodoStatus'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { Todo } from '../../types/Todo'; + +function getVisibleTodos(todos: Todo[], status: TodoStatus) { + const copyTodos = [...todos]; + + if (status === TodoStatus.Active) { + return copyTodos.filter(todo => !todo.completed); + } + + if (status === TodoStatus.Completed) { + return copyTodos.filter(todo => todo.completed); + } + + return copyTodos; +} + +export const TodoList: React.FC = () => { + const { todos, filterStatus } = useContext(TodosContext); + + const visibleTodos = getVisibleTodos(todos, filterStatus); + + return ( +
+ {visibleTodos.length !== 0 && + visibleTodos.map(todo => )} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..d305e3c1e 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 './components/TodoAppContext/TodoAppContext'; 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..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..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..207c27648 --- /dev/null +++ b/src/types/TodoStatus.ts @@ -0,0 +1,5 @@ +export enum TodoStatus { + All = 'All', + Active = 'Active', + Completed = 'Completed', +}