diff --git a/src/App.tsx b/src/App.tsx index 81e011f432..1bfad25d88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,263 @@ -/* 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, useRef, useState } from 'react'; + import { UserWarning } from './UserWarning'; -const USER_ID = 0; +import * as todoServices from './api/todos'; + +import { ErrorMessage } from './types/ErrorMessage'; +import { Todo } from './types/Todo'; +import { FilterBy } from './types/FilterBy'; + +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { Error as ErrorCard } from './components/Error'; export const App: React.FC = () => { - if (!USER_ID) { + const [todos, setTodos] = useState([]); + const [tempTodo, setTempTodo] = useState(null); + const [filterBy, setFilterBy] = useState(FilterBy.all); + const [loadingTodos, setLoadingTodos] = useState([]); + + const [errorMessage, setErrorMessage] = useState(''); + + const [inputText, setInputText] = useState(''); + const inputRef = useRef(null); + + const [currentTodoList, setCurrentTodoList] = useState([]); + + const focusInput = () => { + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + useEffect(() => { + focusInput(); + + todoServices + .getTodos() + .then(setTodos) + .catch(() => { + setErrorMessage('Unable to load todos'); + setTimeout(() => setErrorMessage(''), 3000); + }); + }, []); + + const handleDeleteTodo = (id: number, callback: (arg: boolean) => void) => { + callback(true); + todoServices + .deleteTodo(id) + .then(() => setTodos(todos.filter(todo => todo.id !== id))) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + callback(false); + focusInput(); + }); + }; + + const handleDeleteAllCompletedTodos = async () => { + setLoadingTodos(todos.filter(todo => todo.completed).map(todo => todo.id)); + todoServices + .deleteArrOfTodos(todos.filter(todo => todo.completed)) + .then(res => { + const rejectedTodos = res + .map((result, index) => + result.status === 'rejected' + ? todos.filter(todo => todo.completed)[index] + : null, + ) + .filter(todo => todo !== null); + + setTodos( + todos.filter(todo => !todo.completed || rejectedTodos.includes(todo)), + ); + + if (rejectedTodos.length > 0) { + throw new Error('Some todos were not deleted'); + } + }) + .catch(() => { + setErrorMessage('Unable to delete a todo'); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + setLoadingTodos([]); + focusInput(); + }); + }; + + const handleAddingTodo = () => { + if (inputRef.current) { + inputRef.current.disabled = true; + + if (inputText.trim() === '') { + setErrorMessage('Title should not be empty'); + setTimeout(() => setErrorMessage(''), 3000); + inputRef.current.disabled = false; + focusInput(); + + return; + } + + setTempTodo({ + id: 0, + userId: todoServices.USER_ID, + title: inputText.trim(), + completed: false, + }); + + todoServices + .addPost(inputText.trim()) + .then(newTodo => { + setTodos(list => [...list, newTodo]); + setInputText(''); + }) + .catch(() => { + setErrorMessage('Unable to add a todo'); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + if (inputRef.current) { + inputRef.current.disabled = false; + } + + setTempTodo(null); + focusInput(); + }); + } + }; + + const handleUpdatingTodo = ( + updatedTodo: Todo, + callback: (arg: boolean) => void, + ): Promise => { + callback(true); + + return todoServices + .updateTodo(updatedTodo) + .then(item => + setTodos(currTodos => { + const newTodos = [...currTodos]; + const index = newTodos.findIndex(todo => todo.id === item.id); + + newTodos.splice(index, 1, updatedTodo); + + return newTodos; + }), + ) + .catch(() => { + setErrorMessage('Unable to update a todo'); + setTimeout(() => setErrorMessage(''), 3000); + throw new Error('Unable to update a todo'); + }) + .finally(() => { + callback(false); + focusInput(); + }); + }; + + const switchTodos = (arr: Todo[]) => { + setLoadingTodos(arr.map(todo => todo.id)); + todoServices + .switchTodosStatus(arr) + .then(result => { + const updatedTodos = result + .filter(res => res.status === 'fulfilled') + .map(res => res.value); + + // Now update the state with the API results + setTodos(currTodos => + currTodos.map(todo => { + const updatedTodo = updatedTodos.find(t => t.id === todo.id); + + return updatedTodo ? { ...todo, ...updatedTodo } : todo; + }), + ); + }) + .catch(() => { + setErrorMessage('Unable to update a todo'); + setTimeout(() => setErrorMessage(''), 3000); + }) + .finally(() => { + setLoadingTodos([]); + focusInput(); + }); + }; + + const handleSwitchTodosStatus = () => { + const activeTodos = todos.filter(item => !item.completed); + + if (activeTodos.length > 0) { + switchTodos(activeTodos); + + return; + } + + switchTodos(todos); + }; + + useEffect(() => { + const filteredTodos = todos.filter(item => { + return ( + filterBy === FilterBy.all || + (filterBy === FilterBy.active && !item.completed) || + (filterBy === FilterBy.completed && item.completed) + ); + }); + + setCurrentTodoList(filteredTodos); + }, [filterBy, todos]); + + if (!todoServices.USER_ID) { return ; } return ( -
-

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

- -

Styles are already copied

-
+
+

todos

+ +
+
+ + + + {/* Hide the footer if there are no todos */} + {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/api/todos.ts b/src/api/todos.ts new file mode 100644 index 0000000000..9a3933ad54 --- /dev/null +++ b/src/api/todos.ts @@ -0,0 +1,39 @@ +import { Todo } from '../types/Todo'; +import { client } from '../utils/fetchClient'; + +export const USER_ID = 2088; + +export const getTodos = () => { + return client.get(`/todos?userId=${USER_ID}`); +}; + +// Add more methods here +export const deleteTodo = (todoId: number) => { + return client.delete(`/todos/${todoId}`); +}; + +export const deleteArrOfTodos = async (arr: Todo[]) => { + const result = await Promise.allSettled(arr.map(todo => deleteTodo(todo.id))); + + return result; +}; + +export const addPost = (title: string) => { + return client.post(`/todos`, { + title, + userId: USER_ID, + completed: false, + }); +}; + +export const updateTodo = ({ id, userId, title, completed }: Todo) => { + return client.patch(`/todos/${id}`, { id, userId, title, completed }); +}; + +export const switchTodosStatus = async (arr: Todo[]) => { + const result = await Promise.allSettled( + arr.map(todo => updateTodo({ ...todo, completed: !todo.completed })), + ); + + return result; +}; diff --git a/src/components/Error/Error.tsx b/src/components/Error/Error.tsx new file mode 100644 index 0000000000..f388f1b60e --- /dev/null +++ b/src/components/Error/Error.tsx @@ -0,0 +1,33 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { ErrorMessage } from '../../types/ErrorMessage'; +import classNames from 'classnames'; + +type Props = { + errorMessage: ErrorMessage; + setErrorMessage: (arg: ErrorMessage) => void; +}; + +export const Error: React.FC = ({ errorMessage, setErrorMessage }) => { + return ( +
+
+ ); +}; diff --git a/src/components/Error/index.tsx b/src/components/Error/index.tsx new file mode 100644 index 0000000000..ae6e95d01d --- /dev/null +++ b/src/components/Error/index.tsx @@ -0,0 +1 @@ +export * from './Error'; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..27fcbef6c7 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,62 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { FilterBy } from '../../types/FilterBy'; +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; + +type Props = { + todos: Todo[]; + filterBy: FilterBy; + setFilterBy: (arg: FilterBy) => void; + deleteCompletedTodos: () => void; +}; + +export const Footer: React.FC = ({ + todos = [], + filterBy, + setFilterBy, + deleteCompletedTodos, +}) => { + const completeTodosNum = todos.filter(todo => !todo.completed).length; + + return ( +
+ + {completeTodosNum} items left + + + {/* Active link should have the 'selected' class */} + + + {/* this button should be disabled if there are no completed todos */} + + +
+ ); +}; diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx new file mode 100644 index 0000000000..ddcc5a9cd1 --- /dev/null +++ b/src/components/Footer/index.tsx @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..793764219b --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,62 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import classNames from 'classnames'; + +type Props = { + inputRef: React.RefObject; + inputText: string; + setInputText: (value: string) => void; + handleCreateTodo: () => void; + handleCompleteTodo: () => void; + todos: Todo[]; + loadingTodos: number[]; +}; + +export const Header: React.FC = ({ + inputRef, + inputText, + setInputText, + handleCreateTodo, + handleCompleteTodo, + todos, + loadingTodos, +}) => { + const includesActiveTodos = todos.filter(todo => !todo.completed).length > 0; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 0000000000..266dec8a1b --- /dev/null +++ b/src/components/Header/index.tsx @@ -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 0000000000..78283a4763 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,150 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; + +import { Todo } from '../../types/Todo'; + +type Props = { + todo: Todo; + handleDeleteTodo: (id: number, callback: (arg: boolean) => void) => void; + handleUpdateTodo: (todo: Todo, callback: (arg: boolean) => void) => void; + loading?: boolean; +}; + +export const TodoItem: React.FC = ({ + todo, + handleDeleteTodo, + handleUpdateTodo, + loading = false, +}) => { + const [isLoading, setIsLoading] = useState(loading as boolean); + const [isChanging, setIsChanging] = useState(false); + + const [currentTodo, setCurrentTodo] = useState(todo); + + useEffect(() => { + setCurrentTodo(todo); + }, [todo]); + + useEffect(() => { + setIsLoading(loading); + }, [loading]); + + const changeIsLoading = (state: boolean) => setIsLoading(state); + + const onTodoUpdate = async () => { + currentTodo.title = currentTodo.title.trim(); + if (currentTodo.title === '') { + handleDeleteTodo(currentTodo.id, changeIsLoading); + + return; + } + + if (currentTodo.title === todo.title) { + setIsChanging(false); + + return; + } + + try { + await handleUpdateTodo(currentTodo, setIsLoading); + + setIsChanging(false); + } catch (err) { + setIsChanging(true); + } + }; + + const setTodoCompleted = async () => { + try { + const newTodo = { ...currentTodo, completed: !currentTodo.completed }; + + await handleUpdateTodo(newTodo, setIsLoading); + + setCurrentTodo(newTodo); + } catch (err) { + setCurrentTodo(todo); + } + }; + + const handleDoubleClick = () => { + setIsChanging(true); + }; + + const handleOnChange = (e: React.ChangeEvent) => { + setCurrentTodo({ ...currentTodo, title: e.target.value }); + }; + + const handleOnKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onTodoUpdate(); + } + + if (e.key === 'Escape') { + setIsChanging(false); + setCurrentTodo(todo); + } + }; + + const handleOnBlur = () => { + onTodoUpdate(); + }; + + return ( +
+ + + {isChanging ? ( + handleOnBlur()} + onChange={e => handleOnChange(e)} + onKeyDown={e => handleOnKeyDown(e)} + autoFocus + /> + ) : ( + handleDoubleClick()} + > + {currentTodo.title} + + )} + + {!isChanging && ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/index.tsx b/src/components/TodoItem/index.tsx new file mode 100644 index 0000000000..21f4abac39 --- /dev/null +++ b/src/components/TodoItem/index.tsx @@ -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 0000000000..5664500a9c --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,45 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { Todo } from '../../types/Todo'; +import { TodoItem } from '../TodoItem/TodoItem'; + +type Props = { + todos: Todo[]; + handleDeleteTodo: (id: number, callback: (arg: boolean) => void) => void; + handleUpdateTodo: (todo: Todo, callback: (arg: boolean) => void) => void; + loadingTodos: number[]; + tempTodo: Todo | null; +}; + +export const TodoList: React.FC = ({ + todos, + handleDeleteTodo, + handleUpdateTodo, + loadingTodos, + tempTodo, +}) => { + return ( +
+ {todos.map(todo => { + return ( + + ); + })} + {tempTodo && ( + + )} +
+ ); +}; diff --git a/src/components/TodoList/index.tsx b/src/components/TodoList/index.tsx new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/src/components/TodoList/index.tsx @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/types/ErrorMessage.ts b/src/types/ErrorMessage.ts new file mode 100644 index 0000000000..ed8d297809 --- /dev/null +++ b/src/types/ErrorMessage.ts @@ -0,0 +1,7 @@ +export type ErrorMessage = + | '' + | 'Unable to load todos' + | 'Title should not be empty' + | 'Unable to add a todo' + | 'Unable to delete a todo' + | 'Unable to update a todo'; diff --git a/src/types/FilterBy.tsx b/src/types/FilterBy.tsx new file mode 100644 index 0000000000..768315c0da --- /dev/null +++ b/src/types/FilterBy.tsx @@ -0,0 +1,5 @@ +export enum FilterBy { + all = 'All', + completed = 'Completed', + active = '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'), +};