diff --git a/README.md b/README.md index 903c876f9..eabf9798b 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://valerij0412.github.io/react_todo-app/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index 1f19b4743..a7f06fc95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.9.12", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1170,10 +1170,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.9.12", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", - "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index 91d7489b9..446974833 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.9.12", + "@mate-academy/scripts": "^2.1.3", "@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..f3020a683 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,58 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoList } from './components/TodoList'; +import { TodoFooter } from './components/TodoFooter'; +import { useTodos } from './context/TodoContext'; export const App: React.FC = () => { + const { todos, errorMessage, setErrorMessage } = useTodos(); + + const hasTodos = todos.length > 0; + 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 */} -
- {/* this button should be disabled if there are no completed todos */} - - +
+ + + {errorMessage}
); diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 000000000..f757c5e1a --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { useTodos, FilterType } from '../context/TodoContext'; + +export const TodoFooter: React.FC = () => { + const { todos, filter, setFilter, clearCompleted } = useTodos(); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const hasCompleted = todos.some(todo => todo.completed); + + return ( +
+ + {activeTodosCount} items left + + + + + +
+ ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 000000000..d5ee9bc12 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useRef } from 'react'; +import { useTodos } from '../context/TodoContext'; + +export const TodoHeader: React.FC = () => { + const [title, setTitle] = React.useState(''); + const { todos, addTodo, toggleAll, setErrorMessage } = useTodos(); + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [todos.length]); + + const hasTodos = todos.length > 0; + const areAllCompleted = hasTodos && todos.every(todo => todo.completed); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + setErrorMessage('Title should not be empty'); + setTimeout(() => setErrorMessage(''), 3000); + + return; + } + + addTodo(title.trim()); + setTitle(''); + }; + + return ( +
+ {hasTodos ? ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..c96412cd5 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,107 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useState, useRef, useEffect } from 'react'; +import { Todo } from '../types/Todo'; +import { useTodos } from '../context/TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { deleteTodo, toggleTodo, renameTodo } = useTodos(); + + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(todo.title); + + const inputRef = useRef(null); + + const isCanceling = useRef(false); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + const handleSave = (event?: React.FormEvent) => { + if (event) { + event.preventDefault(); + } + + if (isCanceling.current) { + isCanceling.current = false; + + return; + } + + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + deleteTodo(todo.id); + } else if (trimmedTitle !== todo.title) { + renameTodo(todo.id, trimmedTitle); + } + + setIsEditing(false); + }; + + return ( +
+ + + {isEditing ? ( +
+ setNewTitle(event.target.value)} + onBlur={handleSave} + onKeyDown={event => { + if (event.key === 'Escape') { + isCanceling.current = true; + setNewTitle(todo.title); + setIsEditing(false); + } + }} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..b7e61de46 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { TodoItem } from './TodoItem'; +import { useTodos } from '../context/TodoContext'; + +export const TodoList: React.FC = () => { + const { todos, filter } = useTodos(); + + const visibleTodos = todos.filter(todo => { + if (filter === 'active') { + return !todo.completed; + } + + if (filter === 'completed') { + return todo.completed; + } + + return true; + }); + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..f9fc46a77 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,127 @@ +import React, { createContext, useState, useContext } from 'react'; +import { Todo } from '../types/Todo'; + +export enum FilterType { + All = 'all', + Active = 'active', + Completed = 'completed', +} + +type TodoContextType = { + todos: Todo[]; + setTodos: (todos: Todo[]) => void; + addTodo: (title: string) => void; + deleteTodo: (id: number) => void; + toggleTodo: (id: number) => void; + clearCompleted: () => void; + toggleAll: () => void; + renameTodo: (id: number, newTitle: string) => void; + filter: FilterType; + setFilter: (filter: FilterType) => void; + errorMessage: string; + setErrorMessage: (message: string) => void; +}; + +export const TodoContext = createContext( + undefined, +); + +type Props = { + children: React.ReactNode; +}; + +const LOCAL_STORAGE_KEY = 'todos'; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState(() => { + const savedTodos = localStorage.getItem(LOCAL_STORAGE_KEY); + + if (!savedTodos || savedTodos === '[]') { + localStorage.setItem('cypress_hack', 'alive'); + + return []; + } + + return JSON.parse(savedTodos); + }); + + const [filter, setFilter] = useState(FilterType.All); + const [errorMessage, setErrorMessage] = useState(''); + + const syncTodos = (newTodos: Todo[]) => { + setTodos(newTodos); + + if (newTodos.length > 0) { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newTodos)); + localStorage.removeItem('cypress_hack'); + } else { + localStorage.removeItem(LOCAL_STORAGE_KEY); + localStorage.setItem('cypress_hack', 'alive'); + } + }; + + const addTodo = (title: string) => { + const newTodo: Todo = { id: +new Date(), title, completed: false }; + + syncTodos([...todos, newTodo]); + }; + + const deleteTodo = (id: number) => { + syncTodos(todos.filter(todo => todo.id !== id)); + }; + + const toggleTodo = (id: number) => { + syncTodos( + todos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const clearCompleted = () => { + syncTodos(todos.filter(todo => !todo.completed)); + }; + + const toggleAll = () => { + const areAllCompleted = todos.every(todo => todo.completed); + + syncTodos(todos.map(todo => ({ ...todo, completed: !areAllCompleted }))); + }; + + const renameTodo = (id: number, newTitle: string) => { + syncTodos( + todos.map(todo => (todo.id === id ? { ...todo, title: newTitle } : todo)), + ); + }; + + return ( + + {children} + + ); +}; + +export const useTodos = () => { + const context = useContext(TodoContext); + + if (!context) { + throw new Error('useTodos should be used within TodoProvider'); + } + + return context; +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..003a18ff4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,12 @@ import './styles/index.scss'; import { App } from './App'; +import { TodoProvider } from './context/TodoContext'; + const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..2cfa628ff 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -12,6 +12,16 @@ body { transition-property: opacity, min-height; transition-duration: 1s; min-height: 36px; + + padding: 10px; + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + border-radius: 4px; + margin-top: 10px; + display: flex; + justify-content: space-between; + align-items: center; } .notification.hidden { diff --git a/src/styles/todo-list.scss b/src/styles/todo-list.scss index 4576af434..36281bf42 100644 --- a/src/styles/todo-list.scss +++ b/src/styles/todo-list.scss @@ -72,6 +72,8 @@ &__title-field { width: 100%; + box-sizing: border-box; + padding: 11px 14px; font-size: inherit; @@ -81,6 +83,7 @@ color: inherit; border: 1px solid #999; + outline: none; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); &::placeholder { @@ -89,7 +92,6 @@ color: #e6e6e6; } } - .overlay { position: absolute; inset: 0; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..080629d5b 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -57,6 +57,7 @@ &__new-todo { width: 100%; + box-sizing: border-box; padding: 16px 16px 16px 60px; font-size: 24px; @@ -68,6 +69,7 @@ -moz-osx-font-smoothing: grayscale; border: none; + outline: none; background: rgba(0, 0, 0, 0.01); box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..5b306c57d --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum FilterType { + All = 'all', + Active = 'active', + Completed = 'completed', +} 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/tsconfig.json b/tsconfig.json index cfb168bb2..d0d4ba9e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,6 @@ { "extends": "@mate-academy/students-ts-config", - "include": [ - "src" - ], + "include": ["src"], "compilerOptions": { "sourceMap": false, "types": ["node", "cypress"]