From a0f55926cb6c2795bd2bb83925215049ccffaa65 Mon Sep 17 00:00:00 2001 From: Andrii Marusiak Date: Thu, 21 Sep 2023 13:57:56 +0300 Subject: [PATCH 1/7] init commit with new src --- src/App.tsx | 21 ++- src/Components/Footer/Footer.tsx | 97 ++++++++++++++ src/Components/Footer/index.ts | 1 + src/Components/Form/Form.tsx | 31 +++++ src/Components/Form/index.ts | 1 + src/Components/Header/Header.tsx | 101 +++++++++++++++ src/Components/Header/index.ts | 1 + src/Components/TodoItem/TodoItem.tsx | 170 +++++++++++++++++++++++++ src/Components/TodoItem/index.ts | 1 + src/Components/TodosList/TodosList.tsx | 21 +++ src/Components/TodosList/index.ts | 1 + src/Components/UI/ApiError.tsx | 61 +++++++++ src/Context/InitialTodos.ts | 7 + src/Context/TodosProvider.tsx | 102 +++++++++++++++ src/Context/TodosReducer.ts | 60 +++++++++ src/Context/actions/actionCreators.ts | 27 ++++ src/Context/index.ts | 1 + src/Pages/Application/Application.tsx | 32 +++++ src/Pages/Auth/UserWarning.tsx | 22 ++++ src/api/todos.ts | 17 +++ src/helpers/USER_ID.ts | 3 + src/helpers/getTodos.ts | 27 ++++ src/types/actionTypes.ts | 21 +++ src/types/apiErrorsType.ts | 5 + src/types/filterTypes.ts | 5 + src/types/requestMethod.ts | 1 + src/types/todosTypes.ts | 9 ++ src/utils/fetchClient.ts | 48 +++++++ 28 files changed, 882 insertions(+), 12 deletions(-) create mode 100644 src/Components/Footer/Footer.tsx create mode 100644 src/Components/Footer/index.ts create mode 100644 src/Components/Form/Form.tsx create mode 100644 src/Components/Form/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/TodosList/TodosList.tsx create mode 100644 src/Components/TodosList/index.ts create mode 100644 src/Components/UI/ApiError.tsx create mode 100644 src/Context/InitialTodos.ts create mode 100644 src/Context/TodosProvider.tsx create mode 100644 src/Context/TodosReducer.ts create mode 100644 src/Context/actions/actionCreators.ts create mode 100644 src/Context/index.ts create mode 100644 src/Pages/Application/Application.tsx create mode 100644 src/Pages/Auth/UserWarning.tsx create mode 100644 src/api/todos.ts create mode 100644 src/helpers/USER_ID.ts create mode 100644 src/helpers/getTodos.ts create mode 100644 src/types/actionTypes.ts create mode 100644 src/types/apiErrorsType.ts create mode 100644 src/types/filterTypes.ts create mode 100644 src/types/requestMethod.ts create mode 100644 src/types/todosTypes.ts create mode 100644 src/utils/fetchClient.ts diff --git a/src/App.tsx b/src/App.tsx index 5749bdf78..8264a6e63 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,12 @@ -/* eslint-disable max-len */ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; -import { UserWarning } from './UserWarning'; -const USER_ID = 0; +import { TodosProvider } from './Context'; +import USER_ID from './helpers/USER_ID'; + +// components +import { UserWarning } from './Pages/Auth/UserWarning'; +import { Application } from './Pages/Application/Application'; export const App: React.FC = () => { if (!USER_ID) { @@ -11,14 +14,8 @@ export const App: React.FC = () => { } return ( -
-

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

- -

Styles are already copied

-
+ + + ); }; diff --git a/src/Components/Footer/Footer.tsx b/src/Components/Footer/Footer.tsx new file mode 100644 index 000000000..e0fc9f613 --- /dev/null +++ b/src/Components/Footer/Footer.tsx @@ -0,0 +1,97 @@ +import React, { useContext } from 'react'; +import cn from 'classnames'; + +import { FiltersType } from '../../types/filterTypes'; +import { TodosContext, ApiErrorContext, FormFocusContext } from '../../Context'; +import { deleteTodo } from '../../api/todos'; +import { + deleteTodoAction, + setIsDeletingAction, + removeIsDeletingAction, +} from '../../Context/actions/actionCreators'; + +import { getActiveTodos, getCompletedTodos } from '../../helpers/getTodos'; + +export const Footer: React.FC = () => { + const { setIsFocused } = useContext(FormFocusContext); + const { + todos, + filter, + setFilter, + dispatch, + } = useContext(TodosContext); + const { setApiError } = useContext(ApiErrorContext); + + const activeTodosNumber = getActiveTodos(todos).length; + const completedTodos = getCompletedTodos(todos); + const isClearCompletedInvisible = completedTodos.length === 0; + + const handleClearCompletedClick = () => { + setIsFocused(false); + + completedTodos.forEach(({ id }) => { + const isDeletingAction = setIsDeletingAction(id); + + dispatch(isDeletingAction); + deleteTodo(id) + .then(() => { + const deleteAction = deleteTodoAction(id); + + dispatch(deleteAction); + }) + .catch((error) => { + const removeAction = removeIsDeletingAction(id); + + dispatch(removeAction); + setApiError(error); + }) + .finally(() => { + setIsFocused(true); + }); + }); + }; + + 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/Form/Form.tsx b/src/Components/Form/Form.tsx new file mode 100644 index 000000000..42226ac04 --- /dev/null +++ b/src/Components/Form/Form.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +type Props = { + placeholder: string, + onInputChange: (e: React.ChangeEvent) => void, + value: string, + onSubmit: (e: React.FormEvent) => void, + forCypress: string, +}; + +type Ref = HTMLInputElement | null; + +export const Form = React.forwardRef(({ + placeholder, + onInputChange, + value, + onSubmit, + forCypress, +}, ref) => ( +
+ +
+)); diff --git a/src/Components/Form/index.ts b/src/Components/Form/index.ts new file mode 100644 index 000000000..b690c60a1 --- /dev/null +++ b/src/Components/Form/index.ts @@ -0,0 +1 @@ +export * from './Form'; diff --git a/src/Components/Header/Header.tsx b/src/Components/Header/Header.tsx new file mode 100644 index 000000000..4fcf02e31 --- /dev/null +++ b/src/Components/Header/Header.tsx @@ -0,0 +1,101 @@ +import React, { + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import cn from 'classnames'; + +import { TodosContext, ApiErrorContext, FormFocusContext } from '../../Context'; +import { Form } from '../Form'; +import USER_ID from '../../helpers/USER_ID'; +import { addTodo } from '../../api/todos'; +import { postTodoAction } from '../../Context/actions/actionCreators'; +import { emptyInputError } from '../../types/apiErrorsType'; + +// Component +export const Header: React.FC = () => { + const { todos, setTempTodo, dispatch } = useContext(TodosContext); + const { isFocused } = useContext(FormFocusContext); + const { setApiError } = useContext(ApiErrorContext); + const ref = useRef(null); + const [inputValue, setInputValue] = useState(''); + + const isToggleVisible = todos.length > 0; + const isToggleActive = todos.every(todo => todo.completed); + + useEffect(() => { + if (ref.current && isFocused) { + ref.current.focus(); + } + }, [ref, isFocused]); + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const preparedInputValue = inputValue.trim(); + + if (!preparedInputValue.length) { + setApiError(new Error(emptyInputError)); + + return; + } + + const data = { + userId: USER_ID, + title: preparedInputValue, + completed: false, + }; + + setTempTodo({ ...data, id: 0 }); + + if (ref.current) { + ref.current.blur(); + ref.current.disabled = true; + } + + addTodo(data) + .then((newTodo) => { + const actionPost = postTodoAction(newTodo); + + dispatch(actionPost); + setInputValue(''); + }) + .catch(error => setApiError(error)) + .finally(() => { + setTempTodo(null); + + if (ref.current && isFocused) { + ref.current.disabled = false; + ref.current.focus(); + } + }); + }; + + return ( +
+ {/* eslint-disable jsx-a11y/control-has-associated-label */} + {isToggleVisible && ( +
+ ); +}; 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..7de707ed0 --- /dev/null +++ b/src/Components/TodoItem/TodoItem.tsx @@ -0,0 +1,170 @@ +import React, { useContext, useEffect, useState } from 'react'; +import cn from 'classnames'; + +import { Todo } from '../../types/todosTypes'; +import { TodosContext, ApiErrorContext, FormFocusContext } from '../../Context'; +import { deleteTodo } from '../../api/todos'; +import { deleteTodoAction } from '../../Context/actions/actionCreators'; + +type Props = { + todo: Todo, +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { setIsFocused } = useContext(FormFocusContext); + const { + id, + title, + completed, + } = todo; + const isTodoEdited = todo.isDeleting; + const [isDeleting, setIsDeleting] = useState(isTodoEdited || false); + const { dispatch } = useContext(TodosContext); + const { setApiError } = useContext(ApiErrorContext); + + useEffect(() => { + setIsDeleting(isTodoEdited || false); + }, [isTodoEdited]); + + const handleDeleteClick = () => { + setIsDeleting(true); + setIsFocused(false); + + deleteTodo(id) + .then(() => { + const deleteAction = deleteTodoAction(id); + + dispatch(deleteAction); + }) + .catch((error) => { + setApiError(error); + }) + .finally(() => { + setIsDeleting(false); + setIsFocused(true); + }); + }; + + return ( +
+ + + + {title} + + + + +
+
+
+
+
+ ); +}; + +// {/* This is a completed todo */} +//
+// + +// Completed Todo + +// {/* Remove button appears only on hover */} +// + +// {/* overlay will cover the todo while it is being updated */} +//
+//
+//
+//
+//
+ +// {/* This todo is not completed */} +//
+// + +// 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 +// + +// {/* 'is-active' class puts this modal on top of the todo */} +//
+//
+//
+//
+//
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/TodosList/TodosList.tsx b/src/Components/TodosList/TodosList.tsx new file mode 100644 index 000000000..699d2ed9b --- /dev/null +++ b/src/Components/TodosList/TodosList.tsx @@ -0,0 +1,21 @@ +import React, { useContext } from 'react'; + +import { TodosContext } from '../../Context'; +import { TodoItem } from '../TodoItem'; + +import getFilteredTodos from '../../helpers/getTodos'; + +export const TodosList: React.FC = () => { + const { todos, filter, tempTodo } = useContext(TodosContext); + + const filteredTodos = getFilteredTodos(todos, filter); + + return ( +
+ {filteredTodos.map(todo => )} + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/Components/TodosList/index.ts b/src/Components/TodosList/index.ts new file mode 100644 index 000000000..2fd6e52c5 --- /dev/null +++ b/src/Components/TodosList/index.ts @@ -0,0 +1 @@ +export * from './TodosList'; diff --git a/src/Components/UI/ApiError.tsx b/src/Components/UI/ApiError.tsx new file mode 100644 index 000000000..3256a3172 --- /dev/null +++ b/src/Components/UI/ApiError.tsx @@ -0,0 +1,61 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useContext, useEffect, useState } from 'react'; +import cn from 'classnames'; + +import { ApiErrorContext } from '../../Context'; +import { RequestMethod } from '../../types/requestMethod'; +import { EmptyInputErrorType } from '../../types/apiErrorsType'; + +type ResponseErrors = { + [key in RequestMethod | EmptyInputErrorType]: string; +}; + +const responseErrors: ResponseErrors = { + GET: 'Unable to load todos', + POST: 'Unable to add a todo', + PATCH: 'Unable to update a todo', + DELETE: 'Unable to delete a todo', + REQUIRED: 'Title should not be empty', +}; + +export const ApiError: React.FC = () => { + const { apiError } = useContext(ApiErrorContext); + const [addClassName, setAddClassName] = useState(true); + + useEffect(() => { + let timeOutId: ReturnType; + + if (apiError) { + setAddClassName(false); + + timeOutId = setTimeout(() => { + setAddClassName(true); + }, 3000); + } + + return () => { + clearTimeout(timeOutId); + }; + }, [apiError]); + + const errorMessage = apiError?.message as RequestMethod; + + return ( +
+
+ ); +}; diff --git a/src/Context/InitialTodos.ts b/src/Context/InitialTodos.ts new file mode 100644 index 000000000..51d105ca2 --- /dev/null +++ b/src/Context/InitialTodos.ts @@ -0,0 +1,7 @@ +import { TodosListType } from '../types/todosTypes'; + +const dataFromStorage = localStorage.getItem('todosList'); + +export const initialTodos: TodosListType = dataFromStorage + ? JSON.parse(dataFromStorage) + : []; diff --git a/src/Context/TodosProvider.tsx b/src/Context/TodosProvider.tsx new file mode 100644 index 000000000..2630b4a89 --- /dev/null +++ b/src/Context/TodosProvider.tsx @@ -0,0 +1,102 @@ +import React, { + createContext, useEffect, useReducer, useState, +} from 'react'; + +import { TodosListType, Todo } from '../types/todosTypes'; +import { ApiErrorType } from '../types/apiErrorsType'; +import { Actions } from '../types/actionTypes'; +import { FiltersType } from '../types/filterTypes'; + +import { loadTodosAction } from './actions/actionCreators'; + +import { initialTodos } from './InitialTodos'; +import { todosReducer } from './TodosReducer'; + +import { getTodos } from '../api/todos'; +import USER_ID from '../helpers/USER_ID'; + +// create Context and types + +type TodosContextType = { + todos: TodosListType, + dispatch: React.Dispatch, + filter: FiltersType, + setFilter: React.Dispatch> + tempTodo: Todo | null, + setTempTodo: React.Dispatch> +}; + +type ApiErrorContextType = { + apiError: ApiErrorType, + setApiError: React.Dispatch> +}; + +type FormFocusContextType = { + isFocused: boolean, + setIsFocused: React.Dispatch> +}; + +type Props = { + children: React.ReactNode, +}; + +export const ApiErrorContext = createContext({ + apiError: null, + setApiError: () => { }, +}); + +export const FormFocusContext = createContext({ + isFocused: true, + setIsFocused: () => { }, +}); + +export const TodosContext = createContext({ + todos: initialTodos, + dispatch: () => null, + filter: FiltersType.ALL, + setFilter: () => { }, + tempTodo: null, + setTempTodo: () => null, +}); + +// Component + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, dispatch] = useReducer( + todosReducer, + initialTodos, + ); + const [apiError, setApiError] = useState(null); + const [filter, setFilter] = useState(FiltersType.ALL); + const [tempTodo, setTempTodo] = useState(null); + const [isFocused, setIsFocused] = useState(true); + + const todosContextValue = { + todos, + dispatch, + filter, + setFilter, + tempTodo, + setTempTodo, + }; + + useEffect(() => { + getTodos(USER_ID) + .then((data) => { + const action = loadTodosAction(data); + + dispatch(action); + }) + .catch(e => setApiError(e)); + }, []); + + return ( + + + + {children} + + + + ); +}; diff --git a/src/Context/TodosReducer.ts b/src/Context/TodosReducer.ts new file mode 100644 index 000000000..81b53a58e --- /dev/null +++ b/src/Context/TodosReducer.ts @@ -0,0 +1,60 @@ +import { TodosListType } from '../types/todosTypes'; +import { Actions } from '../types/actionTypes'; + +export const todosReducer + = (todos: TodosListType, action: Actions): TodosListType => { + const { type, payload } = action; + + switch (type) { + case 'LOAD': { + return [...payload]; + } + + case 'POST': { + return [...todos, payload]; + } + + case 'DELETE': { + const filtered = todos.filter(({ id }) => ( + id !== payload)); + + return filtered; + } + + case 'IS_DELETING': { + const maped = todos.map((todo) => { + if (todo.id === payload) { + const copyTodo = { ...todo }; + + copyTodo.isDeleting = true; + + return copyTodo; + } + + return todo; + }); + + return maped; + } + + case 'REMOVE_IS_DELETING': { + const maped = todos.map((todo) => { + if (todo.id === payload) { + const copyTodo = { ...todo }; + + delete copyTodo.isDeleting; + + return copyTodo; + } + + return todo; + }); + + return maped; + } + + default: { + throw Error('Unknown action'); + } + } + }; diff --git a/src/Context/actions/actionCreators.ts b/src/Context/actions/actionCreators.ts new file mode 100644 index 000000000..756b293fb --- /dev/null +++ b/src/Context/actions/actionCreators.ts @@ -0,0 +1,27 @@ +import { TodosListType, Todo } from '../../types/todosTypes'; +import { Actions } from '../../types/actionTypes'; + +export const loadTodosAction = (data: TodosListType): Actions => ({ + type: 'LOAD', + payload: data, +}); + +export const postTodoAction = (data: Todo): Actions => ({ + type: 'POST', + payload: data, +}); + +export const deleteTodoAction = (data: number): Actions => ({ + type: 'DELETE', + payload: data, +}); + +export const setIsDeletingAction = (data: number): Actions => ({ + type: 'IS_DELETING', + payload: data, +}); + +export const removeIsDeletingAction = (data: number): Actions => ({ + type: 'REMOVE_IS_DELETING', + payload: data, +}); diff --git a/src/Context/index.ts b/src/Context/index.ts new file mode 100644 index 000000000..5621de8cf --- /dev/null +++ b/src/Context/index.ts @@ -0,0 +1 @@ +export * from './TodosProvider'; diff --git a/src/Pages/Application/Application.tsx b/src/Pages/Application/Application.tsx new file mode 100644 index 000000000..00b333913 --- /dev/null +++ b/src/Pages/Application/Application.tsx @@ -0,0 +1,32 @@ +import React, { useContext } from 'react'; + +import { TodosContext } from '../../Context'; + +// components +import { Header } from '../../Components/Header'; +import { TodosList } from '../../Components/TodosList'; +import { Footer } from '../../Components/Footer'; +import { ApiError } from '../../Components/UI/ApiError'; + +export const Application: React.FC = () => { + const { todos, tempTodo } = useContext(TodosContext); + const isContentVisible = Boolean(todos.length) || Boolean(tempTodo); + + return ( +
+

todos

+ +
+ + {isContentVisible && ( +
+ + +
+
+ )} + + +
+ ); +}; diff --git a/src/Pages/Auth/UserWarning.tsx b/src/Pages/Auth/UserWarning.tsx new file mode 100644 index 000000000..db7dd16e3 --- /dev/null +++ b/src/Pages/Auth/UserWarning.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export const UserWarning: React.FC = () => ( +
+

+ Please get your + {' '} + userId + {' '} + + here + + {' '} + and save it in the app + {' '} +

const USER_ID = ...
+ + All requests to the API must be sent with this + userId. +

+
+); diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..f5e09ffc4 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,17 @@ +import { Todo } from '../types/todosTypes'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +// eslint-disable-next-line +export const addTodo = (data: Omit) => { + return client.post('/todos/', data); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +// eslint-disable-next-line diff --git a/src/helpers/USER_ID.ts b/src/helpers/USER_ID.ts new file mode 100644 index 000000000..4a11791f8 --- /dev/null +++ b/src/helpers/USER_ID.ts @@ -0,0 +1,3 @@ +const USER_ID = 11495; + +export default USER_ID; diff --git a/src/helpers/getTodos.ts b/src/helpers/getTodos.ts new file mode 100644 index 000000000..f2dd07558 --- /dev/null +++ b/src/helpers/getTodos.ts @@ -0,0 +1,27 @@ +import { FiltersType } from '../types/filterTypes'; +import { TodosListType } from '../types/todosTypes'; + +export const getCompletedTodos + = (todos: TodosListType) => todos.filter(({ completed }) => completed); + +export const getActiveTodos + = (todos: TodosListType) => todos.filter(({ completed }) => !completed); + +const getFilteredTodos + = (todos: TodosListType, filter: FiltersType): TodosListType => { + switch (filter) { + case FiltersType.ALL: + return todos; + + case FiltersType.ACTIVE: + return getActiveTodos(todos); + + case FiltersType.COMPLETED: + return getCompletedTodos(todos); + + default: + return todos; + } + }; + +export default getFilteredTodos; diff --git a/src/types/actionTypes.ts b/src/types/actionTypes.ts new file mode 100644 index 000000000..9e1b58d3a --- /dev/null +++ b/src/types/actionTypes.ts @@ -0,0 +1,21 @@ +import { Todo, TodosListType } from './todosTypes'; + +export type Actions = { + type: 'LOAD', + payload: TodosListType, +} | { + type: 'POST', + payload: Todo, +} | { + type: 'DELETE', + payload: number, +} | { + type: 'PATCH', + payload: Todo, +} | { + type: 'IS_DELETING', + payload: number, +} | { + type: 'REMOVE_IS_DELETING', + payload: number, +}; diff --git a/src/types/apiErrorsType.ts b/src/types/apiErrorsType.ts new file mode 100644 index 000000000..aceaec269 --- /dev/null +++ b/src/types/apiErrorsType.ts @@ -0,0 +1,5 @@ +export type EmptyInputErrorType = 'REQUIRED'; + +export const emptyInputError: EmptyInputErrorType = 'REQUIRED'; + +export type ApiErrorType = Error | null; diff --git a/src/types/filterTypes.ts b/src/types/filterTypes.ts new file mode 100644 index 000000000..9b14cdd26 --- /dev/null +++ b/src/types/filterTypes.ts @@ -0,0 +1,5 @@ +export enum FiltersType { + ALL = 'All', + ACTIVE = 'Active', + COMPLETED = 'Completed', +} diff --git a/src/types/requestMethod.ts b/src/types/requestMethod.ts new file mode 100644 index 000000000..dc082f68b --- /dev/null +++ b/src/types/requestMethod.ts @@ -0,0 +1 @@ +export type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; diff --git a/src/types/todosTypes.ts b/src/types/todosTypes.ts new file mode 100644 index 000000000..11b8560d0 --- /dev/null +++ b/src/types/todosTypes.ts @@ -0,0 +1,9 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; + isDeleting?: boolean; +} + +export type TodosListType = Todo[]; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 000000000..53045ad21 --- /dev/null +++ b/src/utils/fetchClient.ts @@ -0,0 +1,48 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// To have autocompletion and avoid mistypes +import { RequestMethod } from '../types/requestMethod'; + +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); + }); +} + +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(300) + .then(() => fetch(BASE_URL + url, options)) + .then(response => { + if (!response.ok) { + const message = options.method; + + throw new Error(message); + } + + 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'), +}; From b77d6b359fd328401f2538f47116dcb0eed16d1d Mon Sep 17 00:00:00 2001 From: Andrii Marusiak Date: Thu, 21 Sep 2023 19:57:42 +0300 Subject: [PATCH 2/7] add PATCH feature, app without Transition group yet --- src/Components/Footer/Footer.tsx | 10 +- src/Components/Form/Form.tsx | 10 +- src/Components/Header/Header.tsx | 44 ++++++- src/Components/TodoItem/TodoItem.tsx | 164 ++++++++++++++++++++++---- src/Context/TodosReducer.ts | 32 ++++- src/Context/actions/actionCreators.ts | 18 ++- src/api/todos.ts | 5 +- src/types/actionTypes.ts | 7 +- src/types/todosTypes.ts | 2 +- 9 files changed, 246 insertions(+), 46 deletions(-) diff --git a/src/Components/Footer/Footer.tsx b/src/Components/Footer/Footer.tsx index e0fc9f613..6cad68e14 100644 --- a/src/Components/Footer/Footer.tsx +++ b/src/Components/Footer/Footer.tsx @@ -6,8 +6,8 @@ import { TodosContext, ApiErrorContext, FormFocusContext } from '../../Context'; import { deleteTodo } from '../../api/todos'; import { deleteTodoAction, - setIsDeletingAction, - removeIsDeletingAction, + setIsSpinningAction, + removeIsSpinningAction, } from '../../Context/actions/actionCreators'; import { getActiveTodos, getCompletedTodos } from '../../helpers/getTodos'; @@ -30,9 +30,9 @@ export const Footer: React.FC = () => { setIsFocused(false); completedTodos.forEach(({ id }) => { - const isDeletingAction = setIsDeletingAction(id); + const isSpinningAction = setIsSpinningAction(id); - dispatch(isDeletingAction); + dispatch(isSpinningAction); deleteTodo(id) .then(() => { const deleteAction = deleteTodoAction(id); @@ -40,7 +40,7 @@ export const Footer: React.FC = () => { dispatch(deleteAction); }) .catch((error) => { - const removeAction = removeIsDeletingAction(id); + const removeAction = removeIsSpinningAction(id); dispatch(removeAction); setApiError(error); diff --git a/src/Components/Form/Form.tsx b/src/Components/Form/Form.tsx index 42226ac04..98103156b 100644 --- a/src/Components/Form/Form.tsx +++ b/src/Components/Form/Form.tsx @@ -6,6 +6,9 @@ type Props = { value: string, onSubmit: (e: React.FormEvent) => void, forCypress: string, + className: string, + onBlur?: (e: React.FocusEvent) => void, + onKeyUp?: (e: React.KeyboardEvent) => void; }; type Ref = HTMLInputElement | null; @@ -16,16 +19,21 @@ export const Form = React.forwardRef(({ value, onSubmit, forCypress, + className, + onBlur = () => {}, + onKeyUp = () => {}, }, ref) => (
)); diff --git a/src/Components/Header/Header.tsx b/src/Components/Header/Header.tsx index 4fcf02e31..0cbe57a46 100644 --- a/src/Components/Header/Header.tsx +++ b/src/Components/Header/Header.tsx @@ -9,9 +9,15 @@ import cn from 'classnames'; import { TodosContext, ApiErrorContext, FormFocusContext } from '../../Context'; import { Form } from '../Form'; import USER_ID from '../../helpers/USER_ID'; -import { addTodo } from '../../api/todos'; -import { postTodoAction } from '../../Context/actions/actionCreators'; +import { addTodo, patchTodo } from '../../api/todos'; +import { + postTodoAction, + removeIsSpinningAction, + patchTodoAction, + setIsSpinningAction, +} from '../../Context/actions/actionCreators'; import { emptyInputError } from '../../types/apiErrorsType'; +import { getActiveTodos } from '../../helpers/getTodos'; // Component export const Header: React.FC = () => { @@ -30,6 +36,8 @@ export const Header: React.FC = () => { } }, [ref, isFocused]); + // handlers + const handleInputChange = (e: React.ChangeEvent) => { setInputValue(e.target.value); }; @@ -75,6 +83,36 @@ export const Header: React.FC = () => { }); }; + const handleAllToggle = () => { + const todosForToggle = isToggleActive + ? todos + : getActiveTodos(todos); + + todosForToggle.forEach(({ id }) => { + const isSpinningAction = setIsSpinningAction(id); + const data = { completed: !isToggleActive }; + + dispatch(isSpinningAction); + + patchTodo(id, data) + .then((patchedTodo) => { + const patchAction = patchTodoAction(patchedTodo); + + dispatch(patchAction); + }) + .catch((error) => { + setApiError(error); + }) + .finally(() => { + const removeAction = removeIsSpinningAction(id); + + dispatch(removeAction); + }); + }); + }; + + // render + return (
{/* eslint-disable jsx-a11y/control-has-associated-label */} @@ -85,6 +123,7 @@ export const Header: React.FC = () => { active: isToggleActive, })} data-cy="ToggleAllButton" + onClick={handleAllToggle} /> )} @@ -92,6 +131,7 @@ export const Header: React.FC = () => { forCypress="NewTodoField" ref={ref} placeholder="What needs to be done?" + className="todoapp__new-todo" onInputChange={handleInputChange} value={inputValue} onSubmit={handleSubmit} diff --git a/src/Components/TodoItem/TodoItem.tsx b/src/Components/TodoItem/TodoItem.tsx index 7de707ed0..e5d6cf5f6 100644 --- a/src/Components/TodoItem/TodoItem.tsx +++ b/src/Components/TodoItem/TodoItem.tsx @@ -1,33 +1,57 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { + useContext, + useEffect, + useState, + useRef, +} from 'react'; import cn from 'classnames'; import { Todo } from '../../types/todosTypes'; import { TodosContext, ApiErrorContext, FormFocusContext } from '../../Context'; -import { deleteTodo } from '../../api/todos'; -import { deleteTodoAction } from '../../Context/actions/actionCreators'; +import { deleteTodo, patchTodo } from '../../api/todos'; +import { deleteTodoAction, patchTodoAction } + from '../../Context/actions/actionCreators'; +import { Form } from '../Form'; type Props = { todo: Todo, }; +// Component export const TodoItem: React.FC = ({ todo }) => { const { setIsFocused } = useContext(FormFocusContext); const { id, title, completed, + isSpinned, } = todo; - const isTodoEdited = todo.isDeleting; - const [isDeleting, setIsDeleting] = useState(isTodoEdited || false); + const [isTodoSpinned, setIsTodoSpinned] = useState(isSpinned || false); + const [isCompleted, setIsCompleted] = useState(completed); + const [isEdited, setIsEdited] = useState(false); const { dispatch } = useContext(TodosContext); const { setApiError } = useContext(ApiErrorContext); + const [inputValue, setInputValue] = useState(title); + const ref = useRef(null); useEffect(() => { - setIsDeleting(isTodoEdited || false); - }, [isTodoEdited]); + setIsTodoSpinned(isSpinned || false); + }, [isSpinned]); + + useEffect(() => { + setIsCompleted(completed); + }, [completed]); + + useEffect(() => { + if (ref.current) { + ref.current.focus(); + } + }, [isEdited]); + + // handlers const handleDeleteClick = () => { - setIsDeleting(true); + setIsTodoSpinned(true); setIsFocused(false); deleteTodo(id) @@ -38,17 +62,92 @@ export const TodoItem: React.FC = ({ todo }) => { }) .catch((error) => { setApiError(error); + setInputValue(title); }) .finally(() => { - setIsDeleting(false); + setIsTodoSpinned(false); setIsFocused(true); + setIsEdited(false); + }); + }; + + const handleCompletedToggle = () => { + setIsTodoSpinned(true); + const data = { completed: !isCompleted }; + + patchTodo(id, data) + .then((patchedTodo) => { + const patchAction = patchTodoAction(patchedTodo); + + setIsCompleted(prev => !prev); + dispatch(patchAction); + }) + .catch((error) => { + setApiError(error); + }) + .finally(() => { + setIsTodoSpinned(false); + }); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.code === 'Escape') { + setIsEdited(false); + setInputValue(title); + } + }; + + const handleSubmit = (event: React.FormEvent + | React.FocusEvent) => { + event.preventDefault(); + + const preparedInputValue = inputValue.trim(); + const data = { title: preparedInputValue }; + + if (preparedInputValue === title) { + setIsEdited(false); + + return; + } + + if (!preparedInputValue.length) { + handleDeleteClick(); + + return; + } + + setIsTodoSpinned(true); + setIsEdited(false); + + patchTodo(id, data) + .then((patchedTodo) => { + const patchAction = patchTodoAction(patchedTodo); + + dispatch(patchAction); + setInputValue(patchedTodo.title); + }) + .catch(error => { + setApiError(error); + setInputValue(title); + }) + .finally(() => { + setIsTodoSpinned(false); + if (ref.current) { + ref.current.disabled = false; + } }); }; + // render + return (
@@ -57,17 +156,41 @@ export const TodoItem: React.FC = ({ todo }) => { data-cy="TodoStatus" type="checkbox" className="todo__status" - checked={completed} + checked={isCompleted} + onChange={handleCompletedToggle} /> - - {title} - + {isEdited ? ( +
+ + ) : ( + { + setIsEdited(true); + }} + > + {title} + + )}
+ )} - -
= ({ todo }) => {
); }; - -// {/* This is a completed todo */} -//
-// - -// Completed Todo - -// {/* Remove button appears only on hover */} -// - -// {/* overlay will cover the todo while it is being updated */} -//
-//
-//
-//
-//
- -// {/* This todo is not completed */} -//
-// - -// 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 -// - -// {/* 'is-active' class puts this modal on top of the todo */} -//
-//
-//
-//
-//
diff --git a/src/helpers/getTodos.ts b/src/helpers/getTodos.ts index f2dd07558..b832d0f05 100644 --- a/src/helpers/getTodos.ts +++ b/src/helpers/getTodos.ts @@ -11,6 +11,7 @@ const getFilteredTodos = (todos: TodosListType, filter: FiltersType): TodosListType => { switch (filter) { case FiltersType.ALL: + default: return todos; case FiltersType.ACTIVE: @@ -18,9 +19,6 @@ const getFilteredTodos case FiltersType.COMPLETED: return getCompletedTodos(todos); - - default: - return todos; } }; From f66c5921ff83f0930928da094fc1ce12eebf6a55 Mon Sep 17 00:00:00 2001 From: Andrii Marusiak Date: Mon, 25 Sep 2023 12:47:06 +0300 Subject: [PATCH 5/7] change onSubmit in TodoItem --- src/Components/Header/Header.tsx | 2 +- src/Components/TodoItem/TodoItem.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Components/Header/Header.tsx b/src/Components/Header/Header.tsx index 5b8b84c7d..e42d036f7 100644 --- a/src/Components/Header/Header.tsx +++ b/src/Components/Header/Header.tsx @@ -55,7 +55,7 @@ export const Header: React.FC = () => { completed: false, }; - setTempTodo({ ...data, id: 0 }); + setTempTodo({ ...data, isSpinned: true, id: 0 }); if (ref.current) { ref.current.blur(); diff --git a/src/Components/TodoItem/TodoItem.tsx b/src/Components/TodoItem/TodoItem.tsx index cadc6aae8..b32264fad 100644 --- a/src/Components/TodoItem/TodoItem.tsx +++ b/src/Components/TodoItem/TodoItem.tsx @@ -107,6 +107,7 @@ export const TodoItem: React.FC = ({ todo }) => { if (preparedInputValue === title) { setIsEdited(false); + setInputValue(preparedInputValue); return; } @@ -118,7 +119,6 @@ export const TodoItem: React.FC = ({ todo }) => { } setIsTodoSpinned(true); - setIsEdited(false); patchTodo(id, data) .then((patchedTodo) => { @@ -132,6 +132,7 @@ export const TodoItem: React.FC = ({ todo }) => { setInputValue(title); }) .finally(() => { + setIsEdited(false); setIsTodoSpinned(false); if (ref.current) { ref.current.disabled = false; @@ -178,7 +179,7 @@ export const TodoItem: React.FC = ({ todo }) => { setIsEdited(true); }} > - {title} + {inputValue}