diff --git a/src/App.tsx b/src/App.tsx index a399287bd..c2a0082f8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,35 @@ +//#region lint exception +/* eslint-disable react-hooks/rules-of-hooks */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +//#endregion +import React, { useMemo, useState } from 'react'; +import { FilterStatus } from './types/FilterStatus'; +import { getFilteredTodos } from './utils/getFilteredTodos'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList/TodoList'; +import { Footer } from './components/Footer/Footer'; +import { useGlobalState } from './context/Store'; export const App: React.FC = () => { + const [filter, setFilter] = useState(FilterStatus.All); + const todos = useGlobalState(); + + const filteredTodos = useMemo( + () => getFilteredTodos(todos, filter), + [filter, todos], + ); + 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 */} - -
+ {!!todos.length &&
); diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..8b332f4fa --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,20 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 968; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const postTodo = (todo: Omit) => { + return client.post(`/todos`, todo); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const patchTodo = (id: number, todoData: Partial) => { + return client.patch(`/todos/${id}`, todoData); +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..619b9ccd0 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { FilterStatus } from '../../types/FilterStatus'; +import cn from 'classnames'; +import { useDispatch, useGlobalState } from '../../context/Store'; + +type Props = { + filter: FilterStatus; + onFilter: (filter: FilterStatus) => void; +}; + +export const Footer: React.FC = ({ filter, onFilter }) => { + const todos = useGlobalState(); + const dispatch = useDispatch(); + + const activeTodos = todos.filter(todo => !todo.completed).length; + + const haveCompletedTodos = todos.some(todo => todo.completed); + + const handleClearCompleted = () => + todos + .filter(todo => todo.completed) + .forEach(todo => dispatch({ type: 'delete', payload: todo.id })); + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 000000000..ddcc5a9cd --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..09dece5b7 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,85 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; + +import { useDispatch, useGlobalState } from '../../context/Store'; + +export const Header: FC = () => { + const [title, setTitle] = useState(''); + const [isSubmitting, setIsSubmiting] = useState(false); + + const todos = useGlobalState(); + const dispatch = useDispatch(); + + const areAllTodosCompleted = todos.every(todo => todo.completed); + + const handleToggleAll = () => { + const haveActive = todos.some(todo => !todo.completed); + const todosToUpdate = haveActive + ? todos.filter(todo => !todo.completed) + : todos; + + todosToUpdate.forEach(todo => + dispatch({ type: 'update', payload: { ...todo, completed: haveActive } }), + ); + }; + + const handleSubmit = ( + event: // eslint-disable-next-line @typescript-eslint/indent + React.FormEvent | React.FocusEvent, + ) => { + event.preventDefault(); + + const newTitle = title.trim(); + + if (!newTitle) { + setTitle(''); + + return; + } + + try { + setIsSubmiting(true); + dispatch({ type: 'add', payload: newTitle }); + setTitle(''); + } catch (error) { + throw error; + } finally { + setIsSubmiting(false); + } + }; + + const titleField = useRef(null); + + useEffect(() => { + titleField.current?.focus(); + }, [todos, isSubmitting]); + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 000000000..266dec8a1 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..d1b3600e9 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,126 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; +import { Todo } from '../../types/Todo'; +import { useDispatch } from '../../context/Store'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { id, title, completed } = todo; + + const dispatch = useDispatch(); + + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(title); + + const handleDelete = () => dispatch({ type: 'delete', payload: id }); + + const handleSubmit = ( + event: // eslint-disable-next-line @typescript-eslint/indent + React.FormEvent | React.FocusEvent, + ) => { + event.preventDefault(); + + const validEditedTitle = editedTitle.trim(); + + if (validEditedTitle === title) { + setIsEditing(false); + + return; + } + + if (!validEditedTitle) { + handleDelete(); + + return; + } + + try { + dispatch({ + type: 'update', + payload: { ...todo, title: validEditedTitle }, + }); + setIsEditing(false); + } catch (error) { + setIsEditing(true); + + throw error; + } + }; + + const handleEsc = (event: React.KeyboardEvent) => { + event.preventDefault(); + + if (event.key === 'Escape') { + setIsEditing(false); + setEditedTitle(title); + } + }; + + const toggleCompletedStatus = () => { + dispatch({ + type: 'update', + payload: { ...todo, completed: !completed }, + }); + }; + + const editField = useRef(null); + + useEffect(() => { + if (isEditing) { + editField.current?.focus(); + } + }, [isEditing]); + + return ( +
+ + + {isEditing ? ( +
+ setEditedTitle(event.target.value)} + onBlur={handleSubmit} + onKeyUp={handleEsc} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 000000000..21f4abac3 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..09c40ce0c --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +type Props = { + filteredTodos: Todo[]; +}; + +export const TodoList: React.FC = ({ filteredTodos }) => ( +
+ {filteredTodos.map((todo: Todo) => ( + + ))} +
+); diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/context/Store.tsx b/src/context/Store.tsx new file mode 100644 index 000000000..000d10a2d --- /dev/null +++ b/src/context/Store.tsx @@ -0,0 +1,74 @@ +import { + createContext, + Dispatch, + FC, + ReactNode, + useContext, + useReducer, +} from 'react'; +import { Todo } from '../types/Todo'; +import { getLocalStorage } from '../utils/getLocalStorageData'; + +type Action = + | { type: 'add'; payload: string } + | { type: 'update'; payload: Todo } + | { type: 'delete'; payload: number }; + +function reducer(todos: Todo[], action: Action) { + let newTodos: Todo[] = []; + + switch (action.type) { + case 'add': + newTodos = [ + ...todos, + { + id: +new Date(), + title: action.payload, + completed: false, + }, + ]; + break; + + case 'update': + newTodos = todos.map(todo => + todo.id === action.payload.id ? action.payload : todo, + ); + break; + + case 'delete': + newTodos = todos.filter(todo => todo.id !== action.payload); + break; + + default: + return todos; + } + + localStorage.setItem('todos', JSON.stringify(newTodos)); + + return newTodos; +} + +const initialTodos: Todo[] = getLocalStorage('todos', []); + +const defaultDispatch: Dispatch = () => {}; + +export const TodosContext = createContext(initialTodos); +export const DispatchContext = createContext(defaultDispatch); + +type Props = { + children: ReactNode; +}; + +export const GlobalStateProvider: FC = ({ children }) => { + const [todos, dispatch] = useReducer(reducer, initialTodos); + + return ( + + {children} + + ); +}; + +export const useGlobalState = () => useContext(TodosContext); + +export const useDispatch = () => useContext(DispatchContext); diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..7beac4659 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,14 @@ import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import 'bulma/css/bulma.css'; +import '@fortawesome/fontawesome-free/css/all.css'; +import './styles/index.scss'; import { App } from './App'; +import { GlobalStateProvider } from './context/Store'; -const container = document.getElementById('root') as HTMLDivElement; - -createRoot(container).render(); +createRoot(document.getElementById('root') as HTMLDivElement).render( + + + , +); diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts new file mode 100644 index 000000000..7ca17f289 --- /dev/null +++ b/src/types/FilterStatus.ts @@ -0,0 +1,5 @@ +export enum FilterStatus { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..f9e06b381 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 000000000..33b2e0bd2 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const BASE_URL = 'https://mate.academy/students-api'; + +// returns a promise resolved after a given delay +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +// To have autocompletion and avoid mistypes +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, // we can send any data to the server +): Promise { + const options: RequestInit = { method }; + + if (data) { + // We add body and Content-Type only for the requests with data + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + // DON'T change the delay it is required for tests + return wait(100) + .then(() => fetch(BASE_URL + url, options)) + .then(response => { + if (!response.ok) { + throw new Error(); + } + + return response.json(); + }); +} + +export const client = { + get: (url: string) => request(url), + post: (url: string, data: any) => request(url, 'POST', data), + patch: (url: string, data: any) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +}; diff --git a/src/utils/getFilteredTodos.ts b/src/utils/getFilteredTodos.ts new file mode 100644 index 000000000..2a28f3ceb --- /dev/null +++ b/src/utils/getFilteredTodos.ts @@ -0,0 +1,17 @@ +import { FilterStatus } from '../types/FilterStatus'; +import { Todo } from '../types/Todo'; + +export function getFilteredTodos(todos: Todo[], filter: FilterStatus) { + return todos.filter(todo => { + switch (filter) { + case FilterStatus.Active: + return !todo.completed; + + case FilterStatus.Completed: + return todo.completed; + + default: + return todos; + } + }); +} diff --git a/src/utils/getLocalStorageData.ts b/src/utils/getLocalStorageData.ts new file mode 100644 index 000000000..e1555f4f9 --- /dev/null +++ b/src/utils/getLocalStorageData.ts @@ -0,0 +1,17 @@ +export function getLocalStorage(key: string, value: T) { + const data = localStorage.getItem(key); + + if (!data) { + localStorage.setItem(key, JSON.stringify(value)); + + return value; + } + + try { + return JSON.parse(data); + } catch { + localStorage.removeItem(key); + + return value; + } +}