diff --git a/README.md b/README.md index c5078685e..f87d3566d 100644 --- a/README.md +++ b/README.md @@ -53,4 +53,4 @@ Implement a simple [TODO app](http://todomvc.com/examples/vanillajs/) working as - Implement a solution following the [React task guideline](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 one more 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://koros-rk.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 20e932bab..7619261b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,93 +1,47 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useState } from 'react'; + +import { Todo } from './types/Todo'; +import { FilterBy } from './types/FilterBy'; +import { StateContext } from './states/TodosContext'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoFooter } from './components/TodoFooter'; +import { TodoList } from './components/TodoList'; + +const prepareTodos = (todos: Todo[], filterBy: FilterBy): Todo[] => { + return todos.filter((todo) => { + switch (filterBy) { + case FilterBy.Completed: + return todo.completed; + case FilterBy.Active: + return !todo.completed; + default: + return true; + } + }) + .sort((a, b) => a.id - b.id); +}; export const App: React.FC = () => { + const { todos } = useContext(StateContext); + const [filterBy, setFilterBy] = useState(FilterBy.All); + const preparedTodos = prepareTodos(todos, filterBy); + return (
-
-

todos

+ -
- + - -
- -
- - - -
    -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • -
-
- -
- - 3 items left - - - - - -
+ + + )}
); }; diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 000000000..2d77f5c72 --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,92 @@ +import React, { useCallback, useContext, useMemo } from 'react'; +import classNames from 'classnames'; +import { DispatchContext, StateContext } from '../states/TodosContext'; +import { FilterBy } from '../types/FilterBy'; + +interface Props { + selectedFilter: FilterBy, + onFilterSelected: (value: FilterBy) => void, +} + +export const TodoFooter: React.FC = React.memo(({ + selectedFilter, + onFilterSelected, +}) => { + const { todos } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + const isCompletedExists = useMemo( + () => todos.some((todo) => todo.completed), + [todos], + ); + + const getActiveCount = useCallback(() => { + return todos.reduce((acc, todo) => { + return !todo.completed ? acc + 1 : acc; + }, 0); + }, [todos]); + const clearCompleted = () => { + const completedIds = todos.reduce((acc, todo) => { + return todo.completed ? [...acc, todo.id] : acc; + }, [] as number[]); + + completedIds.forEach((todoId) => { + dispatch({ type: 'remove', payload: { id: todoId } }); + }); + }; + + return ( + + ); +}); diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 000000000..1d49b6976 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,41 @@ +import React, { useCallback, useContext, useState } from 'react'; +import { DispatchContext } from '../states/TodosContext'; + +export const TodoHeader: React.FC = React.memo(() => { + const dispatch = useContext(DispatchContext); + const [todoContent, setTodoContent] = useState(''); + + const handleSubmit = useCallback((event: React.FormEvent) => { + event.preventDefault(); + + if (todoContent !== '') { + dispatch({ + type: 'add', + payload: { + id: +new Date(), + title: todoContent, + completed: false, + }, + }); + } + + setTodoContent(''); + }, [dispatch, todoContent]); + + return ( +
+

todos

+ +
+ setTodoContent(event.target.value)} + /> +
+
+ ); +}); diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..070916a1f --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,122 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { + useCallback, + useContext, useEffect, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; + +import { DispatchContext } from '../states/TodosContext'; +import { Todo } from '../types/Todo'; + +interface Props { + todo: Todo +} + +export const TodoItem: React.FC = ({ todo }) => { + const dispatch = useContext(DispatchContext); + const { id, title, completed } = todo; + const [newContent, setNewContent] = useState(title); + const [isEditing, setIsEditing] = useState(false); + const editInput = useRef(null); + + const toggleTodo = useCallback((todoId: number) => { + dispatch({ + type: 'toggleCheck', + payload: { id: todoId }, + }); + }, [dispatch]); + + const deleteTodo = useCallback((todoId: number) => { + dispatch({ + type: 'remove', + payload: { id: todoId }, + }); + }, [dispatch]); + + const handleSubmit = useCallback(() => { + if (newContent !== '') { + dispatch({ + type: 'update', + payload: { + id, + content: newContent, + }, + }); + } + + if (newContent === '') { + dispatch({ + type: 'remove', + payload: { + id, + }, + }); + } + + setIsEditing(false); + }, [dispatch, id, newContent]); + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setNewContent(title); + setIsEditing(false); + } + + if (event.key === 'Enter') { + handleSubmit(); + } + }; + + document.addEventListener('keyup', handleEscape); + + return () => { + document.removeEventListener('keyup', handleEscape); + }; + }, [handleSubmit, title]); + + useEffect(() => { + if (editInput.current) { + editInput.current.focus(); + } + }, [isEditing]); + + return ( +
  • +
    + toggleTodo(id)} + checked={completed} + /> + +
    + setNewContent(event.target.value)} + onBlur={handleSubmit} + /> +
  • + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..cd69cdf31 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,44 @@ +import React, { useCallback, useContext, useState } from 'react'; + +import { DispatchContext } from '../states/TodosContext'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +interface Props { + todos: Todo[], +} + +export const TodoList: React.FC = React.memo(({ todos }) => { + const dispatch = useContext(DispatchContext); + const [isEnabledAll, setIsEnabledAll] = useState(true); + + const toggleAll = useCallback(() => { + setIsEnabledAll(prevState => !prevState); + + dispatch({ + type: 'toggleAll', + payload: { type: isEnabledAll }, + }); + }, [dispatch, isEnabledAll]); + + return ( +
    + + + +
      + { + todos.map((todo) => ( + + )) + } +
    +
    + ); +}); diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..e7e1d6753 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,24 @@ +import React, { useEffect, useState } from 'react'; + +const getValue = (key: string, initialState: T): T => { + const storage = localStorage.getItem(key); + + if (storage) { + return JSON.parse(storage); + } + + return initialState; +}; + +export function useLocalStorage( + key: string, + initialValue: T, +): [T, React.Dispatch] { + const [value, setValue] = useState(getValue(key, initialValue)); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + return [value, setValue]; +} diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..4a6490943 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,7 +5,12 @@ import './styles/todo-list.css'; import './styles/filters.css'; import { App } from './App'; +import { TodosProvider } from './states/TodosContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/states/TodosContext.tsx b/src/states/TodosContext.tsx new file mode 100644 index 000000000..0ff8cb87f --- /dev/null +++ b/src/states/TodosContext.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useReducer } from 'react'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { Todo } from '../types/Todo'; + +type Action + = { type: 'add', payload: Todo } + | { type: 'update', payload: { id: number, content: string } } + | { type: 'remove', payload: { id: number } } + | { type: 'toggleCheck', payload: { id: number } } + | { type: 'toggleAll', payload: { type: boolean } }; + +interface State { + todos: Todo[] +} + +interface Props { + children: React.ReactNode, +} + +const reducer = ({ todos }: State, { type, payload }: Action): State => { + switch (type) { + case 'add': + return { todos: [...todos, payload] }; + case 'update': + return { + todos: todos.map((todo) => { + if (todo.id === payload.id) { + return { ...todo, title: payload.content }; + } + + return todo; + }), + }; + case 'remove': + return { + todos: todos.filter((todo) => todo.id !== payload.id), + }; + case 'toggleCheck': + return { + todos: todos.map((todo) => { + if (todo.id === payload.id) { + return { ...todo, completed: !todo.completed }; + } + + return todo; + }), + }; + case 'toggleAll': + return { + todos: todos.map((todo) => { + return { + ...todo, + completed: payload.type, + }; + }), + }; + default: + return { todos }; + } +}; + +const initialState: State = { + todos: [], +}; + +export const DispatchContext + = React.createContext((_action: Action) => {}); // eslint-disable-line +export const StateContext + = React.createContext(initialState); + +export const TodosProvider: React.FC = ({ children }) => { + // eslint-disable-next-line max-len + const [storedTodos, setStoredTodos] = useLocalStorage('todos', initialState.todos); + const [state, dispatch] = useReducer(reducer, { todos: storedTodos }); + + useEffect(() => { + setStoredTodos(state.todos); + }, [setStoredTodos, state]); + + return ( + + + {children} + + + ); +}; diff --git a/src/types/FilterBy.ts b/src/types/FilterBy.ts new file mode 100644 index 000000000..23d46b28d --- /dev/null +++ b/src/types/FilterBy.ts @@ -0,0 +1,5 @@ +export enum FilterBy { + All, + Active, + Completed, +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..d956daef0 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export type Todo = { + id: number + title: string, + completed: boolean, +};