diff --git a/README.md b/README.md index 903c876f9..a64be6840 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://Nazarin565.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..b9aac861a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,23 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { TodoList } from './components/TodoList'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { useGlobalState } from './Store'; export const App: React.FC = () => { + const { todos } = useGlobalState(); + 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 */} - + {!!todos.length && } - {/* this button should be disabled if there are no completed todos */} - -
+ {!!todos.length &&
}
); diff --git a/src/Store.tsx b/src/Store.tsx new file mode 100644 index 000000000..fc6df0a94 --- /dev/null +++ b/src/Store.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useReducer } from 'react'; +import { SelectedFilter } from './types/SelectedFilter'; +import { State } from './types/State'; +import { Action } from './types/Action'; +import { loadFromLocalStorage } from './utils/LocaleStorage'; + +const initialState: State = { + todos: [], + query: '', + filter: SelectedFilter.ALL, + editingTodoId: null, + currentTitle: '', +}; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'toogleAllChecked': + const checked = state.todos.every(todo => todo.completed); + + return { + ...state, + todos: [...state.todos.map(todo => ({ ...todo, completed: !checked }))], + }; + + case 'addTodo': + return { + ...state, + todos: [...state.todos, action.payload], + }; + + case 'setQuery': + return { + ...state, + query: action.payload, + }; + + case 'deleteTodo': + return { + ...state, + todos: [...state.todos.filter(todo => todo.id !== action.payload)], + }; + + case 'massDelete': + return { + ...state, + todos: [ + ...state.todos.filter(todo => !action.payload.includes(todo.id)), + ], + }; + + case 'changeCheckbox': + return { + ...state, + todos: [ + ...state.todos.map(todo => + todo.id === action.payload + ? { ...todo, completed: !todo.completed } + : todo, + ), + ], + }; + + case 'updateTodo': + return { + ...state, + todos: [ + ...state.todos.map(todo => { + return todo.id === action.payload.id + ? { ...todo, title: action.payload.title } + : todo; + }), + ], + }; + + case 'setCurrentTitle': + return { + ...state, + currentTitle: action.payload, + }; + + case 'setEditingTodoId': + return { + ...state, + editingTodoId: action.payload, + }; + + case 'setFilter': + return { + ...state, + filter: action.payload, + }; + } +} + +export const StateContext = React.createContext(initialState); +export const DispatchContext = React.createContext>( + () => {}, +); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalStateProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState, () => ({ + todos: loadFromLocalStorage('todos'), + query: '', + filter: SelectedFilter.ALL, + editingTodoId: null, + currentTitle: '', + })); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(state.todos)); + }, [state.todos]); + + return ( + + {children} + + ); +}; + +export const useGlobalState = () => React.useContext(StateContext); +export const useGlobalDispatch = () => React.useContext(DispatchContext); diff --git a/src/components/EditForm.tsx b/src/components/EditForm.tsx new file mode 100644 index 000000000..950a1bd05 --- /dev/null +++ b/src/components/EditForm.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef } from 'react'; +import { useGlobalDispatch, useGlobalState } from '../Store'; +import { Todo } from '../types/Todo'; + +type Props = { + newTodo: Todo; +}; + +export const EditForm: React.FC = ({ + newTodo: { id, title, completed }, +}) => { + const { currentTitle, editingTodoId } = useGlobalState(); + const dispatch = useGlobalDispatch(); + + const editRef = useRef(null); + + useEffect(() => { + if (editRef.current && editingTodoId) { + editRef.current.focus(); + } + }, [editRef, editingTodoId]); + + const handleUpdateTitle = (newTodo: Todo) => { + const trimmedTitle = currentTitle.trim(); + + if (!trimmedTitle) { + dispatch({ type: 'deleteTodo', payload: newTodo.id }); + } else { + dispatch({ + type: 'updateTodo', + payload: { ...newTodo, title: trimmedTitle }, + }); + } + + dispatch({ type: 'setEditingTodoId', payload: null }); + }; + + const handleUpdateTitleSubmit = (event: React.FormEvent, newTodo: Todo) => { + event.preventDefault(); + handleUpdateTitle(newTodo); + }; + + const handleChange = (event: React.ChangeEvent) => + dispatch({ type: 'setCurrentTitle', payload: event.target.value }); + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + dispatch({ type: 'setCurrentTitle', payload: title }); + dispatch({ type: 'setEditingTodoId', payload: null }); + } + }; + + return ( +
+ handleUpdateTitleSubmit(event, { id, title, completed }) + } + > + handleUpdateTitle({ id, title, completed })} + onChange={handleChange} + onKeyUp={handleKeyUp} + /> +
+ ); +}; diff --git a/src/components/FilterOption.tsx b/src/components/FilterOption.tsx new file mode 100644 index 000000000..5c0a4dd8d --- /dev/null +++ b/src/components/FilterOption.tsx @@ -0,0 +1,28 @@ +import cn from 'classnames'; +import { FilteredOptions } from '../types/FilteredOptions'; +import { useGlobalDispatch, useGlobalState } from '../Store'; + +type Props = { + option: FilteredOptions; +}; + +export const FilterOptions: React.FC = ({ + option: { type, href, data }, +}) => { + const { filter } = useGlobalState(); + const dispatch = useGlobalDispatch(); + + return ( + dispatch({ type: 'setFilter', payload: type })} + > + {type} + + ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..b49bfa5d0 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,65 @@ +import { SelectedFilter } from '../types/SelectedFilter'; +import { FilterOptions } from './FilterOption'; +import { useGlobalDispatch, useGlobalState } from '../Store'; + +export const Footer = () => { + const { todos } = useGlobalState(); + const dispatch = useGlobalDispatch(); + + const filteredOptions = [ + { type: SelectedFilter.ALL, href: '#/', data: 'FilterLinkAll' }, + + { type: SelectedFilter.ACTIVE, href: '#/active', data: 'FilterLinkActive' }, + + { + type: SelectedFilter.COMPLETED, + href: '#/completed', + data: 'FilterLinkCompleted', + }, + ]; + + const handleMassDeleteTodo = (ids: number[]) => { + dispatch({ type: 'massDelete', payload: ids }); + }; + + const itemsLeft = () => { + const uncompletedTodos = todos.filter(todo => !todo.completed).length; + + if (uncompletedTodos === 1) { + return `${uncompletedTodos} item left`; + } + + return `${uncompletedTodos} items left`; + }; + + const completedTodosIds = todos + .filter(todo => todo.completed) + .map(todo => todo.id); + + return ( +
+ + {itemsLeft()} + + + + + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..ecee1aeac --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,33 @@ +import cn from 'classnames'; +import { MainInput } from './MainInput'; +import { useGlobalDispatch, useGlobalState } from '../Store'; + +export const Header = () => { + const { todos } = useGlobalState(); + const dispatch = useGlobalDispatch(); + + const completedTodosIds = todos + .filter(todo => todo.completed) + .map(todo => todo.id); + + const toogleAllChecked = () => { + dispatch({ type: 'toogleAllChecked' }); + }; + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/MainInput.tsx b/src/components/MainInput.tsx new file mode 100644 index 000000000..05b017651 --- /dev/null +++ b/src/components/MainInput.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react'; +import { useGlobalDispatch, useGlobalState } from '../Store'; +import { Todo } from '../types/Todo'; + +export const MainInput = () => { + const { query, editingTodoId, todos } = useGlobalState(); + const dispatch = useGlobalDispatch(); + + const mainInputRef = useRef(null); + + useEffect(() => { + if (mainInputRef.current && !editingTodoId) { + mainInputRef.current.focus(); + } + }, [todos, editingTodoId]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = query.trim(); + + if (!trimmedTitle) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: trimmedTitle, + completed: false, + }; + + dispatch({ type: 'addTodo', payload: newTodo }); + dispatch({ type: 'setQuery', payload: '' }); + }; + + const handleQueryChange = (event: React.ChangeEvent) => { + dispatch({ type: 'setQuery', payload: event.target.value }); + }; + + return ( +
+ +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..6b6f707de --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,70 @@ +import cn from 'classnames'; +import { Todo } from '../types/Todo'; +import { EditForm } from './EditForm'; +import { useGlobalDispatch, useGlobalState } from '../Store'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ + todo: { id, title, completed }, +}) => { + const { editingTodoId } = useGlobalState(); + const dispatch = useGlobalDispatch(); + + const handleDeleteTodo = (index: number) => { + dispatch({ type: 'deleteTodo', payload: index }); + }; + + const handleChangeCheckbox = (index: number) => { + dispatch({ type: 'changeCheckbox', payload: index }); + }; + + const handleDoubleClick = (editId: number, editTitle: string) => { + dispatch({ type: 'setCurrentTitle', payload: editTitle }); + dispatch({ type: 'setEditingTodoId', payload: editId }); + }; + + return ( +
+ + + {editingTodoId === id ? ( + + ) : ( + <> + handleDoubleClick(id, title)} + > + {title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..94a622727 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; +import { TodoItem } from './TodoItem'; +import { useGlobalState } from '../Store'; +import { SelectedFilter } from '../types/SelectedFilter'; + +export const TodoList = () => { + const { todos, filter } = useGlobalState(); + + const visibleTodos = useMemo(() => { + switch (filter) { + case SelectedFilter.ACTIVE: + return todos.filter(todo => !todo.completed); + + case SelectedFilter.COMPLETED: + return todos.filter(todo => todo.completed); + + default: + return todos; + } + }, [todos, filter]); + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..285a018a2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,17 @@ 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'; +import './styles/todo.scss'; +import './styles/todoapp.scss'; +import './styles/filter.scss'; +import { GlobalStateProvider } from './Store'; + const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/todo.scss b/src/styles/todo.scss index 4576af434..0eddc2686 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -1,3 +1,7 @@ +* { + box-sizing: border-box; +} + .todo { position: relative; diff --git a/src/types/Action.ts b/src/types/Action.ts new file mode 100644 index 000000000..2df7920ba --- /dev/null +++ b/src/types/Action.ts @@ -0,0 +1,14 @@ +import { SelectedFilter } from './SelectedFilter'; +import { Todo } from './Todo'; + +export type Action = + | { type: 'toogleAllChecked' } + | { type: 'addTodo'; payload: Todo } + | { type: 'setQuery'; payload: string } + | { type: 'deleteTodo'; payload: number } + | { type: 'massDelete'; payload: number[] } + | { type: 'changeCheckbox'; payload: number } + | { type: 'updateTodo'; payload: Todo } + | { type: 'setCurrentTitle'; payload: string } + | { type: 'setEditingTodoId'; payload: number | null } + | { type: 'setFilter'; payload: SelectedFilter }; diff --git a/src/types/FilteredOptions.ts b/src/types/FilteredOptions.ts new file mode 100644 index 000000000..880e3264c --- /dev/null +++ b/src/types/FilteredOptions.ts @@ -0,0 +1,7 @@ +import { SelectedFilter } from './SelectedFilter'; + +export type FilteredOptions = { + type: SelectedFilter; + href: string; + data: string; +}; diff --git a/src/types/SelectedFilter.ts b/src/types/SelectedFilter.ts new file mode 100644 index 000000000..e41ff3695 --- /dev/null +++ b/src/types/SelectedFilter.ts @@ -0,0 +1,5 @@ +export enum SelectedFilter { + ALL = 'All', + ACTIVE = 'Active', + COMPLETED = 'Completed', +} diff --git a/src/types/State.ts b/src/types/State.ts new file mode 100644 index 000000000..b54ce40ba --- /dev/null +++ b/src/types/State.ts @@ -0,0 +1,10 @@ +import { SelectedFilter } from './SelectedFilter'; +import { Todo } from './Todo'; + +export type State = { + todos: Todo[]; + query: string; + filter: SelectedFilter; + editingTodoId: number | null; + currentTitle: string; +}; 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; +}; diff --git a/src/utils/LocaleStorage.ts b/src/utils/LocaleStorage.ts new file mode 100644 index 000000000..2f8bfa692 --- /dev/null +++ b/src/utils/LocaleStorage.ts @@ -0,0 +1,7 @@ +import { Todo } from '../types/Todo'; + +export const loadFromLocalStorage = (key: string): Todo[] => { + const todos = localStorage.getItem(key); + + return todos ? JSON.parse(todos) : []; +};