diff --git a/README.md b/README.md index 47a1add059..6293d9328d 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,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://timurradkevic.github.io/react_todo-app-with-api/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index 81e011f432..0000000000 --- a/src/App.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; - -const USER_ID = 0; - -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/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000000..076adf39dc --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,278 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useEffect, useRef, useState } from 'react'; +import { UserWarning } from '../UserWarning'; +import { + createTodo, + getTodos, + removeTodo, + updateTodo, + USER_ID, +} from '../features/todos/api/todos'; +import { + TodoList, + TodoHeader, + TodoFooter, + ErrorNotification, + QueryTodos, + Todo, +} from '../features/todos'; +import { filterTodos } from '../features/todos/utils/filterTodos'; + +export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [updatingTodos, setUpdatingTodos] = useState([]); + const [selectedTodo, setSelectedTodo] = useState(null); + + const [tempTodo, setTempTodo] = useState(null); + + const [titleTodo, setTitleTodo] = useState(''); + + const [todoInputIsActive, setTodoInputIsActive] = useState(true); + + const activeTodosCount = filterTodos(todos, QueryTodos.Active).length; + const completedTodos = filterTodos(todos, QueryTodos.Completed); + + const [errorMessage, setErrorMessage] = useState(''); + + const [isLoading, setIsLoading] = useState(true); + + const [query, setQuery] = useState(QueryTodos.All); + + const preparedTodos = filterTodos(todos, query); + + const inputRef = useRef(null); + + function loadTodos() { + setIsLoading(true); + + getTodos() + .then(setTodos) + .catch(error => { + setErrorMessage('Unable to load todos'); + throw error; + }) + .finally(() => setIsLoading(false)); + } + + function addTodo({ title, userId, completed }: Omit) { + setErrorMessage(''); + setTodoInputIsActive(false); + setTempTodo({ id: 0, title, userId, completed }); + + return createTodo({ title, userId, completed }) + .then(newTodo => { + setTodos(currentTodos => [...currentTodos, newTodo]); + setTitleTodo(''); + }) + .catch(error => { + setErrorMessage('Unable to add a todo'); + throw error; + }) + .finally(() => { + setTempTodo(null); + setTodoInputIsActive(true); + }); + } + + useEffect(() => { + if (todoInputIsActive) { + inputRef.current?.focus(); + } + }, [todoInputIsActive]); + + function deleteTodo(todoId: number) { + setUpdatingTodos(prev => [...prev, todoId]); + + return removeTodo(todoId) + .then(() => { + inputRef.current?.focus(); + setTodos(currentTodos => { + return currentTodos.filter(todo => todo.id !== todoId); + }); + }) + .catch(error => { + setErrorMessage('Unable to delete a todo'); + throw error; + }) + .finally(() => { + setUpdatingTodos(prev => prev.filter(todo => todo !== todoId)); + }); + } + + function updateTodoTitle(todoId: number, todoTitle: string) { + const currentTodo = todos.find(todo => todo.id === todoId); + const fixedTodoTitle = todoTitle.trim(); + + if (currentTodo?.title === fixedTodoTitle) { + setSelectedTodo(null); + + return Promise.resolve(); + } + + if (fixedTodoTitle === '' && currentTodo) { + deleteTodo(currentTodo.id); + + return Promise.resolve(); + } + + setErrorMessage(''); + setUpdatingTodos(prev => [...prev, todoId]); + + return updateTodo(todoId, { title: fixedTodoTitle }) + .then(updatedTodo => { + setSelectedTodo(null); + + setTodos(current => + current.map(todo => + todo.id === updatedTodo.id ? updatedTodo : todo, + ), + ); + }) + .catch(error => { + setErrorMessage('Unable to update a todo'); + throw error; + }) + .finally(() => { + setUpdatingTodos(prev => prev.filter(id => id !== todoId)); + }); + } + + function toggleTodoStatus(todoId: number, todoCompleted: boolean) { + setUpdatingTodos(prev => [...prev, todoId]); + const todoCompletedReverse = !todoCompleted; + + return updateTodo(todoId, { completed: todoCompletedReverse }) + .then(updatedTodo => { + setSelectedTodo(null); + + setTodos(currentTodos => { + return currentTodos.map(todo => + todo.id === updatedTodo.id ? updatedTodo : todo, + ); + }); + }) + .catch(error => { + setErrorMessage('Unable to update a todo'); + throw error; + }) + .finally(() => { + setUpdatingTodos(prev => prev.filter(todo => todo !== todoId)); + }); + } + + async function toggleAllTodos() { + const completed = !todos.every(todo => todo.completed); + const changedTodos = todos.filter(todo => todo.completed !== completed); + const ids = changedTodos.map(todo => todo.id); + + setUpdatingTodos(prev => [...prev, ...ids]); + + try { + await Promise.all( + changedTodos.map(todo => + updateTodo(todo.id, { + completed, + }), + ), + ); + + setTodos(current => + current.map(todo => + todo.completed === completed ? todo : { ...todo, completed }, + ), + ); + } finally { + setUpdatingTodos(prev => prev.filter(id => !ids.includes(id))); + } + } + + async function clearCompletedTodos() { + const deletePromises = completedTodos.map(todo => deleteTodo(todo.id)); + + await Promise.all(deletePromises); + inputRef.current?.focus(); + } + + useEffect(loadTodos, []); + + useEffect(() => { + const timeoutId = window.setTimeout(() => setErrorMessage(''), 3000); + + return () => { + clearTimeout(timeoutId); + }; + }, [errorMessage]); + + const handleSubmit = ( + event: React.FormEvent, + { title, userId, completed }: Omit, + ) => { + event.preventDefault(); + const fixedTitleTodo = title.trim(); + + if (!fixedTitleTodo) { + setErrorMessage('Title should not be empty'); + + return; + } + + addTodo({ title: fixedTitleTodo, userId, completed }); + }; + + if (!USER_ID) { + return ; + } + + return ( +
+

todos

+ +
+ + + {!isLoading ? ( + + ) : ( +
+
+
+ )} + {!isLoading && todos.length !== 0 && ( + + )} +
+ + {/* DON'T use conditional rendering to hide the notification */} + {/* Add the 'hidden' class to hide the message smoothly */} + +
+ ); +}; diff --git a/src/features/todos/api/todos.ts b/src/features/todos/api/todos.ts new file mode 100644 index 0000000000..609d2a7d3b --- /dev/null +++ b/src/features/todos/api/todos.ts @@ -0,0 +1,21 @@ +import { Todo } from '../types/Todo'; +import { client } from '../../../shared/api/fetchClient'; + +export const USER_ID = 4196; +export const USER_URL = `/todos?userId=${USER_ID}`; + +export const getTodos = () => { + return client.get(USER_URL); +}; + +export function createTodo(data: Omit): Promise { + return client.post('/todos', data); +} + +export function removeTodo(id: number) { + return client.delete(`/todos/${id}`); +} + +export function updateTodo(id: number, data: Partial>) { + return client.patch(`/todos/${id}`, data); +} diff --git a/src/features/todos/components/ErrorNotification/ErrorNotification.tsx b/src/features/todos/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 0000000000..dad312a960 --- /dev/null +++ b/src/features/todos/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +type Props = { + errorMessage: string; + setErrorMessage: (value: string) => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + setErrorMessage, +}) => { + return ( +
+
+ ); +}; diff --git a/src/features/todos/components/ErrorNotification/index.ts b/src/features/todos/components/ErrorNotification/index.ts new file mode 100644 index 0000000000..8cb4787920 --- /dev/null +++ b/src/features/todos/components/ErrorNotification/index.ts @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/features/todos/components/Header/TodoHeader.tsx b/src/features/todos/components/Header/TodoHeader.tsx new file mode 100644 index 0000000000..a823e07a86 --- /dev/null +++ b/src/features/todos/components/Header/TodoHeader.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { USER_ID } from '../../api/todos'; +import { Todo } from '../../types/Todo'; + +type Props = { + todos: Todo[]; + titleTodo: string; + toggleAllTodos: () => void; + setTitleTodo: (value: string) => void; + handleSubmit: ( + event: React.FormEvent, + { title, userId, completed }: Omit, + ) => void; + todoInputIsActive: boolean; + inputRef: React.RefObject; + isLoading: boolean; +}; + +export const TodoHeader: React.FC = ({ + todos, + titleTodo, + toggleAllTodos, + setTitleTodo, + handleSubmit, + todoInputIsActive, + inputRef, + isLoading, +}) => { + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {!isLoading && todos.length !== 0 && ( +
+ ); +}; diff --git a/src/features/todos/components/Header/index.ts b/src/features/todos/components/Header/index.ts new file mode 100644 index 0000000000..c4db4bc408 --- /dev/null +++ b/src/features/todos/components/Header/index.ts @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/features/todos/components/TodoFooter/TodoFooter.tsx b/src/features/todos/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 0000000000..8cfd6f44fe --- /dev/null +++ b/src/features/todos/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { QueryTodos } from '../../constants/queryTodos'; +import { Todo } from '../../types/Todo'; + +type Props = { + activeTodosCount: number; + completedTodos: Todo[]; + clearCompletedTodos: () => void; + query: QueryTodos; + setQuery: (query: QueryTodos) => void; +}; + +export const TodoFooter: React.FC = ({ + activeTodosCount, + completedTodos, + clearCompletedTodos, + query, + setQuery, +}) => { + return ( + + ); +}; diff --git a/src/features/todos/components/TodoFooter/index.ts b/src/features/todos/components/TodoFooter/index.ts new file mode 100644 index 0000000000..544d07114e --- /dev/null +++ b/src/features/todos/components/TodoFooter/index.ts @@ -0,0 +1 @@ +export * from './TodoFooter'; diff --git a/src/features/todos/components/TodoHeader/TodoHeader.tsx b/src/features/todos/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 0000000000..a823e07a86 --- /dev/null +++ b/src/features/todos/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { USER_ID } from '../../api/todos'; +import { Todo } from '../../types/Todo'; + +type Props = { + todos: Todo[]; + titleTodo: string; + toggleAllTodos: () => void; + setTitleTodo: (value: string) => void; + handleSubmit: ( + event: React.FormEvent, + { title, userId, completed }: Omit, + ) => void; + todoInputIsActive: boolean; + inputRef: React.RefObject; + isLoading: boolean; +}; + +export const TodoHeader: React.FC = ({ + todos, + titleTodo, + toggleAllTodos, + setTitleTodo, + handleSubmit, + todoInputIsActive, + inputRef, + isLoading, +}) => { + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {!isLoading && todos.length !== 0 && ( +
+ ); +}; diff --git a/src/features/todos/components/TodoHeader/index.ts b/src/features/todos/components/TodoHeader/index.ts new file mode 100644 index 0000000000..c4db4bc408 --- /dev/null +++ b/src/features/todos/components/TodoHeader/index.ts @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/features/todos/components/TodoList/TempTodo.tsx b/src/features/todos/components/TodoList/TempTodo.tsx new file mode 100644 index 0000000000..18e57cca2a --- /dev/null +++ b/src/features/todos/components/TodoList/TempTodo.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; + +type Props = { + tempTodo: Todo | null; +}; + +export const TempTodo: React.FC = ({ tempTodo }) => { + if (!tempTodo) { + return null; + } + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + + {tempTodo.title} + + + {/* Remove button appears only on hover */} + + + {/* overlay will cover the todo while it is being deleted or updated */} +
+ {/* eslint-disable-next-line max-len */} +
+
+
+
+ ); +}; diff --git a/src/features/todos/components/TodoList/TodoEditForm.tsx b/src/features/todos/components/TodoList/TodoEditForm.tsx new file mode 100644 index 0000000000..401f41dc70 --- /dev/null +++ b/src/features/todos/components/TodoList/TodoEditForm.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; + inputRef: React.RefObject; + updatingTodoTitle: string; + setUpdatingTodoTitle: (value: string) => void; + handleSubmitEditTodos: (todo: Todo) => void; +}; + +export const TodoEditForm: React.FC = ({ + todo, + updatingTodoTitle, + inputRef, + setUpdatingTodoTitle, + handleSubmitEditTodos, +}) => { + return ( +
{ + event.preventDefault(); + handleSubmitEditTodos(todo); + }} + > + setUpdatingTodoTitle(event.target.value)} + onBlur={() => handleSubmitEditTodos(todo)} + ref={inputRef} + /> +
+ ); +}; diff --git a/src/features/todos/components/TodoList/TodoItem.tsx b/src/features/todos/components/TodoList/TodoItem.tsx new file mode 100644 index 0000000000..cf8c834b2b --- /dev/null +++ b/src/features/todos/components/TodoList/TodoItem.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useRef } from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoEditForm } from './TodoEditForm'; + +type Props = { + todo: Todo; + toggleTodoStatus: (todoId: number, todoCompleted: boolean) => void; + selectedTodo: Todo | null; + setSelectedTodo: (todo: Todo | null) => void; + updatingTodoTitle: string; + setUpdatingTodoTitle: (value: string) => void; + deleteTodo: (todoId: number) => void; + handleSubmitEditTodos: (todo: Todo) => void; + updatingTodos: number[]; +}; + +export const TodoItem: React.FC = ({ + todo, + toggleTodoStatus, + selectedTodo, + setSelectedTodo, + updatingTodoTitle, + setUpdatingTodoTitle, + deleteTodo, + handleSubmitEditTodos, + updatingTodos, +}) => { + const inputRef = useRef(null); + + useEffect(() => { + if (selectedTodo) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [selectedTodo]); + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {selectedTodo?.id !== todo.id ? ( + <> + { + setSelectedTodo(todo); + setUpdatingTodoTitle(todo.title); + }} + > + {todo.title} + + + + ) : ( + + )} + + {/* overlay will cover the todo while it is being deleted or updated */} +
+ {/* eslint-disable-next-line max-len */} +
+
+
+
+ ); +}; diff --git a/src/features/todos/components/TodoList/TodoList.tsx b/src/features/todos/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..19d168c7ad --- /dev/null +++ b/src/features/todos/components/TodoList/TodoList.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from 'react'; +import '../../../../shared/styles/index.scss'; +import { Todo } from '../../types/Todo'; +import { TempTodo } from './TempTodo'; +import { TodoItem } from './TodoItem'; + +type Props = { + preparedTodos: Todo[]; + deleteTodo: (todoId: number) => void; + updateTodoTitle: (todoId: number, todoTitle: string) => void; + toggleTodoStatus: (todoId: number, todoCompleted: boolean) => void; + updatingTodos: number[]; + tempTodo: Todo | null; + selectedTodo: Todo | null; + setSelectedTodo: (todo: Todo | null) => void; +}; + +export const TodoList: React.FC = ({ + preparedTodos, + deleteTodo, + updateTodoTitle, + toggleTodoStatus, + updatingTodos, + tempTodo, + selectedTodo, + setSelectedTodo, +}) => { + const [updatingTodoTitle, setUpdatingTodoTitle] = useState(''); + + useEffect(() => { + const handleEscapeKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setSelectedTodo(null); + } + }; + + window.addEventListener('keyup', handleEscapeKeyUp); + + return () => window.removeEventListener('keyup', handleEscapeKeyUp); + }, [setSelectedTodo]); + + const handleSubmitEditTodos = (todo: Todo) => { + updateTodoTitle(todo.id, updatingTodoTitle); + }; + + return ( +
+ {preparedTodos.map(todo => ( + + ))} + +
+ ); +}; diff --git a/src/features/todos/components/TodoList/index.ts b/src/features/todos/components/TodoList/index.ts new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/features/todos/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/features/todos/constants/queryTodos.ts b/src/features/todos/constants/queryTodos.ts new file mode 100644 index 0000000000..c9641c6c86 --- /dev/null +++ b/src/features/todos/constants/queryTodos.ts @@ -0,0 +1,5 @@ +export enum QueryTodos { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/features/todos/index.ts b/src/features/todos/index.ts new file mode 100644 index 0000000000..7f3d387f40 --- /dev/null +++ b/src/features/todos/index.ts @@ -0,0 +1,13 @@ +export { TodoList } from './components/TodoList'; +export { TodoHeader } from './components/TodoHeader'; +export { TodoFooter } from './components/TodoFooter'; +export { ErrorNotification } from './components/ErrorNotification'; + +// export { useTodos } from './hooks/useTodos'; + +export { QueryTodos } from './constants/queryTodos'; + +export type { Todo } from './types/Todo'; + +export * from './api/todos'; +export * from './utils/filterTodos'; diff --git a/src/features/todos/types/Todo.ts b/src/features/todos/types/Todo.ts new file mode 100644 index 0000000000..3f52a5fdde --- /dev/null +++ b/src/features/todos/types/Todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} diff --git a/src/features/todos/utils/filterTodos.ts b/src/features/todos/utils/filterTodos.ts new file mode 100644 index 0000000000..17455d32b2 --- /dev/null +++ b/src/features/todos/utils/filterTodos.ts @@ -0,0 +1,16 @@ +import { QueryTodos } from '../constants/queryTodos'; +import { Todo } from '../types/Todo'; + +export function filterTodos(todos: Todo[], query: QueryTodos): Todo[] { + return todos.filter(todo => { + switch (query) { + case QueryTodos.Active: + return !todo.completed; + case QueryTodos.Completed: + return todo.completed; + case QueryTodos.All: + default: + return true; + } + }); +} diff --git a/src/features/todos/utils/getActiveTodos.ts b/src/features/todos/utils/getActiveTodos.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/index.tsx b/src/index.tsx index fee7a5959b..db6b06a92a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,8 +2,8 @@ import { createRoot } from 'react-dom/client'; import 'bulma/css/bulma.css'; import '@fortawesome/fontawesome-free/css/all.css'; -import './styles/index.scss'; +import './shared/styles/index.scss'; -import { App } from './App'; +import { App } from './app/App'; createRoot(document.getElementById('root') as HTMLDivElement).render(); diff --git a/src/shared/api/fetchClient.ts b/src/shared/api/fetchClient.ts new file mode 100644 index 0000000000..2f4cfcd76e --- /dev/null +++ b/src/shared/api/fetchClient.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const BASE_URL = 'https://mate.academy/students-api'; + +// returns a promise resolved after a given delay +function wait(delay: number) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +// To have autocompletion and avoid mistypes +type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'; + +function request( + url: string, + method: RequestMethod = 'GET', + data: any = null, // we can send any data to the server +): Promise { + const options: RequestInit = { method }; + + if (data) { + // We add body and Content-Type only for the requests with data + options.body = JSON.stringify(data); + options.headers = { + 'Content-Type': 'application/json; charset=UTF-8', + }; + } + + // DON'T change the delay it is required for tests + return wait(100) + .then(() => fetch(BASE_URL + url, options)) + .then(response => { + if (!response.ok) { + throw new Error(); + } + + return response.json(); + }); +} + +export const client = { + get: (url: string) => request(url), + post: (url: string, data: any) => request(url, 'POST', data), + patch: (url: string, data: any) => request(url, 'PATCH', data), + delete: (url: string) => request(url, 'DELETE'), +}; diff --git a/src/styles/filter.scss b/src/shared/styles/filter.scss similarity index 100% rename from src/styles/filter.scss rename to src/shared/styles/filter.scss diff --git a/src/styles/index.scss b/src/shared/styles/index.scss similarity index 100% rename from src/styles/index.scss rename to src/shared/styles/index.scss diff --git a/src/styles/todo.scss b/src/shared/styles/todo.scss similarity index 100% rename from src/styles/todo.scss rename to src/shared/styles/todo.scss diff --git a/src/styles/todoapp.scss b/src/shared/styles/todoapp.scss similarity index 95% rename from src/styles/todoapp.scss rename to src/shared/styles/todoapp.scss index ad28bcb2fe..437713b045 100644 --- a/src/styles/todoapp.scss +++ b/src/shared/styles/todoapp.scss @@ -135,4 +135,11 @@ visibility: hidden; } } + + &__loader { + display: flex; + align-items: center; + justify-content: center; + padding-block: 10px; + } }