From c7ec635805f6f256b5f979526ce896492f303df9 Mon Sep 17 00:00:00 2001 From: KatOlista^^! Date: Tue, 26 Sep 2023 20:44:45 +0200 Subject: [PATCH 1/5] implement update logic --- src/App.tsx | 62 ++++-- src/api/todos.ts | 18 ++ .../ErrorNotification/ErrorNotification.tsx | 48 +++++ src/components/ErrorNotification/index.tsx | 1 + src/components/Footer/Footer.tsx | 111 ++++++++++ src/components/Footer/index.tsx | 1 + src/components/Header/Header.tsx | 148 ++++++++++++++ src/components/Header/index.tsx | 1 + src/components/TodoItem/TodoItem.tsx | 191 ++++++++++++++++++ src/components/TodoItem/index.tsx | 1 + src/components/TodoList/TodoList.tsx | 31 +++ src/components/TodoList/index.tsx | 1 + src/components/TodosContext/TodosContext.tsx | 109 ++++++++++ src/components/TodosContext/index.tsx | 1 + src/index.tsx | 7 +- src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 +++++ src/utils/variables.ts | 1 + 18 files changed, 771 insertions(+), 13 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/ErrorNotification/ErrorNotification.tsx create mode 100644 src/components/ErrorNotification/index.tsx create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.tsx create mode 100644 src/components/TodosContext/TodosContext.tsx create mode 100644 src/components/TodosContext/index.tsx create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts create mode 100644 src/utils/variables.ts diff --git a/src/App.tsx b/src/App.tsx index 5749bdf784..c8a9f86f11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,62 @@ -/* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useEffect } from 'react'; + import { UserWarning } from './UserWarning'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { ErrorNotification } from './components/ErrorNotification'; -const USER_ID = 0; +import { getTodos } from './api/todos'; +import { USER_ID } from './utils/variables'; +import { TodoList } from './components/TodoList'; +import { ErrorMessage, TodosContext } from './components/TodosContext'; export const App: React.FC = () => { + const { + todos, + setTodos, + setFilteredTodos, + setAlarm, + } = useContext(TodosContext); + + useEffect(() => { + if (USER_ID) { + getTodos(USER_ID) + .then(todosFromServer => { + setTodos(todosFromServer); + setFilteredTodos(todosFromServer); + setAlarm(ErrorMessage.Default); + }) + .catch(errorMessage => { + // eslint-disable-next-line no-console + console.log(errorMessage); + setAlarm(ErrorMessage.isLoadTodoError); + setTodos([]); + }); + } + }, [USER_ID]); + if (!USER_ID) { return ; } return ( -
-

- Copy all you need from the prev task: -
- React Todo App - Add and Delete -

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {!!todos.length && ( + <> + + +
+ + )} +
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..79bfbb9491 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +export const createTodo = ({ title, userId, completed }: Omit) => { + return client.post('/todos', { title, userId, completed }); +}; + +export const removeTodo = (postId: number) => { + return client.delete(`/todos/${postId}`); +}; + +export const updateTodo = (todo: Todo) => { + return client.patch(`/todos/${todo.id}`, todo); +}; diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..a25e5567d4 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,48 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useContext, useEffect, useRef } from 'react'; +import cn from 'classnames'; +import { ErrorMessage, TodosContext } from '../TodosContext'; + +type Props = {}; + +export const ErrorNotification: React.FC = () => { + const { + alarm, + setAlarm, + } = useContext(TodosContext); + + const timerId = useRef(0); + + useEffect(() => { + if (timerId.current) { + window.clearTimeout(timerId.current); + } + + timerId.current = window.setTimeout(() => { + setAlarm(ErrorMessage.Default); + }, 3000); + }, [alarm]); + + return ( +
+
+ ); +}; diff --git a/src/components/ErrorNotification/index.tsx b/src/components/ErrorNotification/index.tsx new file mode 100644 index 0000000000..8cb4787920 --- /dev/null +++ b/src/components/ErrorNotification/index.tsx @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..9fa08f2535 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,111 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useContext, useEffect, useState } from 'react'; +import cn from 'classnames'; +import { Todo } from '../../types/Todo'; +import { TodosContext } from '../TodosContext'; + +enum FilterOption { + Default = '', + All = 'All', + Active = 'Active', + Completed = 'Completed', +} + +type Props = {}; + +function filterTodos(option: FilterOption, todos: Todo[]) { + const filteredTodos = todos.filter(todo => { + switch (option) { + case FilterOption.Active: { + return todo.completed === false; + } + + case FilterOption.Completed: { + return todo.completed === true; + } + + default: { + return todo; + } + } + }); + + return filteredTodos; +} + +const OPTIONS = [FilterOption.All, FilterOption.Completed, FilterOption.Active]; + +export const Footer: React.FC = () => { + const { + todos, + setFilteredTodos, + handleRemoveTodo, + isTodoChange, + changingItems, + } = useContext(TodosContext); + const [selectedOption, setSelectedOption] = useState(FilterOption.Default); + const [hasCompleted, setHasCompleted] = useState(false); + + useEffect(() => { + setSelectedOption(FilterOption.All); + }, []); + + useEffect(() => { + setFilteredTodos(filterTodos(selectedOption, todos)); + setHasCompleted(todos.some(todo => todo.completed)); + }, [selectedOption, isTodoChange, changingItems]); + + const handleFilterTodos = (option: FilterOption) => { + setFilteredTodos(filterTodos(option, todos)); + setSelectedOption(option); + }; + + const removeAllCompletedTodos = () => { + const completedTodos = filterTodos(FilterOption.Completed, todos); + + completedTodos.forEach(todo => handleRemoveTodo(todo)); + setHasCompleted(false); + }; + + const counterTodos = todos.filter(todo => !todo.completed).length; + + return ( +
+ + {`${counterTodos} items left`} + + + + + {/* don't show this button if there are no completed todos */} + +
+ ); +}; diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx new file mode 100644 index 0000000000..ddcc5a9cd1 --- /dev/null +++ b/src/components/Footer/index.tsx @@ -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 0000000000..c49790fc29 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,148 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { + useContext, + useEffect, + useRef, + useState, +} from 'react'; + +import cn from 'classnames'; + +import { Todo } from '../../types/Todo'; +import { createTodo, updateTodo } from '../../api/todos'; +import { USER_ID } from '../../utils/variables'; +import { ErrorMessage, TodosContext } from '../TodosContext'; + +type Props = {}; + +export const Header: React.FC = () => { + const { + todos, + setTodos, + setAlarm, + setTempTodo, + isTodoChange, + setIsTodoChange, + setChangingItems, + } = useContext(TodosContext); + + const titleInput = useRef(null); + + useEffect(() => { + if (titleInput.current) { + titleInput.current.focus(); + } + }, [isTodoChange]); + + const [inputValue, setInputValue] = useState(''); + + const isAllCompletedTodos = todos.every(todo => todo.completed); + + const addTodo = ({ title, userId, completed }: Omit) => { + createTodo({ title, userId, completed }) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + setTempTodo(null); + setInputValue(''); + setChangingItems([]); + }) + .catch(() => { + setAlarm(ErrorMessage.isUnableAddTodo); + setTempTodo(null); + }) + .finally(() => { + setIsTodoChange(false); + }); + }; + + const handleSubmitForm = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedInputValue = inputValue.trim(); + + if (!trimmedInputValue) { + setAlarm(ErrorMessage.isTitleEmpty); + setInputValue(''); + + return; + } + + setChangingItems(current => [...current, 0]); + setIsTodoChange(true); + setTempTodo({ + id: 0, + title: trimmedInputValue, + userId: USER_ID, + completed: false, + }); + addTodo({ title: trimmedInputValue, userId: USER_ID, completed: false }); + }; + + const handleInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + setAlarm(ErrorMessage.Default); + }; + + const updateToggleTodoCompleted = (todo: Todo, val: boolean) => { + const newTodo = { + ...todo, + completed: val, + }; + + setIsTodoChange(true); + setChangingItems(current => [...current, todo.id]); + updateTodo(newTodo) + .then(() => { + setIsTodoChange(false); + setTodos(currentTodos => currentTodos + .map(currTodo => { + return currTodo.id !== newTodo.id + ? currTodo + : newTodo; + })); + }) + .catch(() => setAlarm(ErrorMessage.isUnableUpdateTodo)) + .finally(() => { + setChangingItems([]); + }); + }; + + const handleAllCompletedTodos = () => { + const notCompletedTodos = todos.filter(todo => !todo.completed); + + if (notCompletedTodos.length > 0) { + notCompletedTodos.forEach(todo => updateToggleTodoCompleted(todo, true)); + } else { + todos.forEach(todo => updateToggleTodoCompleted(todo, false)); + } + }; + + return ( +
+ { !!todos.length && ( +
+ ); +}; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 0000000000..266dec8a1b --- /dev/null +++ b/src/components/Header/index.tsx @@ -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 0000000000..96b49596b1 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,191 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import cn from 'classnames'; + +import { Todo } from '../../types/Todo'; +import { ErrorMessage, TodosContext } from '../TodosContext'; +import { updateTodo } from '../../api/todos'; + +type Props = { + todo: Todo, +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { + id, + title, + completed, + } = todo; + + const { + handleRemoveTodo, + setAlarm, + setIsTodoChange, + changingItems, + setChangingItems, + setTodos, + } = useContext(TodosContext); + + const [isCompleted, setIsCompleted] = useState(completed); + const [isLoading, setIsLoading] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [editedInput, setEditedInput] = useState(title); + + const editedFormInput = useRef(null); + + useEffect(() => { + if (isUpdating && editedFormInput.current) { + editedFormInput.current.focus(); + } + }, [isUpdating]); + + useEffect(() => { + setIsCompleted(completed); + }, [completed]); + + const handleUpdateTodo = (val: boolean | string) => { + let newTodo = todo; + + if (typeof val === 'boolean') { + newTodo = { + ...todo, + completed: val, + }; + } + + if (typeof val === 'string' && val.trim() !== '') { + newTodo = { + ...todo, + title: val, + }; + } + + if (newTodo) { + setIsTodoChange(true); + setChangingItems(current => [...current, id]); + updateTodo(newTodo) + .then(() => { + setIsUpdating(false); + setIsTodoChange(false); + setTodos(currentTodos => currentTodos + .map(currTodo => { + return currTodo.id !== newTodo.id + ? currTodo + : newTodo; + })); + + setIsCompleted(newTodo.completed); + }) + .catch(() => { + setAlarm(ErrorMessage.isUnableUpdateTodo); + // setIsUpdating(true); + // editedFormInput.current?.focus(); + }) + .finally(() => { + setChangingItems([]); + }); + } + }; + + const handleUpdateTodoTitle = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedInputValue = editedInput.trim(); + + if (!trimmedInputValue) { + handleRemoveTodo(todo); + } + + if (trimmedInputValue === title) { + setIsUpdating(false); + + return; + } + + setIsTodoChange(true); + handleUpdateTodo(trimmedInputValue); + }; + + const handleEscape = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsUpdating(false); + } + }; + + useEffect(() => { + setIsLoading(changingItems.includes(id)); + }, [changingItems]); + + return ( +
setIsUpdating(true)} + > + + + {!isUpdating && ( + <> + + {title.trim()} + + + + + )} + + {isUpdating && ( +
+ setEditedInput(event.target.value)} + onKeyDown={handleEscape} + /> +
+ )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.tsx b/src/components/TodoItem/index.tsx new file mode 100644 index 0000000000..21f4abac39 --- /dev/null +++ b/src/components/TodoItem/index.tsx @@ -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 0000000000..d5dc404a07 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,31 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useContext } from 'react'; +import { TodoItem } from '../TodoItem'; +import { TodosContext } from '../TodosContext'; + +type Props = {}; + +export const TodoList: React.FC = () => { + const { + filteredTodos, + tempTodo, + } = useContext(TodosContext); + + return ( + <> +
+ {filteredTodos.map(todo => ( + + ))} +
+ + {tempTodo && ( + + )} + + + ); +}; diff --git a/src/components/TodoList/index.tsx b/src/components/TodoList/index.tsx new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/components/TodoList/index.tsx @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/components/TodosContext/TodosContext.tsx b/src/components/TodosContext/TodosContext.tsx new file mode 100644 index 0000000000..5e13eab33a --- /dev/null +++ b/src/components/TodosContext/TodosContext.tsx @@ -0,0 +1,109 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { removeTodo } from '../../api/todos'; + +export enum ErrorMessage { + Default = '', + isLoadTodoError = 'Unable to load todos', + isTitleEmpty = 'Title should not be empty', + isUnableAddTodo = 'Unable to add a todo', + isUnableDeleteTodo = 'Unable to delete a todo', + isUnableUpdateTodo = 'Unable to update a todo', +} + +interface InterfaceTodosContext { + todos: Todo[]; + setTodos: React.Dispatch>; + filteredTodos: Todo[]; + setFilteredTodos: React.Dispatch>; + + alarm: ErrorMessage, + setAlarm: React.Dispatch>; + + tempTodo: null | Todo, + setTempTodo: React.Dispatch>; + isTodoChange: boolean; + setIsTodoChange: React.Dispatch>; + handleRemoveTodo: (val: Todo) => void; + + changingItems: number[]; + setChangingItems: React.Dispatch>; +} + +type Props = { + children: React.ReactNode, +}; + +export const TodosContext = React.createContext({ + todos: [], + setTodos: () => {}, + filteredTodos: [], + setFilteredTodos: () => {}, + + alarm: ErrorMessage.Default, + setAlarm: () => {}, + + tempTodo: null, + setTempTodo: () => {}, + isTodoChange: false, + setIsTodoChange: () => {}, + handleRemoveTodo: () => {}, + + changingItems: [], + setChangingItems: () => {}, +}); + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [filteredTodos, setFilteredTodos] = useState(todos); + + const [alarm, setAlarm] = useState(ErrorMessage.Default); + + const [tempTodo, setTempTodo] = useState(null); + + const [isTodoChange, setIsTodoChange] = useState(false); + + const [changingItems, setChangingItems] = useState([]); + + const handleRemoveTodo = (todo: Todo) => { + setIsTodoChange(true); + setChangingItems(current => [...current, todo.id]); + removeTodo(todo.id) + .then(() => { + setFilteredTodos(todos.splice(todos.indexOf(todo), 1)); + }) + .catch(() => setAlarm(ErrorMessage.isUnableDeleteTodo)) + .finally(() => { + setFilteredTodos(todos); + setIsTodoChange(false); + setChangingItems([]); + }); + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/TodosContext/index.tsx b/src/components/TodosContext/index.tsx new file mode 100644 index 0000000000..b9ab89200f --- /dev/null +++ b/src/components/TodosContext/index.tsx @@ -0,0 +1 @@ +export * from './TodosContext'; diff --git a/src/index.tsx b/src/index.tsx index 7de19e0c70..16cdf03772 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,11 @@ import '@fortawesome/fontawesome-free/css/all.css'; import './styles/index.scss'; import { App } from './App'; +import { TodosProvider } from './components/TodosContext'; createRoot(document.getElementById('root') as HTMLDivElement) - .render(); + .render( + + + , + ); diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 0000000000..3f52a5fdde --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..ca588ab63a --- /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', + }; + } + + // we wait for testing purpose to see loaders + 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/variables.ts b/src/utils/variables.ts new file mode 100644 index 0000000000..de3e5fc9e9 --- /dev/null +++ b/src/utils/variables.ts @@ -0,0 +1 @@ +export const USER_ID = 11481; From 710ad874e65ed5778718e04625766f7798def104 Mon Sep 17 00:00:00 2001 From: KatOlista^^! Date: Wed, 27 Sep 2023 14:56:50 +0200 Subject: [PATCH 2/5] remove redundand comments --- src/components/ErrorNotification/ErrorNotification.tsx | 2 -- src/components/Footer/Footer.tsx | 1 - src/components/TodoItem/TodoItem.tsx | 3 +-- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx index a25e5567d4..a05f28bc3c 100644 --- a/src/components/ErrorNotification/ErrorNotification.tsx +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -40,8 +40,6 @@ export const ErrorNotification: React.FC = () => { className="delete" onClick={() => setAlarm(ErrorMessage.Default)} /> - - {/* show only one message at a time */} {alarm}
); diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index 9fa08f2535..d5e272ba8f 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -96,7 +96,6 @@ export const Footer: React.FC = () => { })} - {/* don't show this button if there are no completed todos */} {alarm}
); diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index b8f0c245a1..b21e54e46e 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ import { useContext, useEffect, useState } from 'react'; import cn from 'classnames'; import { TodosContext } from '../TodosContext'; @@ -37,10 +36,14 @@ export const Footer = () => { const counterTodos = todos.filter(todo => !todo.completed).length; + const item = counterTodos === 1 + ? 'item' + : 'items'; + return (