diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..70ec0fa1c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,259 @@ -/* eslint-disable max-len */ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { + useCallback, + useEffect, + useState, + useMemo, + useRef, +} from 'react'; import { UserWarning } from './UserWarning'; +import { getTodos, USER_ID } from './api/api'; +import * as todoService from './api/api'; -const USER_ID = 0; +import { Todo } from './types/Todo'; +import { Filter } from './types/Filter'; + +import { Header } from './components/Header'; +import { TodoList } from './components/TodoLIst'; +import { Footer } from './components/Footer'; +import { Notification } from './components/Notification'; export const App: React.FC = () => { + const [todosFromServer, setTodosFromServer] = useState([]); + const [filter, setFilter] = useState(Filter.All); + const [tempTodo, setTempTodo] = useState(null); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [notification, setNotification] = useState({ + isHidden: true, + message: '', + }); + + const inputRef = useRef(null); + + const showNotification = (message: string) => { + setNotification({ isHidden: false, message }); + setTimeout(() => setNotification({ isHidden: true, message: '' }), 3000); + }; + + const filterTodos = useCallback((todos: Todo[], filterBy: Filter): Todo[] => { + switch (filterBy) { + case Filter.Completed: + return todos.filter(todo => todo.completed); + + case Filter.Active: + return todos.filter(todo => !todo.completed); + + default: + return todos; + } + }, []); + + const visibleTodos = useMemo( + () => filterTodos(todosFromServer, filter), + [filterTodos, todosFromServer, filter], + ); + + const activeTodosCount = useMemo( + () => todosFromServer.filter(todo => !todo.completed).length, + [todosFromServer], + ); + + const allTodosCompleted = useMemo( + () => activeTodosCount === 0, + [activeTodosCount], + ); + + const hasCompletedTodos = useMemo( + () => todosFromServer.some(todo => todo.completed), + [todosFromServer], + ); + + const handleAddTodo = (title: string) => { + setNotification({ isHidden: true, message: '' }); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle.length) { + showNotification('Title should not be empty'); + + return Promise.reject('Title is empty'); + } + + setTempTodo({ + title: trimmedTitle, + userId: USER_ID, + completed: false, + id: 0, + }); + + return todoService + .createTodo({ title: trimmedTitle, userId: USER_ID, completed: false }) + .then(newTodo => { + setTodosFromServer(currentTodos => [...currentTodos, newTodo]); + }) + .catch(error => { + showNotification('Unable to add a todo'); + throw new Error(error); + }) + .finally(() => { + setTempTodo(null); + }); + }; + + const handleDeleteTodo = (todoId: number) => { + setLoadingTodoIds([todoId]); + + return todoService + .deleteTodo(todoId) + .then(() => { + setTodosFromServer(curr => curr.filter(todo => todo.id !== todoId)); + }) + .catch(error => { + showNotification('Unable to delete a todo'); + throw new Error(error); + }) + .finally(() => { + setLoadingTodoIds([]); + if (inputRef.current) { + inputRef.current.focus(); + } + }); + }; + + const handleClearCompletedTodos = () => { + const completedTodoIds = todosFromServer + .filter(todo => todo.completed) + .map(todo => todo.id); + + setLoadingTodoIds(completedTodoIds); + Promise.all( + completedTodoIds.map(id => + todoService + .deleteTodo(id) + .then(() => { + setTodosFromServer(curr => curr.filter(todo => todo.id !== id)); + }) + .catch(error => { + showNotification('Unable to delete a todo'); + throw new Error(error); + }) + .finally(() => { + setLoadingTodoIds([]); + if (inputRef.current) { + inputRef.current.focus(); + } + }), + ), + ); + }; + + const handleUpdateTodo = (updatedTodo: Todo) => { + setLoadingTodoIds([updatedTodo.id]); + + return todoService + .updateTodo(updatedTodo) + .then(receivedTodo => { + setTodosFromServer(curr => + curr.map(todo => (todo.id === receivedTodo.id ? receivedTodo : todo)), + ); + }) + .catch(error => { + showNotification('Unable to update a todo'); + throw new Error(error); + }) + .finally(() => { + setLoadingTodoIds([]); + }); + }; + + const handleToggleAllTodoStatus = () => { + let todosToChange = []; + + if (allTodosCompleted) { + todosToChange = [...todosFromServer]; + } else { + todosToChange = todosFromServer.filter(todo => !todo.completed); + } + + const todoToChangeIds = todosToChange.map(todo => todo.id); + + setLoadingTodoIds(todoToChangeIds); + Promise.all( + todosToChange.map(todoToChange => { + const { id, completed, title, userId } = todoToChange; + + todoService + .updateTodo({ id, completed: !completed, title, userId }) + .then(receivedTodo => { + setTodosFromServer(curr => + curr.map(todo => + todo.id === receivedTodo.id ? receivedTodo : todo, + ), + ); + }) + .catch(error => { + showNotification('Unable to update a todo'); + throw new Error(error); + }) + .finally(() => { + setLoadingTodoIds([]); + }); + }), + ); + }; + + useEffect(() => { + getTodos() + .then(setTodosFromServer) + .catch(() => { + showNotification('Unable to load todos'); + }); + }, []); + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + {!!todosFromServer.length && ( + + )} + + {!!todosFromServer.length && ( +
+ )} +
+ + setNotification({ ...notification, isHidden: true })} + /> +
); }; diff --git a/src/api/api.tsx b/src/api/api.tsx new file mode 100644 index 0000000000..f645d587c3 --- /dev/null +++ b/src/api/api.tsx @@ -0,0 +1,22 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClients'; + +export const USER_ID = 2159; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +export function createTodo({ title, userId, completed }: Omit) { + return client.post(`/todos`, { title, userId, completed }); +} + +export function deleteTodo(todoId: number) { + return client.delete(`/todos/${todoId}`); +} + +export function updateTodo(data: Todo): Promise { + const { id } = data; + + return client.patch(`/todos/${id}`, data); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..9c3557e846 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,57 @@ +import classNames from 'classnames'; +import { Filter } from '../types/Filter'; + +type Props = { + activeTodosCount: number; + currFilter: Filter; + hasCompletedTodos: boolean; + onFilter: (newFilter: Filter) => void; + onClearCompletedTodos: () => void; +}; + +export const Footer: React.FC = ({ + activeTodosCount, + currFilter, + hasCompletedTodos, + onFilter, + onClearCompletedTodos, +}) => { + return ( +
+ + {`${activeTodosCount} items left`} + + + + + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000000..5f1b2d7eca --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,73 @@ +import classNames from 'classnames'; +import { useEffect } from 'react'; + +type Props = { + inputRef: React.RefObject; + hasTodos: boolean; + allCompletedTodos: boolean; + onAddTodo: (value: string) => Promise; + onToggleAll: () => void; +}; + +export const Header: React.FC = ({ + inputRef, + hasTodos, + allCompletedTodos, + onAddTodo, + onToggleAll, +}) => { + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [inputRef]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const inputElement = inputRef.current; + + if (inputElement) { + inputElement.disabled = true; + + onAddTodo(inputElement.value) + .then(() => { + if (inputElement) { + inputElement.value = ''; + } + }) + .catch(() => {}) + .finally(() => { + if (inputElement) { + inputElement.disabled = false; + inputElement.focus(); + } + }); + } + }; + + return ( +
+ {hasTodos && ( +
+ ); +}; diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx new file mode 100644 index 0000000000..23910284ef --- /dev/null +++ b/src/components/Notification.tsx @@ -0,0 +1,31 @@ +import classNames from 'classnames'; + +type Props = { + message: string; + isHidden: boolean; + onClose: (value: boolean) => void; +}; + +export const Notification: React.FC = ({ + message, + isHidden, + onClose, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..bb59367ecb --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,124 @@ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useRef, useState } from 'react'; + +type Props = { + todo: Todo; + isLoading?: boolean; + onDeleteTodo?: (value: number) => Promise; + onUpdateTodo?: (updatedTodo: Todo) => Promise; +}; + +export const TodoItem: React.FC = ({ + todo, + onDeleteTodo = () => {}, + isLoading = false, + onUpdateTodo = () => {}, +}) => { + const { completed, id, title: todoTitle, userId } = todo; + + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(todoTitle); + const inputEdit = useRef(null); + + const handleEditSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = editedTitle.trim(); + + if (trimmedTitle === todoTitle) { + setIsEditing(false); + + return; + } + + if (!trimmedTitle) { + onDeleteTodo(id) + ?.then(() => setIsEditing(false)) + .catch(() => {}); + + return; + } + + onUpdateTodo({ id, userId, completed, title: trimmedTitle }) + ?.then(() => setIsEditing(false)) + .catch(() => {}); + }; + + const handleEditCancel = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + } + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {isEditing ? ( +
+ setEditedTitle(event.target.value)} + onBlur={handleEditSubmit} + autoFocus + /> +
+ ) : ( + <> + { + setIsEditing(true); + setEditedTitle(todoTitle); + }} + > + {todoTitle} + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoLIst.tsx b/src/components/TodoLIst.tsx new file mode 100644 index 0000000000..04008b14d0 --- /dev/null +++ b/src/components/TodoLIst.tsx @@ -0,0 +1,34 @@ +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + tempTodo: Todo | null; + onDeleteTodo: (value: number) => Promise; + loading: number[]; + onUpdateTodo: (updatedTodo: Todo) => Promise; +}; + +export const TodoList: React.FC = ({ + todos, + tempTodo, + onDeleteTodo, + loading, + onUpdateTodo, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} + + {tempTodo && } +
+ ); +}; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..174408fd69 --- /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 0000000000..3f52a5fdde --- /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/fetchClients.ts b/src/utils/fetchClients.ts new file mode 100644 index 0000000000..708ac4c17b --- /dev/null +++ b/src/utils/fetchClients.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'), +};