From a24fe9e12b02d429d7673a795a374b4b1e4651df Mon Sep 17 00:00:00 2001 From: Volodymyr Vynnyk Date: Mon, 28 Oct 2024 12:14:34 +0200 Subject: [PATCH 1/4] draft version --- src/App.tsx | 165 ++++----------------------- src/api/todos.ts | 20 ++++ src/components/Footer/Footer.tsx | 61 ++++++++++ src/components/Footer/index.ts | 1 + src/components/Header/Header.tsx | 77 +++++++++++++ src/components/Header/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 128 +++++++++++++++++++++ src/components/TodoItem/index.ts | 1 + src/components/TodoList/TodoList.tsx | 15 +++ src/components/TodoList/index.ts | 1 + src/context/Store.tsx | 65 +++++++++++ src/index.tsx | 15 ++- src/types/FilterStatus.ts | 5 + src/types/Todo.ts | 5 + src/utils/fetchClient.ts | 46 ++++++++ src/utils/getFilteredTodos.ts | 17 +++ src/utils/getLocalStorageData.ts | 17 +++ 17 files changed, 491 insertions(+), 149 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.ts create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.ts create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/context/Store.tsx create mode 100644 src/types/FilterStatus.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts create mode 100644 src/utils/getFilteredTodos.ts create mode 100644 src/utils/getLocalStorageData.ts 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..d9a519e0e --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,77 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; + +import { useDispatch, useGlobalState } from '../../context/Store'; + +export const Header: FC = () => { + const titleField = useRef(null); + 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: React.FormEvent) => { + event.preventDefault(); + + setIsSubmiting(true); + + const newTitle = title.trim(); + + if (newTitle) { + try { + dispatch({ type: 'add', payload: newTitle }); + setTitle(''); + } catch (error) { + throw error; + } finally { + setIsSubmiting(false); + } + } + }; + + 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..c596a2ff9 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,128 @@ +/* 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 editField = useRef(null); + + 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; + } + + setEditedTitle(validEditedTitle); + + 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 }, + }); + }; + + useEffect(() => { + if (editField.current && 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..381621765 --- /dev/null +++ b/src/context/Store.tsx @@ -0,0 +1,65 @@ +import { createContext, 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 }; + +type Dispatch = { (action: Action): void }; + +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', []); + +export const TodosContext = createContext(initialTodos); +export const DispatchContext = createContext(() => {}); + +type Props = { + children: ReactNode; +}; + +export const GlobalStateProvider: FC = ({ children }) => { + const [todos, dispatch] = useReducer(reducer, initialTodos); + + + {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; + } +} From a93a9c930abfe725cb32019337f39680d4f8f980 Mon Sep 17 00:00:00 2001 From: Volodymyr Vynnyk Date: Mon, 28 Oct 2024 16:25:00 +0200 Subject: [PATCH 2/4] draft 2 --- src/components/Header/Header.tsx | 3 ++- src/components/TodoItem/TodoItem.tsx | 8 ++++---- src/context/Store.tsx | 23 ++++++++++++++++------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index d9a519e0e..b83971243 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -4,7 +4,6 @@ import cn from 'classnames'; import { useDispatch, useGlobalState } from '../../context/Store'; export const Header: FC = () => { - const titleField = useRef(null); const [title, setTitle] = useState(''); const [isSubmitting, setIsSubmiting] = useState(false); @@ -43,6 +42,8 @@ export const Header: FC = () => { } }; + const titleField = useRef(null); + useEffect(() => { titleField.current?.focus(); }, [todos, isSubmitting]); diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index c596a2ff9..ea568fde9 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -16,8 +16,6 @@ export const TodoItem: React.FC = ({ todo }) => { const [isEditing, setIsEditing] = useState(false); const [editedTitle, setEditedTitle] = useState(title); - const editField = useRef(null); - const handleDelete = () => dispatch({ type: 'delete', payload: id }); const handleSubmit = ( @@ -71,9 +69,11 @@ export const TodoItem: React.FC = ({ todo }) => { }); }; + const editField = useRef(null); + useEffect(() => { - if (editField.current && isEditing) { - editField.current.focus(); + if (isEditing) { + editField.current?.focus(); } }, [isEditing]); diff --git a/src/context/Store.tsx b/src/context/Store.tsx index 381621765..000d10a2d 100644 --- a/src/context/Store.tsx +++ b/src/context/Store.tsx @@ -1,4 +1,11 @@ -import { createContext, FC, ReactNode, useContext, useReducer } from 'react'; +import { + createContext, + Dispatch, + FC, + ReactNode, + useContext, + useReducer, +} from 'react'; import { Todo } from '../types/Todo'; import { getLocalStorage } from '../utils/getLocalStorageData'; @@ -7,8 +14,6 @@ type Action = | { type: 'update'; payload: Todo } | { type: 'delete'; payload: number }; -type Dispatch = { (action: Action): void }; - function reducer(todos: Todo[], action: Action) { let newTodos: Todo[] = []; @@ -45,8 +50,10 @@ function reducer(todos: Todo[], action: Action) { const initialTodos: Todo[] = getLocalStorage('todos', []); +const defaultDispatch: Dispatch = () => {}; + export const TodosContext = createContext(initialTodos); -export const DispatchContext = createContext(() => {}); +export const DispatchContext = createContext(defaultDispatch); type Props = { children: ReactNode; @@ -55,9 +62,11 @@ type Props = { export const GlobalStateProvider: FC = ({ children }) => { const [todos, dispatch] = useReducer(reducer, initialTodos); - - {children} - ; + return ( + + {children} + + ); }; export const useGlobalState = () => useContext(TodosContext); From d9162cd2f212eb808081561d83c8a34ed0bcda40 Mon Sep 17 00:00:00 2001 From: Volodymyr Vynnyk Date: Thu, 31 Oct 2024 10:57:55 +0200 Subject: [PATCH 3/4] add final App version --- src/components/Header/Header.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index b83971243..ce74f1197 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -26,19 +26,20 @@ export const Header: FC = () => { const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); - setIsSubmiting(true); - const newTitle = title.trim(); - if (newTitle) { - try { - dispatch({ type: 'add', payload: newTitle }); - setTitle(''); - } catch (error) { - throw error; - } finally { - setIsSubmiting(false); - } + if (!newTitle) { + return; + } + + try { + setIsSubmiting(true); + dispatch({ type: 'add', payload: newTitle }); + setTitle(''); + } catch (error) { + throw error; + } finally { + setIsSubmiting(false); } }; From 07de3f8362d89bcee9959ed6da417b76bf42e4db Mon Sep 17 00:00:00 2001 From: Volodymyr Vynnyk Date: Tue, 5 Nov 2024 13:23:42 +0200 Subject: [PATCH 4/4] fix final ver --- src/components/Header/Header.tsx | 8 +++++++- src/components/TodoItem/TodoItem.tsx | 2 -- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index ce74f1197..09dece5b7 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -23,12 +23,17 @@ export const Header: FC = () => { ); }; - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = ( + event: // eslint-disable-next-line @typescript-eslint/indent + React.FormEvent | React.FocusEvent, + ) => { event.preventDefault(); const newTitle = title.trim(); if (!newTitle) { + setTitle(''); + return; } @@ -72,6 +77,7 @@ export const Header: FC = () => { value={title} onChange={event => setTitle(event.target.value)} disabled={isSubmitting} + onBlur={handleSubmit} /> diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx index ea568fde9..d1b3600e9 100644 --- a/src/components/TodoItem/TodoItem.tsx +++ b/src/components/TodoItem/TodoItem.tsx @@ -38,8 +38,6 @@ export const TodoItem: React.FC = ({ todo }) => { return; } - setEditedTitle(validEditedTitle); - try { dispatch({ type: 'update',