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..bbc2812ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,12 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { TodosContextProvider } from './components/TodosProvider'; +import { TodoApp } from './components/TodoApp'; +import './styles/index.scss'; 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 */} - -
-
+ + + ); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..e801d6353 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,62 @@ +// crud operations with localstorage for todos + +import { Todo } from '../components/TodosProvider'; + +const TODOS_STORAGE_NAME = 'todos'; + +function saveTodosToStorage(todos: Todo[]) { + if (todos.length === 0) { + localStorage.setItem(TODOS_STORAGE_NAME, '[]'); + } else { + localStorage.setItem(TODOS_STORAGE_NAME, JSON.stringify(todos)); + } +} + +export function getTodosFromStorage(): Todo[] { + const todosFromStorage = localStorage.getItem(TODOS_STORAGE_NAME); + + if (!todosFromStorage) { + localStorage.setItem(TODOS_STORAGE_NAME, '[]'); + } + + return todosFromStorage ? JSON.parse(todosFromStorage) : []; +} + +export function setTodosInStorage(todos: Todo[]) { + saveTodosToStorage(todos); +} + +export function addTodoToStorage(todo: Todo) { + const todosFromStorage = getTodosFromStorage(); + + todosFromStorage.push(todo); + + saveTodosToStorage(todosFromStorage); + + return todo; +} + +export function deleteTodoFromStorage(todoId: Todo['id']) { + const todosFromStorage = getTodosFromStorage(); + + const filteredTodosList = todosFromStorage.filter(todo => todo.id !== todoId); + + saveTodosToStorage(filteredTodosList); +} + +export function updateTodoInStorage( + todoId: Todo['id'], + payload: Partial>, +) { + const todosFromStorage = getTodosFromStorage(); + + const updatedTodosList = todosFromStorage.map(todo => { + if (todo.id === todoId) { + return { ...todo, ...payload }; + } + + return todo; + }); + + saveTodosToStorage(updatedTodosList); +} diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..c754a0cd8 --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,24 @@ +import { useContext } from 'react'; +import { TodoFooter } from './TodoFooter'; +import { TodoHeader } from './TodoHeader'; +import { TodoList } from './TodoList'; +import { TodosContext } from './TodosProvider'; +import '../styles/todoapp.scss'; + +export const TodoApp = () => { + const { todos } = useContext(TodosContext); + + return ( +
+

todos

+ +
+ + + + + {todos.length !== 0 && } +
+
+ ); +}; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 000000000..01c4aa823 --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,54 @@ +import { useContext } from 'react'; +import '../styles/filters.scss'; +import { TodoFilterStatus, TodosContext } from './TodosProvider'; +import classNames from 'classnames'; +import { checkSomeTodosCompleted } from '../utils'; + +export const TodoFooter = () => { + const { todos, setTodoFilterStatus, todoFilterStatus, deleteTodo } = + useContext(TodosContext); + + function handleClearAllCompletedTodos() { + todos.forEach(todo => { + if (todo.completed) { + deleteTodo(todo.id); + } + }); + } + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 000000000..54e02fa1a --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,67 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { TodosContext } from './TodosProvider'; +import classNames from 'classnames'; +import { checkAllTodosCompleted } from '../utils'; + +export const TodoHeader: React.FC = () => { + const [todoTitle, setTodoTitle] = useState(''); + const { todos, submitTodo, setTodos } = useContext(TodosContext); + + function handleTodoSubmit(event: React.FormEvent) { + event.preventDefault(); + if (todoTitle.trim() === '') { + return; + } + + setTodoTitle(''); + submitTodo({ + id: +new Date(), + title: todoTitle.trim(), + completed: false, + }); + } + + function toggleAllTodos() { + if (todos.every(todo => todo.completed)) { + setTodos(todos.map(todo => ({ ...todo, completed: false }))); + + return; + } + + setTodos(todos.map(todo => ({ ...todo, completed: true }))); + } + + const titleInput = useRef(null); + + useEffect(() => { + if (titleInput.current) { + titleInput.current.focus(); + } + }, [todos]); + + return ( +
+ {todos.length !== 0 && ( +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..613d74de4 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,118 @@ +/* eslint-disable max-len */ +/* eslint-disable prettier/prettier */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { useContext, useState } from 'react'; +import '../styles/todo-list.scss'; +import { Todo, TodoFilterStatus, TodosContext } from './TodosProvider'; +import classNames from 'classnames'; + +export const TodoList = () => { + const { todos, todoFilterStatus, deleteTodo, updateTodo } = + useContext(TodosContext); + const [selectedTodoId, setSelectedTodoId] = useState(null); + const [todoTitleInput, setTodoTitleInput] = useState(''); + + const visibleTodos = todos.filter(todo => { + switch (todoFilterStatus) { + case TodoFilterStatus.All: + return true; + case TodoFilterStatus.Active: + return !todo.completed; + case TodoFilterStatus.Completed: + return todo.completed; + } + }); + + function handleTitleUpdate( + todoId: Todo['id'], + event?: React.FormEvent, + ) { + event?.preventDefault(); + if (todoTitleInput.trim() === '') { + deleteTodo(todoId); + + return; + } + + updateTodo(todoId, { + title: todoTitleInput.trim(), + }); + setSelectedTodoId(null); + } + + function handleTodoSelect(todo: Todo) { + setSelectedTodoId(todo.id); + setTodoTitleInput(todo.title); + } + + function handleEscapeClick(event: React.KeyboardEvent) { + if (event.key === 'Escape') { + setSelectedTodoId(null); + } + } + + function handleUnfocusTodoTitle(todoId: Todo['id']) { + setSelectedTodoId(null); + handleTitleUpdate(todoId); + } + + return ( +
+ {visibleTodos.map(todo => ( +
+ + + {selectedTodoId !== todo.id && ( + <> + handleTodoSelect(todo)} + data-cy="TodoTitle" + className="todo__title" + > + {todo.title} + + + + )} + + {selectedTodoId === todo.id && ( +
handleTitleUpdate(todo.id, event)}> + handleEscapeClick(event)} + data-cy="TodoTitleField" + type="text" + className="todo__title-field" + placeholder="Empty todo will be deleted" + onChange={event => setTodoTitleInput(event.target.value)} + value={todoTitleInput} + onBlur={() => handleUnfocusTodoTitle(todo.id)} + /> +
+ )} +
+ ))} +
+ ); +}; diff --git a/src/components/TodosProvider.tsx b/src/components/TodosProvider.tsx new file mode 100644 index 000000000..b3ce9d685 --- /dev/null +++ b/src/components/TodosProvider.tsx @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/indent */ +import { createContext, useEffect, useReducer, useState } from 'react'; +import { + addTodoToStorage, + deleteTodoFromStorage, + getTodosFromStorage, + setTodosInStorage, + updateTodoInStorage, +} from '../api/todos'; + +export enum TodoFilterStatus { + 'All' = 'All', + 'Active' = 'Active', + 'Completed' = 'Completed', +} + +export type Todo = { + id: number; + title: string; + completed: boolean; +}; + +export type TodosContextType = { + todos: Todo[]; + todoFilterStatus: TodoFilterStatus; + setTodoFilterStatus: React.Dispatch>; + submitTodo: (todo: Todo) => Promise; + updateTodo: ( + todoId: number, + payload: Partial>, + ) => Promise; + deleteTodo: (todoId: number) => Promise; + setTodos: (todos: Todo[]) => Promise; +}; + +export type Action = + | { type: 'add_todo'; payload: Todo } + | { + type: 'update_todo'; + todoId: Todo['id']; + payload: Partial>; + } + | { type: 'delete_todo'; todoId: Todo['id'] } + | { type: 'set_todos'; payload: Todo[] } + | { type: 'toggle_todo'; todoId: Todo['id'] }; + +type Props = { + children: React.ReactNode; +}; + +function reducer(state: Todo[], action: Action): Todo[] { + switch (action.type) { + case 'set_todos': + return action.payload; + case 'add_todo': + return [...state, action.payload]; + case 'update_todo': + return state.map(todo => { + if (todo.id === action.todoId) { + return { ...todo, ...action.payload }; + } + + return todo; + }); + case 'delete_todo': + return state.filter(todo => todo.id !== action.todoId); + default: + return state; + } +} + +export const TodosContext = createContext({ + todos: [], + todoFilterStatus: TodoFilterStatus.All, + setTodoFilterStatus: () => {}, + deleteTodo: () => new Promise(() => {}), + submitTodo: () => new Promise(() => {}), + updateTodo: () => new Promise(() => {}), + setTodos: () => new Promise(() => {}), +}); + +export const TodosContextProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, []); + const [todoFilterStatus, setTodoFilterStatus] = useState( + TodoFilterStatus.All, + ); + + async function deleteTodo(todoId: Todo['id']) { + deleteTodoFromStorage(todoId); + + dispatch({ type: 'delete_todo', todoId }); + } + + async function submitTodo(todo: Todo) { + addTodoToStorage(todo); + + dispatch({ type: 'add_todo', payload: todo }); + } + + async function updateTodo( + todoId: Todo['id'], + payload: Partial>, + ) { + updateTodoInStorage(todoId, payload); + + dispatch({ type: 'update_todo', todoId, payload }); + } + + async function setTodos(todos: Todo[]) { + setTodosInStorage(todos); + + dispatch({ type: 'set_todos', payload: todos }); + } + + useEffect(() => { + const todos = getTodosFromStorage(); + + dispatch({ type: 'set_todos', payload: todos }); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..881ca6a43 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -13,6 +13,10 @@ 0 25px 50px 0 rgba(0, 0, 0, 0.1); } + * { + box-sizing: border-box; + } + &__title { font-size: 100px; font-weight: 100; @@ -48,6 +52,10 @@ color: #737373; } + &.hidden { + display: none; + } + &::before { content: '❯'; transform: translateY(2px) rotate(90deg); @@ -68,6 +76,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/utils.ts b/src/utils.ts new file mode 100644 index 000000000..f0f780379 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,17 @@ +import { Todo } from './components/TodosProvider'; + +export function checkAllTodosCompleted(todos: Todo[]) { + if (todos.length === 0) { + return false; + } + + return todos.every(todo => todo.completed); +} + +export function checkSomeTodosCompleted(todos: Todo[]) { + if (todos.length === 0) { + return false; + } + + return todos.some(todo => todo.completed); +}