diff --git a/README.md b/README.md index c5078685e..85bbd872e 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://Marinakyrychynska.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 20e932bab..a44b00f1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,93 +1,18 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext } from 'react'; +import { Header } from './Components/Header/Header'; +import { Main } from './Components/Main/Main'; +import { TodoContext } from './Data/Store'; +import { Footer } from './Components/Footer/Footer'; export const App: React.FC = () => { + const { todos } = useContext(TodoContext); + return (
-
-

todos

- -
- -
-
- -
- - - -
    -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • - -
  • -
    - - -
    - -
  • -
-
- -
- - 3 items left - - - - - -
+
+
+ {todos !== null && todos.length !== 0 &&
}
); }; diff --git a/src/Components/Footer/Footer.tsx b/src/Components/Footer/Footer.tsx new file mode 100644 index 000000000..9103b8ed8 --- /dev/null +++ b/src/Components/Footer/Footer.tsx @@ -0,0 +1,75 @@ +import React, { useContext, useEffect } from 'react'; +import { DispatchContext, TodoContext } from '../../Data/Store'; + +export const Footer: React.FC = () => { + const { + todos, + filterActive, + selectedAll, + selectedCompleted, + } = useContext(TodoContext); + + const dispatch = useContext(DispatchContext); + const todosNotCompleted = todos.filter(todo => todo.completed === false); + const numberItemsLeft = todosNotCompleted.length; + + useEffect(() => { + dispatch({ type: 'selectedAll' }); + }, [dispatch]); + + return ( + + ); +}; diff --git a/src/Components/Header/Header.tsx b/src/Components/Header/Header.tsx new file mode 100644 index 000000000..949b8eba6 --- /dev/null +++ b/src/Components/Header/Header.tsx @@ -0,0 +1,50 @@ +import React, { useContext } from 'react'; +import { DispatchContext, TodoContext } from '../../Data/Store'; + +export const Header: React.FC = () => { + const { value } = useContext(TodoContext); + const dispatch = useContext(DispatchContext); + + const addTodo = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + const todoTitle = event.currentTarget.value.trim(); + + if (todoTitle) { + dispatch({ + type: 'addTodo', + payLoad: event.currentTarget.value, + }); + + dispatch({ + type: 'changeValue', + payLoad: '', + }); + } + } + }; + + const changeValue = (event: React.ChangeEvent) => { + dispatch({ + type: 'changeValue', + payLoad: event.target.value, + }); + }; + + return ( +
+

todos

+ +
event.preventDefault()}> + +
+
+ ); +}; diff --git a/src/Components/Main/Main.tsx b/src/Components/Main/Main.tsx new file mode 100644 index 000000000..38249f6c1 --- /dev/null +++ b/src/Components/Main/Main.tsx @@ -0,0 +1,61 @@ +import React, { useContext } from 'react'; +import { DispatchContext, TodoContext } from '../../Data/Store'; +import { Todo } from '../../Types/Todo'; +import { TodoList } from '../TodoList/TodoList'; + +export const Main: React.FC = () => { + const { + todos, + todosCompleted, + filterActive, + selectedCompleted, + } = useContext(TodoContext); + + const dispatch = useContext(DispatchContext); + + const handleChange = () => { + dispatch({ type: 'toggleCompleted' }); + + if (!todosCompleted) { + dispatch({ type: 'changeTotoCompletedTrue' }); + } + + if (todosCompleted) { + dispatch({ type: 'changeTodoCompletedFalse' }); + } + }; + + const filterTodos = (todoItems: Todo[]) => { + if (filterActive) { + const activeTodos = todoItems.filter(todo => todo.completed === false); + + return activeTodos; + } + + if (selectedCompleted) { + const completedTodos = todoItems.filter(todo => todo.completed === true); + + return completedTodos; + } + + return todoItems; + }; + + return ( +
+ + + + +
+ ); +}; diff --git a/src/Components/TodoItem/TodoItem.tsx b/src/Components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..01f4652a8 --- /dev/null +++ b/src/Components/TodoItem/TodoItem.tsx @@ -0,0 +1,95 @@ +import React, { useContext, useRef, useEffect } from 'react'; +import { Todo } from '../../Types/Todo'; +import { DispatchContext, TodoContext } from '../../Data/Store'; + +interface Props { + title: string, + completed: boolean, + todo: Todo, +} + +export const TodoItem: React.FC = ({ title, completed, todo }) => { + const dispatch = useContext(DispatchContext); + const { edit } = useContext(TodoContext); + + const titleField = useRef(null); + const pressedEnter = (key: string) => key === 'Enter'; + + const handleChange = () => { + dispatch({ + type: 'todoCompleted', + payLoad: todo, + }); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (pressedEnter(event.key) && !event.currentTarget.value) { + dispatch({ type: 'removeTodo', payLoad: todo }); + } + + if (pressedEnter(event.key)) { + dispatch({ + type: 'newTitleTodo', + payLoad: todo, + newTitle: event.currentTarget.value, + }); + } + + if (event.key === 'Escape') { + dispatch({ type: 'removeEdit' }); + } + }; + + useEffect(() => { + if (edit) { + titleField.current?.focus(); + } + }, [edit]); + + return ( +
  • +
    + + + + +
    + { + dispatch({ + type: 'newTitleTodo', + payLoad: todo, + newTitle: title, + }); + }} + /> +
  • + ); +}; diff --git a/src/Components/TodoList/TodoList.tsx b/src/Components/TodoList/TodoList.tsx new file mode 100644 index 000000000..fb9954103 --- /dev/null +++ b/src/Components/TodoList/TodoList.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Todo } from '../../Types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +interface Props { + items: Todo[]; +} + +export const TodoList: React.FC = ({ items }) => { + return ( +
      + {items.map(item => { + const { id, title, completed }: Todo = item; + + return ( + + ); + })} +
    + ); +}; diff --git a/src/Data/Store.tsx b/src/Data/Store.tsx new file mode 100644 index 000000000..a5e5b4d7f --- /dev/null +++ b/src/Data/Store.tsx @@ -0,0 +1,225 @@ +import React, { useEffect, useReducer } from 'react'; +import { Todo } from '../Types/Todo'; + +export const todos: Todo[] = []; + +interface State { + todos: Todo[], + value: string, + todosCompleted: boolean, + filterActive: boolean, + selectedCompleted: boolean, + selectedAll: boolean, + edit: number, +} + +type Action = { type: 'addTodo', payLoad: string } +| { type: 'changeValue', payLoad: string } +| { type: 'toggleCompleted' } +| { type: 'filterActive' } +| { type: 'selectedCompleted' } +| { type: 'changeTotoCompletedTrue' } +| { type: 'changeTodoCompletedFalse' } +| { type: 'selectedAll' } +| { type: 'todoCompleted', payLoad: Todo } +| { type: 'removeTodo', payLoad: Todo } +| { type: 'edit', payLoad: Todo } +| { type: 'newTitleTodo', payLoad: Todo, newTitle: string } +| { type: 'removeEdit' } +| { type: 'clearCompleted' }; + +export function reducer(state: State, action: Action): State { + switch (action.type) { + case 'addTodo': + return { + ...state, + todos: [ + ...state.todos, + { + id: +new Date(), + title: action.payLoad, + completed: false, + }, + ], + }; + + case 'changeValue': + return { + ...state, + value: action.payLoad, + }; + + case 'toggleCompleted': + return { + ...state, + todosCompleted: !state.todosCompleted, + }; + + case 'changeTotoCompletedTrue': { + const newTodos = state.todos.map(todo => { + return { ...todo, completed: true }; + }); + + return { + ...state, + todos: newTodos, + }; + } + + case 'changeTodoCompletedFalse': { + const newTodos = state.todos.map(todo => { + return { ...todo, completed: false }; + }); + + return { + ...state, + todos: newTodos, + }; + } + + case 'todoCompleted': { + const changeTodo = state.todos.map(todo => { + if (todo.id === action.payLoad.id) { + return { ...todo, completed: !todo.completed }; + } + + return todo; + }); + + return { + ...state, + todos: [...changeTodo], + }; + } + + case 'removeTodo': { + const copyTodos = [...state.todos]; + const index = copyTodos.indexOf(action.payLoad); + + copyTodos.splice(index, 1); + + return { + ...state, + todos: [...copyTodos], + }; + } + + case 'edit': { + return { + ...state, + edit: action.payLoad.id, + }; + } + + case 'newTitleTodo': { + const changeTitle = state.todos.map(todo => { + if (todo.id === action.payLoad.id) { + return { ...todo, title: action.newTitle }; + } + + return todo; + }); + + return { + ...state, + edit: 0, + todos: [...changeTitle], + }; + } + + case 'removeEdit': { + return { + ...state, + edit: 0, + }; + } + + case 'selectedAll': { + return { + ...state, + selectedAll: true, + filterActive: false, + selectedCompleted: false, + }; + } + + case 'filterActive': { + return { + ...state, + filterActive: true, + selectedAll: false, + selectedCompleted: false, + }; + } + + case 'selectedCompleted': { + return { + ...state, + selectedCompleted: true, + selectedAll: false, + filterActive: false, + }; + } + + case 'clearCompleted': { + const todosNotCompleted = state.todos + .filter(todo => todo.completed === false); + + return { + ...state, + todos: [...todosNotCompleted], + }; + } + + default: + return state; + } +} + +const initialState: State = { + todos, + value: '', + todosCompleted: false, + filterActive: false, + selectedCompleted: false, + selectedAll: false, + edit: 0, +}; + +export function useLocalStorage( + key: string, + startValue: State + ): [State, React.Dispatch] { + const storedValue + = JSON.parse(localStorage.getItem(key) + || JSON.stringify(startValue)); + + const [store, dispatch] = useReducer(reducer, storedValue); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(store)); + }, [key, store]); + + return [store, dispatch]; +} + +export const TodoContext = React.createContext(initialState); +/* eslint-disable */ +export const DispatchContext = React.createContext((_action: Action) => {}); + +type Props = { + children: React.ReactNode; +}; + + +export const GlobalStateProvider: React.FC = ({ children }) => { + const [state, dispatch] = useLocalStorage('todos', initialState); + + return ( + + + {children} + + + ); +}; diff --git a/src/Types/Todo.ts b/src/Types/Todo.ts new file mode 100644 index 000000000..95f9ccfac --- /dev/null +++ b/src/Types/Todo.ts @@ -0,0 +1,5 @@ +export type Todo = { + id: number, + title: string, + completed: boolean, +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..540b86905 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 { GlobalStateProvider } from './Data/Store'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +);