From bd4ddcd7d1d9e39961a70cfba3a771371e18a5e7 Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 19 Sep 2023 00:46:57 +0300 Subject: [PATCH] Solution --- README.md | 2 +- src/App.tsx | 22 +-- src/api/todos.ts | 8 + src/components/TodoApp/TodoApp.tsx | 37 +++++ src/components/TodoApp/index.js | 3 + src/components/TodoContext/TodoContext.tsx | 155 ++++++++++++++++++ src/components/TodoFooter/TodoFooter.tsx | 78 +++++++++ src/components/TodoFooter/index.js | 3 + src/components/TodoHeader/TodoHeader.tsx | 57 +++++++ src/components/TodoHeader/index.js | 3 + src/components/TodoItem/TodoItem.tsx | 140 ++++++++++++++++ src/components/TodoItem/index.js | 3 + src/components/TodoList/TodoList.tsx | 37 +++++ src/components/TodoList/index.js | 3 + .../TodoNotification/TodoNotification.tsx | 37 +++++ src/components/TodoNotification/index.js | 3 + src/types/Filter.ts | 5 + src/types/Todo.ts | 6 + src/utils/fetchClient.ts | 46 ++++++ 19 files changed, 630 insertions(+), 18 deletions(-) create mode 100644 src/api/todos.ts create mode 100644 src/components/TodoApp/TodoApp.tsx create mode 100644 src/components/TodoApp/index.js create mode 100644 src/components/TodoContext/TodoContext.tsx create mode 100644 src/components/TodoFooter/TodoFooter.tsx create mode 100644 src/components/TodoFooter/index.js create mode 100644 src/components/TodoHeader/TodoHeader.tsx create mode 100644 src/components/TodoHeader/index.js create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.js create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.js create mode 100644 src/components/TodoNotification/TodoNotification.tsx create mode 100644 src/components/TodoNotification/index.js create mode 100644 src/types/Filter.ts create mode 100644 src/types/Todo.ts create mode 100644 src/utils/fetchClient.ts diff --git a/README.md b/README.md index af7dae81f..08d4dfb57 100644 --- a/README.md +++ b/README.md @@ -41,4 +41,4 @@ Implement the ability to edit a todo title on double click: - Implement a solution following the [React task guideline](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). -- Replace `` with your Github username in the [DEMO LINK](https://.github.io/react_todo-app-with-api/) and add it to the PR description. +- Replace `` with your Github username in the [DEMO LINK](https://deandre25.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index 5749bdf78..a7c335c50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +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 { TodoProvider } from './components/TodoContext/TodoContext'; +import TodoApp from './components/TodoApp'; export const App: React.FC = () => { - if (!USER_ID) { - return ; - } - return ( -
-

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

- -

Styles are already copied

-
+ + + ); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..c3b593670 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,8 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const getTodos = (userId: number) => { + return client.get(`/todos?userId=${userId}`); +}; + +// Add more methods here diff --git a/src/components/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx new file mode 100644 index 000000000..642963f30 --- /dev/null +++ b/src/components/TodoApp/TodoApp.tsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import TodoHeader from '../TodoHeader'; +import TodoFooter from '../TodoFooter'; +import TodoList from '../TodoList'; +import { useTodo } from '../TodoContext/TodoContext'; +import { Filter } from '../../types/Filter'; +import TodoNotification from '../TodoNotification'; + +export const TodoApp: React.FC = () => { + const { todos } = useTodo(); + const [selectedFilter, setSelectedFilter] = useState(Filter.all); + + return ( +
+

todos

+ +
+ + + {todos.length > 0 && ( + <> + + + + )} +
+ + +
+ ); +}; diff --git a/src/components/TodoApp/index.js b/src/components/TodoApp/index.js new file mode 100644 index 000000000..291a72082 --- /dev/null +++ b/src/components/TodoApp/index.js @@ -0,0 +1,3 @@ +import { TodoApp } from './TodoApp'; + +export default TodoApp; diff --git a/src/components/TodoContext/TodoContext.tsx b/src/components/TodoContext/TodoContext.tsx new file mode 100644 index 000000000..09561d8a2 --- /dev/null +++ b/src/components/TodoContext/TodoContext.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Todo } from '../../types/Todo'; +import { getTodos } from '../../api/todos'; +import { UserWarning } from '../../UserWarning'; + +const USER_ID = 11453; + +type Props = { + children: React.ReactNode; +}; + +type TodoContextValue = { + todos: Todo[], + todosUncompleted: number, + todosCompleted: boolean, + addTodo: (title: string) => void, + toggleTodo: (id: number) => void, + toogleAll: () => void, + deleteTodo: (id: number) => void, + deleteCompletedTodo: () => void, + updateTodo: (updatedTitle: string, id: number) => void, + isError: boolean, + setIsError: (isError: boolean) => void, + errorMessage: string, + setErrorMessage: (errorMessage: string) => void, +}; + +export const TodoContext = React.createContext({ + todos: [], + todosUncompleted: 0, + todosCompleted: false, + addTodo: () => { }, + toggleTodo: () => { }, + toogleAll: () => { }, + deleteTodo: () => { }, + deleteCompletedTodo: () => { }, + updateTodo: () => { }, + isError: false, + setIsError: () => { }, + errorMessage: '', + setErrorMessage: () => { }, +}); + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + const [isError, setIsError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + if (USER_ID) { + getTodos(USER_ID) + .then(setTodos) + .catch((error) => { + throw error; + }); + } + }, []); + + const addTodo = (title: string) => { + const newTodo: Todo = { + id: +new Date(), + userId: USER_ID, + title, + completed: false, + }; + + setTodos([...todos, newTodo]); + }; + + const toggleTodo = (id: number) => { + const updatedTodos = [...todos]; + const index = todos.findIndex(todo => todo.id === id); + + if (index !== -1) { + updatedTodos[index].completed = !updatedTodos[index].completed; + } + + setTodos(updatedTodos); + }; + + const toogleAll = () => { + const allCompleted = todos.every(todo => todo.completed === true); + + const updatedTodos = todos.map(todo => ({ + ...todo, + completed: !allCompleted, + })); + + setTodos(updatedTodos); + }; + + const deleteTodo = (id: number) => { + const updatedTodos = [...todos]; + const index = todos.findIndex(todo => todo.id === id); + + if (index !== -1) { + updatedTodos.splice(index, 1); + } + + setTodos(updatedTodos); + }; + + const deleteCompletedTodo = () => { + const updatedTodos = todos.filter(todo => !todo.completed); + + setTodos(updatedTodos); + }; + + const updateTodo = (updatedTitle: string, id: number) => { + const updatedTodos = [...todos]; + const todoToUpdate = updatedTodos.find(todo => todo.id === id); + + if (todoToUpdate) { + todoToUpdate.title = updatedTitle; + } + + setTodos(updatedTodos); + }; + + const todosUncompleted = useMemo(() => todos.filter( + todo => !todo.completed, + ).length, [todos]); + + const todosCompleted = useMemo( + () => todos.some(todo => todo.completed), [todos], + ); + + const contextValue: TodoContextValue = { + todos, + todosUncompleted, + todosCompleted, + addTodo, + toggleTodo, + toogleAll, + deleteTodo, + deleteCompletedTodo, + updateTodo, + isError, + setIsError, + errorMessage, + setErrorMessage, + }; + + if (!USER_ID) { + return ; + } + + return ( + + {children} + + ); +}; + +export const useTodo = (): TodoContextValue => React.useContext(TodoContext); diff --git a/src/components/TodoFooter/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..d402f172c --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Filter } from '../../types/Filter'; +import { useTodo } from '../TodoContext/TodoContext'; + +type Props = { + selectedFilter: Filter, + setSelectedFilter: (filter: Filter) => void, +}; + +export const TodoFooter: React.FC = ({ + selectedFilter, + setSelectedFilter, +}) => { + const { + todosCompleted, + todosUncompleted, + deleteCompletedTodo, + } = useTodo(); + + return ( + + ); +}; diff --git a/src/components/TodoFooter/index.js b/src/components/TodoFooter/index.js new file mode 100644 index 000000000..3fe4f86ce --- /dev/null +++ b/src/components/TodoFooter/index.js @@ -0,0 +1,3 @@ +import { TodoFooter } from './TodoFooter'; + +export default TodoFooter; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..a5d90e717 --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,57 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { ChangeEvent, useState } from 'react'; +import classNames from 'classnames'; +import { useTodo } from '../TodoContext/TodoContext'; + +export const TodoHeader: React.FC = () => { + const { + todosUncompleted, + toogleAll, + addTodo, + setIsError, + setErrorMessage, + } = useTodo(); + const [inputValue, setInputValue] = useState(''); + + const handleInputChange = (event: ChangeEvent) => { + setInputValue(event.target.value); + }; + + const handleTodoAdd = (e: React.FormEvent) => { + e.preventDefault(); + if (!inputValue.trim()) { + setIsError(true); + setErrorMessage('Unable to add a todo'); + } else { + addTodo(inputValue); + } + + setTimeout(() => { + setInputValue(''); + }, 500); + }; + + return ( +
+
+ ); +}; diff --git a/src/components/TodoHeader/index.js b/src/components/TodoHeader/index.js new file mode 100644 index 000000000..6163f22f8 --- /dev/null +++ b/src/components/TodoHeader/index.js @@ -0,0 +1,3 @@ +import { TodoHeader } from './TodoHeader'; + +export default TodoHeader; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..c3d9c8f68 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; +import { useTodo } from '../TodoContext/TodoContext'; + +type Props = { + todo: Todo, +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { + toggleTodo, + deleteTodo, + updateTodo, + } = useTodo(); + const [isEditing, setIsEditing] = useState(false); + const [updatedTitle, setUpdatedTitle] = useState(''); + const inputRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + + const handleToggle = () => { + setIsLoading(true); + + setTimeout(() => { + toggleTodo(todo.id); + setIsLoading(false); + }, 500); + }; + + const handleDeleteTodo = () => { + setIsLoading(true); + + setTimeout(() => { + deleteTodo(todo.id); + setIsLoading(false); + }, 500); + }; + + const handleDoubleClick = () => { + setIsEditing(true); + setUpdatedTitle(todo.title); + }; + + const handleTitleChange = (event: React.ChangeEvent) => { + setUpdatedTitle(event.target.value); + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + const handleTodoUpdate = (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setIsEditing(false); + + setTimeout(() => { + if (!updatedTitle.trim()) { + deleteTodo(todo.id); + } else { + updateTodo(updatedTitle, todo.id); + } + + setIsLoading(false); + }, 500); + }; + + const handleBlur = () => { + setIsLoading(true); + setIsEditing(false); + + setTimeout(() => { + if (!updatedTitle.trim()) { + deleteTodo(todo.id); + } else { + updateTodo(updatedTitle, todo.id); + } + + setIsLoading(false); + }, 500); + }; + + return ( +
+ + + {isEditing ? ( +
+ +
+ ) : ( + <> + {todo.title} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.js b/src/components/TodoItem/index.js new file mode 100644 index 000000000..70a3ad9cf --- /dev/null +++ b/src/components/TodoItem/index.js @@ -0,0 +1,3 @@ +import { TodoItem } from './TodoItem'; + +export default TodoItem; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..9c836b51a --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import { Filter } from '../../types/Filter'; +import TodoItem from '../TodoItem'; + +type Props = { + todos: Todo[], + selectedFilter: string, +}; + +function filterTodos(todos: Todo[], selectedFilter: string) { + switch (selectedFilter) { + case Filter.active: + return todos.filter(todo => !todo.completed); + + case Filter.completed: + return todos.filter(todo => todo.completed); + + case Filter.all: + default: + return todos; + } +} + +export const TodoList: React.FC = ({ + todos, selectedFilter, +}) => { + const filteredTodos = filterTodos(todos, selectedFilter); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodoList/index.js b/src/components/TodoList/index.js new file mode 100644 index 000000000..864f7ce8d --- /dev/null +++ b/src/components/TodoList/index.js @@ -0,0 +1,3 @@ +import { TodoList } from './TodoList'; + +export default TodoList; diff --git a/src/components/TodoNotification/TodoNotification.tsx b/src/components/TodoNotification/TodoNotification.tsx new file mode 100644 index 000000000..251ac0473 --- /dev/null +++ b/src/components/TodoNotification/TodoNotification.tsx @@ -0,0 +1,37 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import classNames from 'classnames'; + +import { useTodo } from '../TodoContext/TodoContext'; + +export const TodoNotification: React.FC = () => { + const { + isError, + setIsError, + errorMessage, + setErrorMessage, + } = useTodo(); + + const handleButtonClick = () => { + setIsError(false); + setErrorMessage(''); + }; + + return ( + +
+
+ ); +}; diff --git a/src/components/TodoNotification/index.js b/src/components/TodoNotification/index.js new file mode 100644 index 000000000..cb0c87e9b --- /dev/null +++ b/src/components/TodoNotification/index.js @@ -0,0 +1,3 @@ +import { TodoNotification } from './TodoNotification'; + +export default TodoNotification; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..83445e7ee --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + all = 'all', + active = 'active', + completed = 'completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..3f52a5fdd --- /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 000000000..42421feae --- /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(300) + .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'), +};