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 (
-
+
+
+
);
};
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}
+
+ )}