diff --git a/README.md b/README.md index 47a1add059..73be725940 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://Nika-Andriy.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 81e011f432..ddb56f77f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,187 @@ -/* 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, { useEffect, useState } from 'react'; import { UserWarning } from './UserWarning'; - -const USER_ID = 0; +import { deleteTodo, getTodos, patchTodo, USER_ID } from './api/todos'; +import { NewTodoList } from './components/NewTodoList'; +import { Todo } from './types/Todo'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { FILTER } from './api/filter'; +import { Filter } from './types/Filter'; +import { TodoItem } from './components/TodoItem'; +import { ErrorMessage } from './types/ErrorMessage'; +import { ErrorNotification } from './components/ErrorNotification'; export const App: React.FC = () => { + const [title, setTitle] = useState(''); + const [todos, setTodos] = useState([]); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [filter, setFilter] = useState(FILTER.all); + const [tempTodo, setTempTodo] = useState(null); + const [deletingTodoIds, setDeletingTodoIds] = useState([]); + const [updatingTodoIds, setUpdatingTodoIds] = useState([]); + + const isTodosNotEmpty = todos.length > 0; + + const loadingPosts = () => { + setIsLoading(true); + + getTodos() + .then(setTodos) + .catch(() => setErrorMessage(ErrorMessage.LOAD_TODO)) + .finally(() => setIsLoading(false)); + }; + + useEffect(() => { + loadingPosts(); + }, []); + + useEffect(() => { + if (!errorMessage) { + return; + } + + const timerId = window.setTimeout(() => { + setErrorMessage(''); + }, 3000); + + return () => window.clearTimeout(timerId); + }, [errorMessage]); + + const getVisibleTodos = () => { + switch (filter) { + case FILTER.active: + return todos.filter(todo => !todo.completed); + + case FILTER.completed: + return todos.filter(todo => todo.completed); + + default: + return todos; + } + }; + + const visibleTodos = getVisibleTodos(); + + const handleDelete = (todoId: number) => { + setIsDeleting(true); + setDeletingTodoIds(ids => [...ids, todoId]); + + return deleteTodo(todoId) + .then(() => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.id !== todoId), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessage.DELETE_TODO); + throw new Error(); + }) + .finally(() => { + setIsDeleting(false); + setDeletingTodoIds(ids => ids.filter(id => id !== todoId)); + }); + }; + + const updateTodo = (todo: Todo, newTodo: Todo) => { + setUpdatingTodoIds(ids => [...ids, todo.id]); + + return patchTodo(newTodo) + .then(() => { + setTodos(current => + current.map(item => (item.id === todo.id ? newTodo : item)), + ); + }) + .catch(() => { + setErrorMessage(ErrorMessage.UPDATE_TODO); + throw new Error(); + }) + .finally(() => { + setUpdatingTodoIds(ids => ids.filter(id => id !== todo.id)); + }); + }; + + const handleChecked = (todo: Todo) => { + updateTodo(todo, { + ...todo, + completed: !todo.completed, + }); + }; + + const handleToggleAll = (posts: Todo[]) => { + const areAllTodosCompleted = posts.every(t => t.completed); + + if (!areAllTodosCompleted) { + posts.forEach(t => { + if (!t.completed) { + handleChecked(t); + } + }); + } else { + posts.forEach(t => { + if (t.completed) { + handleChecked(t); + } + }); + } + }; + + const handleHideErrorMessage = () => { + setErrorMessage(''); + }; + if (!USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+ + +
+ + {tempTodo && } +
+ + {isTodosNotEmpty && ( +
+ )} +
+ + +
); }; diff --git a/src/api/filter.ts b/src/api/filter.ts new file mode 100644 index 0000000000..6d8a2e127d --- /dev/null +++ b/src/api/filter.ts @@ -0,0 +1,5 @@ +export const FILTER = { + all: 'all', + completed: 'completed', + active: 'active', +} as const; diff --git a/src/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..569a7a54ea --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,21 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 4198; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here +export const patchTodo = ({ id, ...todoData }: Todo) => { + return client.patch(`/todos/${id}`, todoData); +}; + +export const postTodo = ({ title, userId, completed }: Omit) => { + return client.post('/todos', { title, userId, completed }); +}; + +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; diff --git a/src/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx new file mode 100644 index 0000000000..e5852dede5 --- /dev/null +++ b/src/components/ErrorNotification.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + errorMessage: string; + onHide: () => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + onHide, +}) => { + return ( +
+
+ ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000000..f13bdf8d43 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { Filter } from '../types/Filter'; +import classNames from 'classnames'; +import { FILTER } from '../api/filter'; + +type Props = { + todos: Todo[]; + filter: Filter; + setFilter: (filter: Filter) => void; + onDelete: (todoId: number) => void; +}; + +export const Footer: React.FC = ({ + todos, + filter, + setFilter, + onDelete, +}) => { + const activeTodos = todos.filter(todo => !todo.completed); + const completedTodos = todos.filter(todo => todo.completed); + const isCompletedTodos = todos.some(todo => todo.completed); + + return ( + + ); +}; diff --git a/src/components/NewTodoList.tsx b/src/components/NewTodoList.tsx new file mode 100644 index 0000000000..a0deb35cc0 --- /dev/null +++ b/src/components/NewTodoList.tsx @@ -0,0 +1,107 @@ +import classNames from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { postTodo, USER_ID } from '../api/todos'; +import { ErrorMessage } from '../types/ErrorMessage'; + +type Props = { + todos: Todo[]; + title: string; + isLoading?: boolean; + isDeleting: boolean; + setTempTodo: (t: Todo | null) => void; + setTitle: (t: string) => void; + setTodos: (t: Todo[]) => void; + setErrorMessage: (m: string) => void; + setIsLoading: (l: boolean) => void; + onToggleAll: (todos: Todo[]) => void; +}; + +export const NewTodoList: React.FC = ({ + todos, + title, + setTitle, + isDeleting, + setTodos, + setErrorMessage, + setIsLoading, + setTempTodo, + onToggleAll, +}) => { + const [isPosting, setIsPosting] = useState(false); + const inputRef = useRef(null); + const allCompleted = todos.length > 0 && todos.every(t => t.completed); + + useEffect(() => { + inputRef.current?.focus(); + }, [isPosting, isDeleting]); + + const handleChange: React.ChangeEventHandler = event => { + setTitle(event.target.value); + }; + + const handleSubmit: React.FormEventHandler = event => { + event.preventDefault(); + setIsLoading(true); + setIsPosting(true); + const trimedTitle = title.trim(); + + if (!trimedTitle) { + setErrorMessage(ErrorMessage.CHECK_TITLE); + setIsLoading(false); + setIsPosting(false); + + return; + } + + const tempTodo: Todo = { + title: trimedTitle, + completed: false, + userId: USER_ID, + id: 0, + }; + + setTempTodo(tempTodo); + + postTodo(tempTodo) + .then(createdTodo => { + setTodos([...todos, createdTodo]); + setTitle(''); + }) + .catch(() => setErrorMessage(ErrorMessage.ADD_TODO)) + .finally(() => { + setIsLoading(false); + setIsPosting(false); + setTempTodo(null); + inputRef.current?.focus(); + }); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000000..29047db9d0 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; +import { ErrorMessage } from '../types/ErrorMessage'; + +type Props = { + todo: Todo; + isLoading?: boolean; + updateTodo?: (todo: Todo, newTodo: Todo) => Promise; + setErrorMessage?: (error: string) => void; + onDelete?: (todoId: number) => Promise; + onChecked?: (todo: Todo) => void; +}; + +export const TodoItem: React.FC = ({ + todo, + isLoading, + updateTodo, + setErrorMessage, + onDelete, + onChecked, +}) => { + const [isTodoEditing, setIsTodoEditing] = useState(false); + const [editTitle, setEditTitle] = useState(todo.title); + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsTodoEditing(false); + setEditTitle(todo.title); + } + }; + + const handleChange: React.ChangeEventHandler = event => { + setEditTitle(event.target.value); + }; + + const handleEditTodo = (t: Todo) => { + const normalizedTitle = editTitle.trim(); + + if (todo.title === normalizedTitle) { + setIsTodoEditing(false); + + return; + } + + if (normalizedTitle === '') { + onDelete?.(todo.id) + .then(() => { + setIsTodoEditing(false); + }) + .catch(() => {}); + + return; + } + + updateTodo?.(t, { + ...t, + title: normalizedTitle, + })?.then(() => { + setIsTodoEditing(false); + }); + }; + + const handleSubmit: React.FormEventHandler = event => { + event.preventDefault(); + + try { + handleEditTodo(todo); + } catch (error) { + setErrorMessage?.(ErrorMessage.UPDATE_TODO); + } + }; + + return ( +
+ + + {!isTodoEditing ? ( + <> + setIsTodoEditing(true)} + > + {todo.title} + + + + + ) : ( +
+ +
+ )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000000..5fbaa2016f --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; + deletingTodoIds: number[] | null; + updatingTodoIds: number[] | null; + setErrorMessage?: (error: string) => void; + updateTodo?: (todo: Todo, newTodo: Todo) => Promise; + onDelete?: (todoId: number) => Promise; + onChecked?: (todo: Todo) => void; +}; + +export const TodoList: React.FC = ({ + todos, + deletingTodoIds, + updatingTodoIds, + setErrorMessage, + updateTodo, + onDelete, + onChecked, +}) => { + return ( + <> + {todos.map(todo => { + return ( + + ); + })} + + ); +}; diff --git a/src/constants/filter.ts b/src/constants/filter.ts new file mode 100644 index 0000000000..bfbedd26c0 --- /dev/null +++ b/src/constants/filter.ts @@ -0,0 +1,7 @@ +export const FILTERS = { + all: 'all', + active: 'active', + completed: 'completed', +} as const; + +export type Filter = (typeof FILTERS)[keyof typeof FILTERS]; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..8b042451cb --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + LOAD_TODO = 'Unable to load todos', + ADD_TODO = 'Unable to add a todo', + UPDATE_TODO = 'Unable to update a todo', + DELETE_TODO = 'Unable to delete a todo', + CHECK_TITLE = 'Title should not be empty', +} diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..f04ce41bfe --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1 @@ +export type Filter = 'all' | 'completed' | 'active'; 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/fetchClient.ts b/src/utils/fetchClient.ts new file mode 100644 index 0000000000..708ac4c17b --- /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'), +};