diff --git a/src/App.tsx b/src/App.tsx index 81e011f43..4542756bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,289 @@ -/* eslint-disable max-len */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -import { UserWarning } from './UserWarning'; +import React, { + ChangeEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; -const USER_ID = 0; +import { UserWarning } from './UserWarning'; +import { + USER_ID, + deleteTodo, + getTodos, + updateTodo, + uploadTodo, +} from './api/todos'; +import { Todo } from './types/Todo'; +import { TodoStatus } from './types/TodoStatus'; +import { ErrorType } from './types/Errors'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { emptyTodo } from './utils/EmptyTodo'; +import { UpdateTodoData } from './types/UpdateTodoData'; +import { ErrorNotification } from './components/ErrorNoti/ErrorNotiflication'; export const App: React.FC = () => { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [todoTitle, setTodoTitle] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [selectedTodoStatus, setSelectedTodoStatus] = useState(TodoStatus.All); + const [errorMessage, setErrorMessage] = useState(''); + const [processingTodos, setProcessingTodos] = useState([]); + const [editingTodoId, setEditingTodoId] = useState(null); + + const inputRef = useRef(null); + + const focusInputField = () => inputRef.current?.focus(); + + useEffect(() => { + getTodos() + .then(setTodos) + .catch(() => setErrorMessage(ErrorType.LOAD_TODOS)); + }, []); + + useEffect(() => { + const timer = setTimeout(() => setErrorMessage(''), 3000); + + return () => clearTimeout(timer); + }, [errorMessage]); + + useEffect(focusInputField, [todoTitle, todos, selectedTodoStatus, isLoading]); + + const filteringTodosByActiveStatus = useMemo( + () => todos.filter(todo => !todo.completed), + [todos], + ); + + const filteringTodosByCompletedStatus = useMemo( + () => todos.filter(todo => todo.completed), + [todos], + ); + + const filteredTodos = useMemo(() => { + switch (selectedTodoStatus) { + case TodoStatus.Active: + return filteringTodosByActiveStatus; + + case TodoStatus.Completed: + return filteringTodosByCompletedStatus; + + default: + return todos; + } + }, [ + filteringTodosByActiveStatus, + filteringTodosByCompletedStatus, + selectedTodoStatus, + todos, + ]); + + const changeTodoTitleHandler = useCallback( + (e: ChangeEvent) => { + setErrorMessage(''); + setTodoTitle(e.target.value); + }, + [], + ); + + const closeErrorHandler = () => setErrorMessage(''); + + const handleStatusChange = (status: TodoStatus) => + setSelectedTodoStatus(status); + + const selectedEditTodoId = (id: number | null) => setEditingTodoId(id); + + const updateProcessingTodos = (id: number) => + setProcessingTodos(prev => [...prev, id]); + + const removeProcessingTodos = (id: number) => + setProcessingTodos(prev => prev.filter(prevItem => prevItem !== id)); + + const addTodo = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!todoTitle.trim()) { + setErrorMessage(ErrorType.EMPTY_TITLE); + + return; + } + + setIsLoading(true); + setErrorMessage(''); + const newTempTodo: Todo = { ...emptyTodo, title: todoTitle.trim() }; + + setTempTodo(newTempTodo); + setProcessingTodos([newTempTodo.id]); + + try { + const todo = await uploadTodo({ + ...emptyTodo, + title: todoTitle.trim(), + }); + + setTodos(currentTodos => [...currentTodos, todo]); + setTodoTitle(''); + } catch { + setErrorMessage(ErrorType.ADD_TODO); + } finally { + setIsLoading(false); + setTempTodo(null); + setProcessingTodos([]); + focusInputField(); + } + }, + [todoTitle], + ); + + const onDeleteTodo = useCallback((id: number) => { + deleteTodo(id) + .then(() => + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== id)), + ) + .catch(() => setErrorMessage(ErrorType.DELETE_TODO)) + .finally(() => { + removeProcessingTodos(id); + focusInputField(); + }); + }, []); + + const removeTodo = useCallback( + (id: number) => { + setErrorMessage(''); + updateProcessingTodos(id); + onDeleteTodo(id); + }, + [onDeleteTodo], + ); + + const removeTodos = useCallback(async () => { + setIsLoading(true); + setErrorMessage(''); + + const deletePromises = filteringTodosByCompletedStatus.map(todo => + removeTodo(todo.id), + ); + + await Promise.allSettled(deletePromises); + setIsLoading(false); + }, [filteringTodosByCompletedStatus, removeTodo]); + + const onUpdateTodo = useCallback(async (id: number, data: UpdateTodoData) => { + return updateTodo(id, data) + .then(todo => { + setTodos(currentTodos => { + const newTodos = [...currentTodos]; + const index = newTodos.findIndex( + findedTodo => findedTodo.id === todo.id, + ); + + newTodos.splice(index, 1, todo); + + return newTodos; + }); + setEditingTodoId(null); + }) + .catch(() => { + setErrorMessage(ErrorType.UPDATE_TODO); + if ('title' in data) { + setEditingTodoId(id); + throw new Error(); + } + }) + .finally(() => { + removeProcessingTodos(id); + if (!('title' in data)) { + focusInputField(); + } + }); + }, []); + + const toggleTodoStatus = useCallback( + (id: number, data: UpdateTodoData) => { + setErrorMessage(''); + updateProcessingTodos(id); + onUpdateTodo(id, data); + }, + [onUpdateTodo], + ); + + const toggleAll = useCallback(async () => { + setIsLoading(true); + setErrorMessage(''); + + let todosForChange: Todo[] = []; + + if (filteringTodosByCompletedStatus.length !== todos.length) { + todosForChange = [...filteringTodosByActiveStatus]; + } else { + todosForChange = [...todos]; + } + + const togglePromises = todosForChange.map(todo => + toggleTodoStatus(todo.id, { completed: !todo.completed }), + ); + + await Promise.allSettled(togglePromises); + setIsLoading(false); + }, [ + filteringTodosByActiveStatus, + filteringTodosByCompletedStatus.length, + todos, + toggleTodoStatus, + ]); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + +
+
+ + +
); }; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 000000000..2e798c81d --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,21 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; +import { UpdateTodoData } from '../types/UpdateTodoData'; + +export const USER_ID = 337; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export const uploadTodo = ({ completed, title, userId }: Omit) => { + return client.post('/todos', { completed, title, userId }); +}; + +export const deleteTodo = (id: number) => { + return client.delete(`/todos/${id}`); +}; + +export const updateTodo = (id: number, data: UpdateTodoData) => { + return client.patch(`/todos/${id}`, data); +}; diff --git a/src/components/ErrorNoti/ErrorNotiflication.tsx b/src/components/ErrorNoti/ErrorNotiflication.tsx new file mode 100644 index 000000000..0cb1d1f4d --- /dev/null +++ b/src/components/ErrorNoti/ErrorNotiflication.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames'; + +type Props = { + errorMessage: string; + closeErrorHandler: () => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + closeErrorHandler, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/ErrorNoti/index.ts b/src/components/ErrorNoti/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..f67efca42 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { TodoStatus } from '../../types/TodoStatus'; +import { Todo } from '../../types/Todo'; +import { TodoStatusRoutes } from '../../constants/TodoRoutes'; + +import classNames from 'classnames'; + +type Props = { + todos: Todo[]; + selectedStatus: TodoStatus; + onStatusChange: (status: TodoStatus) => void; + filteringTodosByActiveStatus: number; + filteringTodosByCompletedStatus: number; + removeTodos: () => void; +}; + +export const Footer: React.FC = ({ + todos, + selectedStatus, + onStatusChange, + filteringTodosByActiveStatus, + filteringTodosByCompletedStatus, + removeTodos, +}) => { + if (todos.length > 0) { + return ( + + ); + } else { + return null; + } +}; 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..c875ad645 --- /dev/null +++ b/src/components/Form/Form.tsx @@ -0,0 +1,43 @@ +import { ChangeEvent, KeyboardEvent, RefObject } from 'react'; + +type Props = { + value: string; + onChange: (e: ChangeEvent) => void; + inputRef?: RefObject; + isLoading?: boolean; + addTodo?: (e: React.FormEvent) => void; + onBlur?: () => void; + onCancel?: (e: KeyboardEvent) => void; + classNames?: string; + dataCy?: string; +}; + +export const Form: React.FC = ({ + addTodo, + value, + onChange, + inputRef = null, + isLoading = false, + onBlur = () => {}, + onCancel = () => {}, + classNames, + dataCy = 'NewTodoField', +}) => { + return ( +
+ +
+ ); +}; 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..69ba2e78d --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,51 @@ +import React, { RefObject } from 'react'; +import { Todo } from '../../types/Todo'; + +import classNames from 'classnames'; +import { Form } from '../Form'; + +type Props = { + todos: Todo[]; + addTodo: (e: React.FormEvent) => void; + onChange: (e: React.ChangeEvent) => void; + inputRef: RefObject; + isLoading: boolean; + value: string; + completedTodosCount: number; + toggleAll: () => void; +}; + +export const Header: React.FC = ({ + todos, + addTodo, + onChange, + inputRef, + isLoading, + value, + completedTodosCount, + toggleAll, +}) => { + return ( +
+ {!!todos.length && ( +
+ ); +}; 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..48985c8ec --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,155 @@ +import React, { + FormEvent, + KeyboardEvent, + useCallback, + useRef, + useState, +} from 'react'; + +import { Todo } from '../../types/Todo'; +import { UpdateTodoData } from '../../types/UpdateTodoData'; +import { Form } from './../Form'; + +import classNames from 'classnames'; + +type Props = { + todo: T; + isActive: boolean; + removeTodo?: (id: number) => void; + toggleTodoStatus?: () => void; + onUpdateTodo?: (id: number, data: UpdateTodoData) => Promise; + updateProcessingTodos?: (id: number) => void; + removeProcessingTodos?: (id: number) => void; +}; + +export const TodoItem: React.FC> = ({ + todo, + isActive, + removeTodo = () => {}, + toggleTodoStatus = () => {}, + onUpdateTodo, + updateProcessingTodos, + removeProcessingTodos, +}) => { + const { completed, id, title } = todo; + + const [formActive, setFormActive] = useState(false); + const [todoTitle, setTodoTitle] = useState(''); + const inputRef = useRef(null); + + const dbClickHandler = () => { + setFormActive(true); + setTodoTitle(title); + }; + + const onEdit = useCallback(async () => { + if (!todoTitle.length) { + removeTodo?.(id); + + return; + } + + updateProcessingTodos?.(id); + + if (todoTitle !== title) { + onUpdateTodo?.(id, { title: todoTitle.trim() }) + .then(() => setFormActive(false)) + .catch(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }) + .finally(() => removeProcessingTodos?.(id)); + } else if (todoTitle === title) { + setFormActive(false); + } + }, [id, title, todoTitle]); + + const onEditHandler = (event: FormEvent) => { + event.preventDefault(); + + onEdit(); + }; + + const onBlurHandler = () => { + onEdit(); + }; + + const removeTodoHandler = () => { + removeTodo?.(id); + }; + + const keyUpHandler = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setFormActive(false); + setTodoTitle(title); + removeProcessingTodos?.(id); + } + }, + [id, removeProcessingTodos, title], + ); + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control*/} + + + {formActive ? ( + setTodoTitle(e.target.value)} + onBlur={onBlurHandler} + onCancel={e => keyUpHandler(e)} + inputRef={inputRef} + classNames="todo__title-field" + dataCy="TodoTitleField" + /> + ) : ( + <> + + {title} + + + + )} + +
+
+ {isActive &&
} +
+
+ ); +}; 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/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..52b3a91da --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { UpdateTodoData } from '../../types/UpdateTodoData'; +import { ErrorType } from '../../types/Errors'; + +type Props = { + preparedTodos: Todo[]; + processingTodos: number[]; + tempTodo: Todo | null; + removeTodo: (id: number) => void; + toggleTodoStatus: (id: number, data: UpdateTodoData) => void; + errorMessage: ErrorType | ''; + editingTodoId: number | null; + selectedEditTodoId: (id: number | null) => void; + onUpdateTodo: (id: number, data: UpdateTodoData) => Promise; + updateProcessingTodos: (id: number) => void; + removeProcessingTodos: (id: number) => void; +}; + +export const TodoList: React.FC = ({ + preparedTodos, + processingTodos, + tempTodo, + removeTodo, + toggleTodoStatus, + onUpdateTodo, + updateProcessingTodos, + removeProcessingTodos, +}) => { + return ( +
+ + {preparedTodos.map(todo => ( + + removeTodo(todo.id)} + toggleTodoStatus={() => + toggleTodoStatus(todo.id, { completed: !todo.completed }) + } + onUpdateTodo={onUpdateTodo} + updateProcessingTodos={updateProcessingTodos} + removeProcessingTodos={removeProcessingTodos} + /> + + ))} + {tempTodo && ( + + + + )} + +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/constants/TodoRoutes.tsx b/src/constants/TodoRoutes.tsx new file mode 100644 index 000000000..566f59022 --- /dev/null +++ b/src/constants/TodoRoutes.tsx @@ -0,0 +1,7 @@ +import { TodoStatus } from '../types/TodoStatus'; + +export const TodoStatusRoutes: Record = { + [TodoStatus.All]: '/', + [TodoStatus.Active]: '/active', + [TodoStatus.Completed]: '/completed', +}; diff --git a/src/types/Errors.ts b/src/types/Errors.ts new file mode 100644 index 000000000..098219054 --- /dev/null +++ b/src/types/Errors.ts @@ -0,0 +1,7 @@ +export enum ErrorType { + LOAD_TODOS = 'Unable to load todos', + ADD_TODO = 'Unable to add a todo', + DELETE_TODO = 'Unable to delete a todo', + UPDATE_TODO = 'Unable to update a todo', + EMPTY_TITLE = 'Title should not be empty', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..d9643b6d7 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,14 @@ +import { USER_ID } from '../api/todos'; + +export interface Todo { + id: number; + title: string; + completed: boolean; + userId: number; +} + +export const emptyTodo: Omit = { + completed: false, + userId: USER_ID, + title: '', +}; diff --git a/src/types/TodoStatus.ts b/src/types/TodoStatus.ts new file mode 100644 index 000000000..5a7e0d2f7 --- /dev/null +++ b/src/types/TodoStatus.ts @@ -0,0 +1,5 @@ +export enum TodoStatus { + Active = 'Active', + Completed = 'Completed', + All = 'All', +} diff --git a/src/types/UpdateTodoData.ts b/src/types/UpdateTodoData.ts new file mode 100644 index 000000000..ab50171c1 --- /dev/null +++ b/src/types/UpdateTodoData.ts @@ -0,0 +1,3 @@ +import { Todo } from './Todo'; + +export type UpdateTodoData = Partial>; diff --git a/src/utils/EmptyTodo.tsx b/src/utils/EmptyTodo.tsx new file mode 100644 index 000000000..5f22b2731 --- /dev/null +++ b/src/utils/EmptyTodo.tsx @@ -0,0 +1,9 @@ +import { Todo } from '../types/Todo'; +import { USER_ID } from '../api/todos'; + +export const emptyTodo: Todo = { + id: 0, + completed: false, + userId: USER_ID, + title: '', +}; diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 000000000..708ac4c17 --- /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', + }; + } + + // 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/utils/helpers.tsx b/src/utils/helpers.tsx new file mode 100644 index 000000000..d01dac56d --- /dev/null +++ b/src/utils/helpers.tsx @@ -0,0 +1,18 @@ +import { Todo } from '../types/Todo'; +import { TodoStatus } from '../types/TodoStatus'; + +export const filterTodosByStatus = ( + todos: Todo[], + status: TodoStatus, +): Todo[] => { + switch (status) { + case TodoStatus.Active: + return todos.filter(todo => !todo.completed); + + case TodoStatus.Completed: + return todos.filter(todo => todo.completed); + + default: + return todos; + } +};