diff --git a/README.md b/README.md index 903c876f9..666ee4843 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,27 @@ -# React ToDo App - -Implement a simple [TODO app](https://mate-academy.github.io/react_todo-app/) that functions as described below. - -> If you are unsure about how a feature should work, open the real TodoApp and observe its behavior. - -![todoapp](./description/todoapp.gif) - -1. Learn the markup in `App.tsx`. -2. Show only a field to create a new todo if there are no todos yet. -3. Use React Context to manage todos. -4. Each todo should have an `id` (you can use `+new Date()`), a `title`, and a `completed` status (`false` by default). -5. Save `todos` to `localStorage` using `JSON.stringify` after each change. -6. Display the number of not completed todos in `TodoApp`. -7. Implement filtering by status (`All`/`Active`/`Completed`). -8. Add the ability to delete a todo using the `x` button. -9. Implement the `clearCompleted` button (disabled if there are no completed todos). -10. Implement individual todo status toggling. -11. Implement the `toggleAll` checkbox (checked only when all todos are completed). -12. Enable inline editing for the `TodoItem`: - - Double-clicking on the todo title shows a text field instead of the title and `deleteButton`. - - Form submission saves changes (press `Enter` to save). - - Trim the saved text. - - Delete the todo if the title is empty. - - Save changes `onBlur`. - - Pressing `Escape` cancels editing (use `onKeyUp` and check if `event.key === 'Escape'`). - -![todoedit](./description/edittodo.gif) - -## Instructions - -- Install the Prettier Extension and use these [VSCode settings](https://mate-academy.github.io/fe-program/tools/vscode/settings.json) to enable format on save. -- 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. +# TODO App + +### Description + +- Implemented a simple TODOs App + +### Stack + +- HTML (BEM) +- CSS (Bulma) +- JS +- Typescript +- React +- ReactDOM + +### Tools + +- ESlint +- Prettier +- Cypress +- Mochawesome +- Babel + +### Demo links + +- [Demo](https://AndriiZakharenko.github.io/todo-app/) + diff --git a/src/App.tsx b/src/App.tsx index a399287bd..0fc63ca8b 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 { TodoProvider } from './Components/TodoContext'; +import { TodoApp } from './Components/TodoApp'; 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 */} -
- - 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/Components/Footer.tsx b/src/Components/Footer.tsx new file mode 100644 index 000000000..72b51443d --- /dev/null +++ b/src/Components/Footer.tsx @@ -0,0 +1,66 @@ +import React, { useContext } from 'react'; +import cn from 'classnames'; +import { ActionNames, FilterBy, TodoContext } from './TodoContext'; + +export const Footer: React.FC = () => { + const { todos, dispatch, handleFilterBy, filteredBy, originalTodos } = + useContext(TodoContext); + + const activeTodosCount = originalTodos.filter( + todo => todo.completed === false, + ).length; + const isDisabled = todos.some(todo => todo.completed); + + const handleFilter = ( + event: React.MouseEvent, + type: FilterBy, + ) => { + event.preventDefault(); + + handleFilterBy(type); + }; + + return ( + <> + {originalTodos.length > 0 && ( + + )} + + ); +}; diff --git a/src/Components/Header.tsx b/src/Components/Header.tsx new file mode 100644 index 000000000..fb0be0400 --- /dev/null +++ b/src/Components/Header.tsx @@ -0,0 +1,87 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; +import { ActionNames, TodoContext } from './TodoContext'; +import { Todo } from '../types/Todo'; + +export const Header: React.FC = ({}) => { + const { todos, dispatch, originalTodos } = useContext(TodoContext); + const [value, setValue] = useState(''); + + const handleOnChange = (event: React.ChangeEvent) => { + event.preventDefault(); + setValue(event.target.value); + }; + + const handleKeyDown = (event: React.FormEvent) => { + event.preventDefault(); + + if (value.trim() === '') { + return; + } + + const newTodo: Todo = { + id: 100, + completed: false, + title: value.trim(), + }; + + setValue(''); + + dispatch({ type: ActionNames.Add, payload: newTodo }); + }; + + const isCompleted = + todos.length && todos.some(todo => todo.completed === false); + + const isAllCompleted = originalTodos.every(todo => todo.completed); + + const inputRef = useRef(null); + + const focusInput = () => { + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + useEffect(() => { + if (isAllCompleted) { + focusInput(); + } + }, [isAllCompleted]); + + useEffect(() => { + focusInput(); + }, [todos.length]); + + return ( +
+ {originalTodos.length > 0 && ( +
+ ); +}; diff --git a/src/Components/TodoApp.tsx b/src/Components/TodoApp.tsx new file mode 100644 index 000000000..5ded2a6d1 --- /dev/null +++ b/src/Components/TodoApp.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Header } from './Header'; +import { Footer } from './Footer'; +import { TodoList } from './TodoList'; + +export const TodoApp: React.FC = () => { + return ( +
+

todos

+ +
+
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/src/Components/TodoContext.tsx b/src/Components/TodoContext.tsx new file mode 100644 index 000000000..815f0b6e4 --- /dev/null +++ b/src/Components/TodoContext.tsx @@ -0,0 +1,161 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import React, { + useReducer, + useMemo, + useState, + useCallback, + useEffect, +} from 'react'; +import { Todo } from '../types/Todo'; + +export enum FilterBy { + All = 'ALL', + Active = 'ACTIVE', + Completed = 'COMPLETED', +} + +export type FilterAction = FilterBy.All | FilterBy.Active | FilterBy.Completed; + +type TodoContextType = { + todos: Todo[]; + originalTodos: Todo[]; + dispatch: (action: Action) => void; + filteredBy: FilterBy; + handleFilterBy: (type: FilterBy) => void; +}; + +export const TodoContext = React.createContext({ + todos: [], + originalTodos: [], + dispatch: () => {}, + filteredBy: FilterBy.All, + handleFilterBy: () => {}, +}); + +export type Props = { + children: React.ReactNode; +}; + +export enum ActionNames { + Add = 'ADD', + Delete = 'DELETE', + Update = 'UPDATE', + ToggleCompleted = 'TOGGLE_COMPLETED', + ToggleAllCompleted = 'TOGGLE_ALL_COMPLETED', + ClearCompleted = 'CLEAR_COMPLETED', +} + +type T = { + type: ActionNames.ToggleCompleted; + payload: { id: number; completed: boolean }; +}; + +export type Action = + | { type: ActionNames.Add; payload: Todo } + | { type: ActionNames.Delete; payload: number } + | { type: ActionNames.Update; payload: { id: number; title: string } } + | T + | { type: ActionNames.ToggleAllCompleted; payload: Todo[] } + | { type: ActionNames.ClearCompleted }; + +function reducer(state: Todo[], action: Action) { + switch (action.type) { + case ActionNames.Add: + return [...state, action.payload]; + + case ActionNames.Delete: + return state.filter(todo => todo.id !== action.payload); + + case ActionNames.Update: + return state.map((todo: Todo) => { + if (todo.id === action.payload.id) { + return { + ...todo, + title: action.payload.title.trim(), + }; + } + + return todo; + }); + + case ActionNames.ToggleCompleted: + return state.map(todo => { + if (todo.id === action.payload.id) { + return { + ...todo, + completed: !todo.completed, + }; + } + + return todo; + }); + + case ActionNames.ToggleAllCompleted: + const completed = state.some(todo => todo.completed === false); + + return state.map(todo => { + return { + ...todo, + completed: completed, + }; + }); + + case ActionNames.ClearCompleted: + return state.filter(todo => todo.completed === false); + + default: + return state; + } +} + +function filter(todos: Todo[], type: FilterAction) { + switch (type) { + case FilterBy.Active: + return todos.filter(todo => todo.completed === false); + + case FilterBy.Completed: + return todos.filter(todo => todo.completed === true); + + case FilterBy.All: + default: + return todos; + } +} + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, dispatch] = useReducer(reducer, []); + const [filteredBy, setFilteredBy] = useState(FilterBy.All); + + const handleFilterBy = useCallback((type: FilterBy) => { + setFilteredBy(type); + }, []); + + const value = useMemo( + () => ({ + todos: filter(todos, filteredBy), + originalTodos: todos, + dispatch, + filteredBy, + handleFilterBy, + }), + [dispatch, handleFilterBy, filteredBy, todos], + ); + + useEffect(() => { + const todos = JSON.parse(localStorage.getItem('todos') as string); + + if (todos) { + todos.forEach((todo: Todo) => { + dispatch({ type: ActionNames.Add, payload: todo }); + }); + } + }, []); + + useEffect(() => { + localStorage.clear(); + + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + return {children}; +}; diff --git a/src/Components/TodoItem.tsx b/src/Components/TodoItem.tsx new file mode 100644 index 000000000..aa5923dbb --- /dev/null +++ b/src/Components/TodoItem.tsx @@ -0,0 +1,114 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import React, { useState } from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; +import { Action, ActionNames } from './TodoContext'; + +type Props = { + todo: Todo; + dispatch: (action: Action) => void; +}; + +export const TodoItem: React.FC = ({ todo, dispatch }) => { + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(todo.title); + + const handleEditInput = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (inputValue.trim() === '') { + dispatch({ type: ActionNames.Delete, payload: todo.id }); + } else { + dispatch({ + type: ActionNames.Update, + payload: { id: todo.id, title: inputValue }, + }); + } + + setIsEditing(false); + }; + + const handleEscape = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setInputValue(todo.title); + } + }; + + const handleBlur = () => { + setIsEditing(false); + if (inputValue.trim() === '') { + dispatch({ + type: ActionNames.Delete, + payload: todo.id, + }); + } else if (inputValue) { + dispatch({ + type: ActionNames.Update, + payload: { id: todo.id, title: inputValue }, + }); + } + }; + + return ( +
setIsEditing(true)} + > + {/* eslint-disable jsx-a11y/label-has-associated-control */} + + {/* eslint-disable jsx-a11y/label-has-associated-control */} + {isEditing ? ( +
setIsEditing(false)}> + handleBlur()} + onKeyUp={handleEscape} + /> +
+ ) : ( + <> + + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/Components/TodoList.tsx b/src/Components/TodoList.tsx new file mode 100644 index 000000000..3e5701aba --- /dev/null +++ b/src/Components/TodoList.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { useContext } from 'react'; +import { TodoContext } from './TodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, dispatch } = useContext(TodoContext); + + return ( + <> + {todos.map(todo => ( + + ))} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..f529ee72c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,10 +1,6 @@ import { createRoot } from 'react-dom/client'; - -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; - import { App } from './App'; +import './styles/index.scss'; const container = document.getElementById('root') as HTMLDivElement; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..7379d6019 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; @@ -78,6 +79,24 @@ } } + &__edit-todo { + width: 100%; + box-sizing: border-box; + padding: 16px; + + font-size: 24px; + line-height: 1.4em; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + border: none; + background: rgba(0, 0, 0, 0.01); + box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); + } + &__main { border-top: 1px solid #e6e6e6; } 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; +};